Add editable panels and refine shell settings (#18)

* Integrate panel composition, adaptive sizing, and overflow

* Add instance editing controls to panel settings

* Add panel group editing and measured composition preview

* Reorganize panel settings and add drag-and-drop item editing

* Simplify panel settings and place inspectors beside the editor

* Refine Panel & Shell settings, previews, and panel rendering

* Organize settings navigation and modernize packaged panel config

* Support top and bottom panels and refine shell surfaces

Add edge-aware settings, previews, popup placement, notifications, margins, and corner rendering. Preserve translucency in notification and dialog controls, and tighten spacing around the clock and display-mode icon.

* Add workspace styles and fix Settings text rendering

* Make panel allocation independent of measurement order

* Normalize panel item settings and visibility

* Move shell geometry into each panel

* Add independent group surfaces and finalize panel configuration

* Keep panel fit checks and restoration consistent with allocation

* Reject unknown panel item options and namespace metadata

* Reject invalid shell startup configuration and remove old radius fields

* Discover workspace application icons in a background task

* Remove the panel architecture document and improve reference prose

* Let panel media fill available space and improve Settings scrolling

* Preserve panel labels across clipped GPU rendering

* Use matching alpine lake wallpapers for light and dark modes

* Fix panel accessibility and popup lifecycle

Apply Reduce Transparency to panel opacity in the shell, compositor, and
Settings preview. Reject unknown theme tokens before snapshot substitution
and validate the current geometry fields in both consumers.

Dismiss popovers and workspace tooltips after panel layout changes. Handle
bar pointer exits directly and recover workspace hover after reentry without
repeated motion updates. Cover the transitions with regression tests and
update the wallpaper dimensions check for the bundled lake defaults.

* Use tagged Ferese toolkit fixes

* Fix center-only panel spacing and headless text rendering

* Bundle native 4K abstract wallpapers for both appearances

* Dismiss workspace tooltips before captured pointer dispatch

* Verify installed JPEG wallpapers when replacing a release
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-10 18:50:37 +01:00
1 parent d4c8c10b39
commit 65fcaef13f
84 files changed
+12809 -1258

No files matched your search

+3 -1
View File
@@ -145,6 +145,8 @@ def validate_bundle(bundle, *, installed=False):
allowed = expected.get(target)
# Older releases bundled this retired source asset. Keep their manifests
# verifiable for upgrades and rollback without adding it to new bundles.
if allowed is None and installed and re.fullmatch(r'wallpapers/[A-Za-z0-9._-]+\.jpe?g', target):
allowed = {'target': target, 'mode': '0644'}
if allowed is None and target == 'wallpapers/ferese.svg':
allowed = {'target': target, 'mode': '0644'}
if allowed is None and re.fullmatch(r'licenses/theme-[A-Za-z0-9._-]+\.txt', target):
@@ -167,7 +169,7 @@ def validate_bundle(bundle, *, installed=False):
# Installed releases may predate the appearance wallpapers and license.
# New bundles must include them; upgrades and rollback verify the older
# release's recorded files without requiring these later additions.
required = required - {'wallpapers/ferese-wallpaper-dark.jpg', 'wallpapers/ferese-wallpaper-light.png',
required = required - {'wallpapers/ferese-wallpaper-dark.png', 'wallpapers/ferese-wallpaper-light.png',
'licenses/Ferese-LICENSE.txt'}
if not required <= seen or actual != seen | {'manifest.json'}:
+2 -2
View File
@@ -106,8 +106,8 @@
"mode": "0644"
},
{
"source": "assets/wallpapers/ferese-wallpaper-dark.jpg",
"target": "wallpapers/ferese-wallpaper-dark.jpg",
"source": "assets/wallpapers/ferese-wallpaper-dark.png",
"target": "wallpapers/ferese-wallpaper-dark.png",
"mode": "0644"
},
{
+1 -3
View File
@@ -22,9 +22,7 @@ def snapshot(appearance, accent, reduced_motion, contrast):
"material": {"style": "solid", "opacity": 1.0, "blur_radius": 0.0, "tint_strength": 1.0},
"geometry": {
"border_width": 1.0, "focus_ring_width": 2.0, "window_radius": 14.0,
"shell_radius": 14.0, "top_bar_height": 28.0, "top_bar_margin_top": 0,
"top_bar_margin_horizontal": 0, "top_bar_window_gap": 0,
"panel_padding": 12.0, "control_gap": 12.0,
"shell_radius": 14.0, "control_gap": 12.0,
},
"typography": {"font_family": "Inter"},
"background": {"path": "", "lock_path": None, "mode": "fill"},
+32 -1
View File
@@ -113,7 +113,7 @@ m.main()
self.assertEqual((self.bundles[0] / name).stat().st_mode & 0o777, 0o755)
def without_appearance_wallpapers(self, bundle):
self.without_files(bundle, {'wallpapers/ferese-wallpaper-dark.jpg', 'wallpapers/ferese-wallpaper-light.png'})
self.without_files(bundle, {'wallpapers/ferese-wallpaper-dark.png', 'wallpapers/ferese-wallpaper-light.png'})
def without_files(self, bundle, removed):
manifest_path = bundle / 'manifest.json'
@@ -153,6 +153,37 @@ m.main()
with self.assertRaisesRegex(ValueError, 'Checksum mismatch'):
installer.Installation(self.root).manifest('first')
def replace_dark_wallpaper_with_jpeg(self, bundle):
source = bundle / 'wallpapers/ferese-wallpaper-dark.png'
source.rename(source.with_suffix('.jpg'))
path = bundle / 'manifest.json'
manifest = json.loads(path.read_text())
for item in manifest['files']:
if item['target'] == 'wallpapers/ferese-wallpaper-dark.png':
item['target'] = 'wallpapers/ferese-wallpaper-dark.jpg'
path.write_text(json.dumps(manifest))
def test_upgrade_and_rollback_verify_installed_jpeg_wallpaper(self):
self.install()
installed = self.base / 'releases/first'
self.replace_dark_wallpaper_with_jpeg(installed)
self.assertEqual(installer.Installation(self.root).manifest('first')['release'], 'first')
self.install(1)
self.assertTrue((self.base / 'current/wallpapers/ferese-wallpaper-dark.png').is_file())
self.assertFalse((self.base / 'current/wallpapers/ferese-wallpaper-dark.jpg').exists())
self.run_command('rollback')
self.assertTrue((self.base / 'current/wallpapers/ferese-wallpaper-dark.jpg').is_file())
(installed / 'wallpapers/ferese-wallpaper-dark.jpg').write_bytes(b'corrupt asset')
with self.assertRaisesRegex(ValueError, 'Checksum mismatch: wallpapers/ferese-wallpaper-dark.jpg'):
installer.Installation(self.root).manifest('first')
def test_new_bundle_rejects_jpeg_in_place_of_required_png(self):
changed = Path(self.temporary.name) / 'jpeg-bundle'
shutil.copytree(self.bundles[0], changed)
self.replace_dark_wallpaper_with_jpeg(changed)
with self.assertRaisesRegex(ValueError, 'Invalid inventory entry: wallpapers/ferese-wallpaper-dark.jpg'):
installer.validate_bundle(changed)
def test_new_bundle_requires_appearance_wallpapers(self):
incomplete = Path(self.temporary.name) / 'incomplete'
shutil.copytree(self.bundles[0], incomplete)
+28 -7
View File
@@ -16,6 +16,18 @@ import time
import unittest
def panel_config(preset):
surface, group_surface = ("solid", "none") if preset == "continuous" else ("none", "island")
return f'''panel main {{
surface "{surface}"
group-surface "{group_surface}"
start {{ group navigation {{ island-padding 12; item overview kind=overview; item spaces kind=workspaces; }}; }}
center {{ group title {{ island-padding 12; item title kind=focused-window; }}; }}
end {{ group status {{ island-padding 12; item media kind=media; item quick kind=quick-settings; item network kind=network; item audio kind=audio; item recording kind=recording; item notifications kind=notifications; item battery kind=battery; item clock kind=clock; item display kind=display-mode; }}; }}
}}
'''
@unittest.skipUnless(os.environ.get("FERESE_TEST_SHELL_MOTION") == "1", "opt-in nested shell test")
class ShellMotionTest(unittest.TestCase):
def test_notifications_and_modals_follow_frames_and_finish_closing(self):
@@ -35,7 +47,7 @@ class ShellMotionTest(unittest.TestCase):
config_file = config / "config.kdl"
config_file.write_text(
'animations { speed 0.5; }\n'
f'status {{ keybinding-guide #false; bar-layout "{bar_layout}"; bar-island-padding 12; }}\n')
'status { keybinding-guide #false; }\n' + panel_config(bar_layout))
display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
@@ -104,7 +116,7 @@ class ShellMotionTest(unittest.TestCase):
return None
updates = re.findall(
r'ferese_surface_effects_v1[@#]' + effects[-1] + r'\.set_regions\(array\[(\d+)\]\)', trace)
r'ferese_surface_effects_v1[@#]' + effects[-1] + r'\.set_presentation\(\d+, array\[(\d+)\],', trace)
return int(updates[-1]) // 20 if updates else None
def bar_input_rectangles(surface):
@@ -121,9 +133,20 @@ class ShellMotionTest(unittest.TestCase):
if not creations:
return []
return [tuple(map(int, values)) for values in re.findall(
rectangles = [tuple(map(int, values)) for values in re.findall(
r'wl_region[@#]' + region + r'\.add\((-?\d+), (-?\d+), (\d+), (\d+)\)',
trace[creations[-1].start():update.start()])]
# Wayland regions are unions. Decorated groups can include
# redundant child control rectangles without changing coverage.
def contains(outer, inner):
x, y, width, height = outer
ix, iy, iw, ih = inner
return x <= ix and y <= iy and x + width >= ix + iw and y + height >= iy + ih
return [rectangle for index, rectangle in enumerate(rectangles)
if not any(other != rectangle and contains(other, rectangle)
or other == rectangle and other_index < index
for other_index, other in enumerate(rectangles))]
with log_path.open("w") as log:
try:
@@ -179,9 +202,7 @@ class ShellMotionTest(unittest.TestCase):
if bar_layout == "islands":
for layout_name, expected_count in [("continuous", 1), ("islands", None)]:
source = config_file.read_text()
config_file.write_text(re.sub(
r'bar-layout "[^"]+"', f'bar-layout "{layout_name}"', source))
config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n' + panel_config(layout_name))
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True)
wait_for(lambda: bar_region_count(bar) == (expected_count or 2))
@@ -192,7 +213,7 @@ class ShellMotionTest(unittest.TestCase):
for padding in [4, 0]:
source = config_file.read_text()
config_file.write_text(re.sub(
r'bar-island-padding \d+', f'bar-island-padding {padding}', source))
r'island-padding \d+', f'island-padding {padding}', source))
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True)
expected_width = original_input[0][2] - 2 * (12 - padding)
wait_for(lambda: len(bar_input_rectangles(bar)) == 2
+3 -1
View File
@@ -87,8 +87,10 @@ class ThemeTest(unittest.TestCase):
rejected = until(lambda: (snapshot := command("theme", "status"))["error"] and snapshot)
self.assertEqual(rejected["theme"], ready["theme"])
replace(shared, 'colors { accent "#8F5300"; }; future-token 1\n')
unknown = until(lambda: (snapshot := command("theme", "status"))["error"] and "future_token" in snapshot["error"] and snapshot)
self.assertEqual(unknown["theme"], ready["theme"])
replace(shared, 'colors { accent "#8F5300"; }\n')
repaired = until(lambda: (snapshot := command("theme", "get"))["error"] is None and snapshot["theme"]["requested_accent"] == "#8F5300" and snapshot["presented"] == snapshot["theme"] and snapshot)
self.assertTrue(any("future_token" in warning for warning in repaired["warnings"]))
time.sleep(.35)
self.assertEqual(command("theme", "get")["revision"], repaired["revision"])
replace(source, source.read_text().replace('file "themes/shared.kdl"', 'file "themes/missing.kdl"'))