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
+12657 -1106

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
run: | run: |
dnf install -y --allowerasing python3 python3-gobject python3-pillow git curl gcc gcc-c++ make cmake pkgconf-pkg-config \ dnf install -y --allowerasing python3 python3-gobject python3-pillow git curl gcc gcc-c++ make cmake pkgconf-pkg-config \
wayland-devel wayland-protocols-devel libxkbcommon-devel libinput-devel systemd-devel libseat-devel \ wayland-devel wayland-protocols-devel libxkbcommon-devel libinput-devel systemd-devel libseat-devel \
mesa-libgbm-devel mesa-libEGL-devel mesa-dri-drivers libdrm-devel fontconfig-devel \ mesa-libgbm-devel mesa-libEGL-devel mesa-dri-drivers mesa-vulkan-drivers libdrm-devel fontconfig-devel \
freetype-devel expat-devel dbus-daemon dbus-tools desktop-file-utils foot pam xorg-x11-xauth \ freetype-devel expat-devel dbus-daemon dbus-tools desktop-file-utils foot pam xorg-x11-xauth \
clang clang-devel pipewire-devel pipewire wireplumber polkit polkit-libs libei \ clang clang-devel pipewire-devel pipewire wireplumber polkit polkit-libs libei \
xdg-desktop-portal xdg-desktop-portal-gtk \ xdg-desktop-portal xdg-desktop-portal-gtk \
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y build-essential pkg-config dbus-daemon libwayland-dev \ sudo apt-get install -y build-essential pkg-config dbus-daemon libwayland-dev \
libxkbcommon-dev xkb-data libinput-dev libudev-dev libseat-dev libgbm-dev \ libxkbcommon-dev xkb-data libinput-dev libudev-dev libseat-dev libgbm-dev \
libegl-dev libegl-mesa0 libgl1-mesa-dri libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev xauth \ libegl-dev libegl-mesa0 libgl1-mesa-dri mesa-vulkan-drivers libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev xauth \
clang libclang-dev libpipewire-0.3-dev libspa-0.2-dev libglib2.0-dev libgstreamer1.0-dev libpam-modules clang libclang-dev libpipewire-0.3-dev libspa-0.2-dev libglib2.0-dev libgstreamer1.0-dev libpam-modules
- name: Create private test runtime directory - name: Create private test runtime directory
run: install -d -m 0700 "$XDG_RUNTIME_DIR" run: install -d -m 0700 "$XDG_RUNTIME_DIR"
Generated
+35 -18
View File
@@ -705,7 +705,7 @@ dependencies = [
[[package]] [[package]]
name = "build_helpers" name = "build_helpers"
version = "0.14.0" version = "0.14.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"cfg_aliases", "cfg_aliases",
] ]
@@ -1124,7 +1124,7 @@ dependencies = [
[[package]] [[package]]
name = "cosmic-config" name = "cosmic-config"
version = "1.0.0" version = "1.0.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"atomicwrites", "atomicwrites",
"cosmic-config-derive", "cosmic-config-derive",
@@ -1145,7 +1145,7 @@ dependencies = [
[[package]] [[package]]
name = "cosmic-config-derive" name = "cosmic-config-derive"
version = "1.0.0" version = "1.0.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
@@ -1214,7 +1214,7 @@ dependencies = [
[[package]] [[package]]
name = "cosmic-theme" name = "cosmic-theme"
version = "1.0.0" version = "1.0.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"almost", "almost",
"configparser", "configparser",
@@ -1865,6 +1865,7 @@ dependencies = [
"ferese-ipc", "ferese-ipc",
"ferese-theme", "ferese-theme",
"ferese-theme-client", "ferese-theme-client",
"iced_accessibility",
"image", "image",
"libcosmic", "libcosmic",
"notify", "notify",
@@ -1892,6 +1893,7 @@ dependencies = [
"ferese-protocols", "ferese-protocols",
"ferese-theme", "ferese-theme",
"ferese-theme-client", "ferese-theme-client",
"freedesktop-desktop-entry",
"iced_accessibility", "iced_accessibility",
"image", "image",
"jiff", "jiff",
@@ -1917,6 +1919,7 @@ dependencies = [
"iced_accessibility", "iced_accessibility",
"jiff", "jiff",
"libcosmic", "libcosmic",
"tokio",
] ]
[[package]] [[package]]
@@ -2140,6 +2143,20 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "freedesktop-desktop-entry"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cfa09933e03f1ced380ad639822d65cb59a1914217da6c72aa8db3563e94214"
dependencies = [
"bstr",
"log",
"memchr",
"thiserror 2.0.21",
"unicase",
"xdg",
]
[[package]] [[package]]
name = "fsevent-sys" name = "fsevent-sys"
version = "4.1.0" version = "4.1.0"
@@ -2723,7 +2740,7 @@ dependencies = [
[[package]] [[package]]
name = "iced" name = "iced"
version = "0.14.0" version = "0.14.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"build_helpers", "build_helpers",
"dnd", "dnd",
@@ -2745,7 +2762,7 @@ dependencies = [
[[package]] [[package]]
name = "iced_accessibility" name = "iced_accessibility"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_winit", "accesskit_winit",
@@ -2754,7 +2771,7 @@ dependencies = [
[[package]] [[package]]
name = "iced_core" name = "iced_core"
version = "0.14.0" version = "0.14.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"build_helpers", "build_helpers",
@@ -2782,7 +2799,7 @@ dependencies = [
[[package]] [[package]]
name = "iced_debug" name = "iced_debug"
version = "0.14.0" version = "0.14.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"iced_core", "iced_core",
"iced_futures", "iced_futures",
@@ -2792,7 +2809,7 @@ dependencies = [
[[package]] [[package]]
name = "iced_futures" name = "iced_futures"
version = "0.14.0" version = "0.14.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"futures", "futures",
"iced_core", "iced_core",
@@ -2806,7 +2823,7 @@ dependencies = [
[[package]] [[package]]
name = "iced_graphics" name = "iced_graphics"
version = "0.14.0" version = "0.14.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"bytemuck", "bytemuck",
@@ -2827,7 +2844,7 @@ dependencies = [
[[package]] [[package]]
name = "iced_program" name = "iced_program"
version = "0.14.0" version = "0.14.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"iced_graphics", "iced_graphics",
"iced_runtime", "iced_runtime",
@@ -2836,7 +2853,7 @@ dependencies = [
[[package]] [[package]]
name = "iced_renderer" name = "iced_renderer"
version = "0.14.0" version = "0.14.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"iced_graphics", "iced_graphics",
"iced_tiny_skia", "iced_tiny_skia",
@@ -2848,7 +2865,7 @@ dependencies = [
[[package]] [[package]]
name = "iced_runtime" name = "iced_runtime"
version = "0.14.0" version = "0.14.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"build_helpers", "build_helpers",
"bytes", "bytes",
@@ -2865,7 +2882,7 @@ dependencies = [
[[package]] [[package]]
name = "iced_tiny_skia" name = "iced_tiny_skia"
version = "0.14.0" version = "0.14.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"cosmic-text", "cosmic-text",
@@ -2882,7 +2899,7 @@ dependencies = [
[[package]] [[package]]
name = "iced_wgpu" name = "iced_wgpu"
version = "0.14.0" version = "0.14.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"as-raw-xcb-connection", "as-raw-xcb-connection",
"bitflags 2.13.2", "bitflags 2.13.2",
@@ -2914,7 +2931,7 @@ dependencies = [
[[package]] [[package]]
name = "iced_widget" name = "iced_widget"
version = "0.14.2" version = "0.14.2"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"build_helpers", "build_helpers",
"cosmic-client-toolkit", "cosmic-client-toolkit",
@@ -2934,7 +2951,7 @@ dependencies = [
[[package]] [[package]]
name = "iced_winit" name = "iced_winit"
version = "0.14.0" version = "0.14.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"build_helpers", "build_helpers",
"cosmic-client-toolkit", "cosmic-client-toolkit",
@@ -3471,7 +3488,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]] [[package]]
name = "libcosmic" name = "libcosmic"
version = "1.0.0" version = "1.0.0"
source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c"
dependencies = [ dependencies = [
"apply", "apply",
"ashpd 0.12.3", "ashpd 0.12.3",
+1 -1
View File
@@ -31,7 +31,7 @@ bitflags = "2.9.4"
calloop = { version = "0.14.4", features = ["signals"] } calloop = { version = "0.14.4", features = ["signals"] }
jiff = "0.2.37" jiff = "0.2.37"
libc = "0.2.176" libc = "0.2.176"
libcosmic = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.0", default-features = false, features = ["multi-window", "tokio", "wayland"] } libcosmic = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.1", default-features = false, features = ["multi-window", "tokio", "wayland"] }
smithay = { version = "0.7.0", default-features = false, features = [ smithay = { version = "0.7.0", default-features = false, features = [
"backend_drm", "backend_drm",
"backend_gbm", "backend_gbm",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 6.2 MiB

+170
View File
@@ -0,0 +1,170 @@
"""Render the matching Ferese wallpapers directly at UHD resolution.
Run with Blender 4.5 or later:
blender --background --python assets/wallpapers/render.py
Pass -- --preview to render 960 x 540 previews instead.
"""
import math
import sys
from pathlib import Path
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parent
PREVIEW = "--preview" in sys.argv
DESTINATION = Path("/tmp/ferese-wallpaper-render") if PREVIEW else ROOT
def material(name, color, metallic=0.0, roughness=0.35):
result = bpy.data.materials.new(name)
result.use_nodes = True
shader = result.node_tree.nodes.get("Principled BSDF")
shader.inputs["Base Color"].default_value = (*color, 1.0)
shader.inputs["Metallic"].default_value = metallic
shader.inputs["Roughness"].default_value = roughness
shader.inputs["Coat Weight"].default_value = 0.3
shader.inputs["Coat Roughness"].default_value = 0.2
return result
def ribbon(name, phase, offset, surface):
vertices, faces = [], []
length_steps, width_steps = 360, 64
for i in range(length_steps):
t = i / length_steps
angle = 2.0 * math.pi * t
x = 2.0 + 5.4 * math.cos(angle)
y = 3.5 * math.sin(angle)
z = 0.8 * math.sin(2.0 * angle) + offset
tangent = Vector((-5.4 * math.sin(angle), 3.5 * math.cos(angle), 0.0))
normal = Vector((-tangent.y, tangent.x, 0.0)).normalized()
width = 1.75 + 0.25 * math.cos(angle + phase)
twist = angle + phase
for j in range(width_steps + 1):
u = 2.0 * j / width_steps - 1.0
distance = width * u * math.cos(twist)
curl = 0.25 * width * u * u
vertices.append(
(
x + normal.x * distance,
y + normal.y * distance,
z + width * u * math.sin(twist) + curl,
)
)
for i in range(length_steps):
for j in range(width_steps):
a = i * (width_steps + 1) + j
b = ((i + 1) % length_steps) * (width_steps + 1) + j
faces.append((a, a + 1, b + 1, b))
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(vertices, [], faces)
mesh.update()
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
obj.data.materials.append(surface)
for polygon in mesh.polygons:
polygon.use_smooth = True
solid = obj.modifiers.new("Satin edge", "SOLIDIFY")
solid.thickness = 0.028
bevel = obj.modifiers.new("Soft edge", "BEVEL")
bevel.width = 0.025
bevel.segments = 3
return obj
def area(name, position, color, power, size, target=(1.0, 0.0, 0.0)):
light = bpy.data.lights.new(name, "AREA")
light.energy = power
light.color = color
light.shape = "RECTANGLE"
light.size = size
light.size_y = size * 2.0
obj = bpy.data.objects.new(name, light)
bpy.context.collection.objects.link(obj)
obj.location = position
obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler()
def render(appearance):
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.samples = 24 if PREVIEW else 64
scene.cycles.use_denoising = True
scene.cycles.adaptive_threshold = 0.03
scene.cycles.max_bounces = 6
scene.render.resolution_x = 960 if PREVIEW else 3840
scene.render.resolution_y = 540 if PREVIEW else 2160
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGB"
scene.render.image_settings.color_depth = "8"
scene.render.film_transparent = False
scene.view_settings.view_transform = "AgX"
scene.view_settings.look = "AgX - Medium High Contrast"
dark = appearance == "dark"
world = bpy.data.worlds.new("Studio")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (
(0.11, 0.16, 0.27, 1.0) if dark else (0.75, 0.82, 0.95, 1.0)
)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.35 if dark else 0.55
scene.world = world
pearl = material("Pearl satin", (0.12, 0.43, 0.66) if dark else (0.56, 0.75, 0.95), 0.45, 0.28)
blue = material("Blue satin", (0.014, 0.085, 0.30) if dark else (0.24, 0.46, 0.74), 0.55, 0.3)
backdrop = bpy.data.materials.new("Quiet gradient")
backdrop.use_nodes = True
nodes, links = backdrop.node_tree.nodes, backdrop.node_tree.links
nodes.clear()
geometry = nodes.new("ShaderNodeNewGeometry")
distance = nodes.new("ShaderNodeVectorMath")
distance.operation = "DISTANCE"
distance.inputs[1].default_value = (5.0, 2.0, -4.0)
links.new(geometry.outputs["Position"], distance.inputs[0])
radius = nodes.new("ShaderNodeMath")
radius.operation = "DIVIDE"
radius.inputs[1].default_value = 18.0
links.new(distance.outputs["Value"], radius.inputs[0])
ramp = nodes.new("ShaderNodeValToRGB")
ramp.color_ramp.interpolation = "EASE"
ramp.color_ramp.elements[0].color = (0.022, 0.052, 0.11, 1.0) if dark else (1.5, 1.7, 2.0, 1.0)
ramp.color_ramp.elements[1].color = (0.002, 0.006, 0.016, 1.0) if dark else (0.9, 1.15, 1.5, 1.0)
links.new(radius.outputs[0], ramp.inputs[0])
emission = nodes.new("ShaderNodeEmission")
links.new(ramp.outputs[0], emission.inputs["Color"])
output = nodes.new("ShaderNodeOutputMaterial")
links.new(emission.outputs[0], output.inputs["Surface"])
fold = ribbon("Satin fold", -0.6, 0.7, pearl)
fold.data.materials.append(blue)
fold.modifiers["Satin edge"].material_offset = 1
bpy.ops.mesh.primitive_plane_add(size=200, location=(0.0, 0.0, -4.0))
bpy.context.object.data.materials.append(backdrop)
area("Broad softbox", (-4.0, 3.0, 12.0), (0.81, 0.93, 1.0), 2400, 8.0)
area("Upper rim", (7.0, 6.0, 8.0), (0.52, 0.82, 1.0), 1900, 6.0)
area("Blue reflection", (1.0, -7.0, 5.0), (0.25, 0.46, 1.0) if dark else (0.75, 0.87, 1.0), 1100, 7.0)
camera = bpy.data.cameras.new("Wallpaper camera")
camera.type = "ORTHO"
camera.ortho_scale = 21.0
obj = bpy.data.objects.new("Wallpaper camera", camera)
bpy.context.collection.objects.link(obj)
obj.location = (-1.5, -7.0, 22.0)
target = Vector((-1.5, 0.0, 0.0))
obj.rotation_euler = (target - obj.location).to_track_quat("-Z", "Y").to_euler()
scene.camera = obj
DESTINATION.mkdir(parents=True, exist_ok=True)
scene.render.filepath = str(DESTINATION / f"ferese-wallpaper-{appearance}.png")
bpy.ops.render.render(write_still=True)
Path(scene.render.filepath).chmod(0o644)
for appearance in ("dark", "light"):
render(appearance)
+117 -22
View File
@@ -31,6 +31,8 @@ use crate::window_rules::{WindowRule, WindowRuleConfig};
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
pub struct Config { pub struct Config {
#[serde(default)]
panels: Option<Vec<ferese_config::panel::Panel>>,
#[serde(default)] #[serde(default)]
notifications: ferese_config::notifications::NotificationConfig, notifications: ferese_config::notifications::NotificationConfig,
#[serde(default)] #[serde(default)]
@@ -83,14 +85,9 @@ pub(crate) struct DaemonConfig {
// Validate shell-only fields too, before publishing an accepted source. // Validate shell-only fields too, before publishing an accepted source.
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
#[serde(deny_unknown_fields)]
struct ShellStatusConfig { struct ShellStatusConfig {
bar_layout: Option<ferese_config::BarLayout>, keybinding_guide: Option<bool>,
#[serde(
default = "ferese_config::default_bar_island_padding",
deserialize_with = "ferese_config::deserialize_bar_island_padding"
)]
bar_island_padding: f32,
battery_percentage: Option<bool>,
low_battery_threshold: Option<u8>, low_battery_threshold: Option<u8>,
settings_command: Option<Vec<String>>, settings_command: Option<Vec<String>>,
} }
@@ -112,10 +109,15 @@ impl Config {
.expect("resolved theme matches runtime schema"), .expect("resolved theme matches runtime schema"),
..Self::default() ..Self::default()
}; };
config.theme_settings() let mut settings = config.theme_settings()?;
settings.reduce_transparency = theme.accessibility.reduce_transparency;
Ok(settings)
} }
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> { pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
if let Some(panels) = &self.panels {
ferese_config::panel::validate(panels).map_err(ConfigError::Binding)?;
}
self.notifications.validate().map_err(ConfigError::Binding)?; self.notifications.validate().map_err(ConfigError::Binding)?;
self.desktop_widgets.validate().map_err(ConfigError::Binding)?; self.desktop_widgets.validate().map_err(ConfigError::Binding)?;
self.xwayland.validate().map_err(ConfigError::Binding)?; self.xwayland.validate().map_err(ConfigError::Binding)?;
@@ -137,6 +139,16 @@ impl Config {
bindings, bindings,
window_rules: self.window_rules()?, window_rules: self.window_rules()?,
theme_settings: self.theme_settings()?, theme_settings: self.theme_settings()?,
panel_corner_radius: self
.panels
.as_ref()
.and_then(|panels| panels.first())
.and_then(|panel| panel.corner_radius),
panel_background_opacity: self
.panels
.as_ref()
.and_then(|panels| panels.first())
.and_then(|panel| panel.background_opacity),
focus_effect: self.focus_effect_settings()?, focus_effect: self.focus_effect_settings()?,
default_column_width: self.default_column_width()?, default_column_width: self.default_column_width()?,
scrolling_focus_strategy: self.scrolling_focus_strategy(), scrolling_focus_strategy: self.scrolling_focus_strategy(),
@@ -372,25 +384,107 @@ pub(crate) fn config_path() -> Option<PathBuf> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test] #[test]
fn bar_layout_is_validated_before_live_publication() { fn geometry_rejects_removed_and_misspelled_keys_in_both_consumption_paths() {
for layout in ["continuous", "islands"] { for key in [
let source = format!("status {{ bar-layout \"{layout}\"; }}"); "top-bar-radius",
Config::parse_source(&source).unwrap().runtime_config().unwrap(); "top-bar-height",
"top-bar-margin-top",
"top-bar-margin-horizontal",
"top-bar-window-clearance",
"shell-raduis",
] {
let source = format!("theme {{ geometry {{ {key} 8; }}; }}");
assert!(
Config::parse_source(&source)
.unwrap_err()
.to_string()
.contains("unknown field")
);
assert!(
crate::theme::prepare(&source, std::path::Path::new("/tmp"))
.err()
.unwrap()
.contains(&key.replace('-', "_"))
);
} }
Config::parse_source("theme { geometry { border-width 1; focus-ring-width 2; window-radius 12; shell-radius 14; control-gap 8; }; }").unwrap();
assert!(Config::parse_source("status { bar-layout \"invalid\"; }").is_err());
} }
#[test] #[test]
fn island_padding_is_validated_before_live_publication() { fn panel_background_opacity_override_leaves_the_shell_material_unchanged() {
for padding in ["0", "4.5", "12", "32"] { let config = Config::parse_source(
let source = format!("status {{ bar-island-padding {padding}; }}"); "theme { material { style translucent; opacity 0.3; }; }; panel main { background-opacity 0.8; }",
Config::parse_source(&source).unwrap().runtime_config().unwrap(); )
.unwrap();
let runtime = config.runtime_config().unwrap();
assert_eq!(runtime.panel_background_opacity, Some(0.8));
assert!((runtime.theme_settings.shell_opacity - 0.3).abs() < 0.001);
let inherited = Config::parse_source("panel main;").unwrap().runtime_config().unwrap();
assert!(inherited.panel_background_opacity.is_none());
} }
#[test]
fn panel_corner_override_does_not_replace_shell_radius() {
let config = Config::parse_source(
"theme { geometry { shell-radius 20; }; }; panel \"main\" { corner-radius \"4px 8px 12px 16px\"; }",
)
.unwrap();
let runtime = config.runtime_config().unwrap();
assert_eq!(runtime.panel_corner_radius.unwrap().0, [4., 8., 12., 16.]);
assert_eq!(runtime.theme_settings.material_radius, 20.);
let inherited = Config::parse_source("theme { geometry { shell-radius 20; }; }")
.unwrap()
.runtime_config()
.unwrap();
assert!(inherited.panel_corner_radius.is_none());
assert_eq!(inherited.theme_settings.panel_radius, 20.);
}
#[test]
fn validates_shell_composition_before_publishing_config() {
let source = r#"panel "main" { end { group "status" { item "network" kind="network"; item "clock" kind="clock"; }; }; }"#;
assert!(Config::parse_source(source).unwrap().runtime_config().is_ok());
assert!(
Config::parse_source(&source.replace("item \"clock\"", "item \"network\""))
.unwrap()
.runtime_config()
.is_err()
);
assert!(
Config::parse_source("panel \"one\"; panel \"two\";")
.unwrap()
.runtime_config()
.is_err()
);
assert!(Config::parse_source("panel \"one\" { edge \"left\"; }").is_err());
}
#[test]
fn panel_surfaces_are_validated_before_live_publication() {
for surface in ["none", "inset", "island"] {
let source = format!(
"panel main {{ surface solid; group-surface {surface}; end {{ group status {{ surface {surface}; }}; }}; }}"
);
Config::parse_source(&source).unwrap().runtime_config().unwrap();
}
assert!(Config::parse_source("panel main { group-surface invalid; }").is_err());
assert!(Config::parse_source("panel main { background islands; }").is_err());
assert!(Config::parse_source("status { bar-layout islands; }").is_err());
}
#[test]
fn group_padding_is_validated_before_live_publication() {
for padding in ["0", "4.5", "12", "32"] {
let source = format!("panel main {{ end {{ group status {{ island-padding {padding}; }}; }}; }}");
Config::parse_source(&source).unwrap().runtime_config().unwrap();
}
for padding in ["-1", "32.5", "\"small\""] { for padding in ["-1", "32.5", "\"small\""] {
let source = format!("status {{ bar-island-padding {padding}; }}"); let source = format!("panel main {{ end {{ group status {{ island-padding {padding}; }}; }}; }}");
assert!(Config::parse_source(&source).is_err()); assert!(
Config::parse_source(&source)
.map(|config| config.runtime_config().is_err())
.unwrap_or(true)
);
} }
} }
@@ -712,10 +806,10 @@ mod tests {
} }
#[test] #[test]
fn shell_radius_is_independent_of_windows_and_overrides_legacy_keys() { fn shell_radius_is_independent_of_windows() {
for radius in [0.0, 18.0] { for radius in [0.0, 18.0] {
let config = parse(&format!( let config = parse(&format!(
"appearance {{\n corner-radius 9\n}}\ntheme {{\n geometry {{\n shell-radius {radius}\n top-bar-radius 5\n window-radius 23\n }}\n}}" "theme {{\n geometry {{\n shell-radius {radius}\n window-radius 23\n }}\n}}"
)); ));
let theme = config.theme_settings().unwrap(); let theme = config.theme_settings().unwrap();
assert_eq!(theme.material_radius, radius); assert_eq!(theme.material_radius, radius);
@@ -762,6 +856,7 @@ mod tests {
material_tint_strength: 0.5, material_tint_strength: 0.5,
material_radius: 14.0, material_radius: 14.0,
panel_radius: 14.0, panel_radius: 14.0,
reduce_transparency: false,
} }
); );
assert!(invalid_color.theme_settings().is_err()); assert!(invalid_color.theme_settings().is_err());
+7 -10
View File
@@ -22,12 +22,7 @@ impl Config {
let shadow_opacity = unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?; let shadow_opacity = unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?;
let material_radius = nonnegative_theme_value( let material_radius = nonnegative_theme_value(
self.theme self.theme.geometry.shell_radius.unwrap_or(14.0),
.geometry
.shell_radius
.or(self.appearance.corner_radius)
.or(self.theme.geometry.top_bar_radius)
.unwrap_or(14.0),
"geometry.shell_radius", "geometry.shell_radius",
)?; )?;
Ok(ThemeSettings { Ok(ThemeSettings {
@@ -59,6 +54,7 @@ impl Config {
material_tint_strength: unit_theme_value(self.theme.material.tint_strength, "material.tint_strength")?, material_tint_strength: unit_theme_value(self.theme.material.tint_strength, "material.tint_strength")?,
material_radius, material_radius,
panel_radius: material_radius, panel_radius: material_radius,
reduce_transparency: false,
}) })
} }
} }
@@ -231,6 +227,7 @@ pub struct ThemeSettings {
pub material_tint_strength: f64, pub material_tint_strength: f64,
pub material_radius: f64, pub material_radius: f64,
pub panel_radius: f64, pub panel_radius: f64,
pub reduce_transparency: bool,
} }
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
@@ -244,7 +241,6 @@ pub enum MaterialStyle {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub(super) struct AppearanceConfig { pub(super) struct AppearanceConfig {
pub(super) corner_radius: Option<f64>,
#[serde(default)] #[serde(default)]
pub(super) focus_effect: FocusEffectConfig, pub(super) focus_effect: FocusEffectConfig,
} }
@@ -406,7 +402,10 @@ impl Default for SoftShadowConfig {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ThemeGeometryConfig { pub(super) struct ThemeGeometryConfig {
#[serde(default, rename = "control_gap")]
_control_gap: Option<f64>,
#[serde(default = "default_border_width")] #[serde(default = "default_border_width")]
pub(super) border_width: f64, pub(super) border_width: f64,
#[serde(default = "default_focus_ring_width")] #[serde(default = "default_focus_ring_width")]
@@ -414,18 +413,16 @@ pub(super) struct ThemeGeometryConfig {
#[serde(default = "default_window_radius")] #[serde(default = "default_window_radius")]
pub(super) window_radius: f64, pub(super) window_radius: f64,
#[serde(default)] #[serde(default)]
pub(super) top_bar_radius: Option<f64>,
#[serde(default)]
pub(super) shell_radius: Option<f64>, pub(super) shell_radius: Option<f64>,
} }
impl Default for ThemeGeometryConfig { impl Default for ThemeGeometryConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
_control_gap: None,
border_width: default_border_width(), border_width: default_border_width(),
focus_ring_width: default_focus_ring_width(), focus_ring_width: default_focus_ring_width(),
window_radius: default_window_radius(), window_radius: default_window_radius(),
top_bar_radius: None,
shell_radius: None, shell_radius: None,
} }
} }
+18 -2
View File
@@ -239,7 +239,7 @@ pub(crate) fn surface_region_opacities(surface: &WlSurface) -> Vec<u32> {
} }
fn decode_region_opacities(bytes: &[u8]) -> Option<Vec<u32>> { fn decode_region_opacities(bytes: &[u8]) -> Option<Vec<u32>> {
if !bytes.len().is_multiple_of(4) || bytes.len() > 32 * 4 { if !bytes.len().is_multiple_of(4) || bytes.len() > ferese_protocols::effects::v1::MAX_REGIONS * 4 {
return None; return None;
} }
bytes bytes
@@ -254,7 +254,7 @@ fn decode_region_opacities(bytes: &[u8]) -> Option<Vec<u32>> {
} }
fn decode_regions(bytes: &[u8]) -> Option<Vec<[f64; 5]>> { fn decode_regions(bytes: &[u8]) -> Option<Vec<[f64; 5]>> {
if !bytes.len().is_multiple_of(20) || bytes.len() > 32 * 20 { if !bytes.len().is_multiple_of(20) || bytes.len() > ferese_protocols::effects::v1::MAX_REGIONS * 20 {
return None; return None;
} }
bytes bytes
@@ -616,6 +616,22 @@ mod tests {
} }
} }
#[test]
fn maximum_panel_groups_and_overflow_fit_one_effects_publication() {
let count = ferese_config::panel::MAX_GROUPS + 1;
assert_eq!(count, ferese_protocols::effects::v1::MAX_REGIONS);
let region = [0.0f32, 0., 12., 36., 4.];
let bytes: Vec<_> = region.into_iter().flat_map(f32::to_ne_bytes).collect();
assert_eq!(decode_regions(&bytes.repeat(count)).unwrap().len(), count);
assert_eq!(
decode_region_opacities(&1000u32.to_ne_bytes().repeat(count))
.unwrap()
.len(),
count
);
assert!(decode_regions(&bytes.repeat(count + 1)).is_none());
}
#[test] #[test]
fn role_changes_advance_generation_only_when_material_changes() { fn role_changes_advance_generation_only_when_material_changes() {
let effects = SurfaceEffectsState::new(); let effects = SurfaceEffectsState::new();
+88 -1
View File
@@ -406,6 +406,93 @@ fn apple_shoulder_extent_never_claims_transparent_pixels_as_opaque() {
} }
} }
#[test]
#[ignore = "requires an EGL rendering device"]
fn docked_panel_fill_covers_the_entire_attached_edge() {
let mut renderer = renderer();
let mut resources = RenderResources::default();
let material = material_program_for_corners(&mut resources, &mut renderer, CornerShape::Circular).unwrap();
for edge in [ferese_config::panel::Edge::Top, ferese_config::panel::Edge::Bottom] {
let radii = ferese_config::panel::CornerRadii([14., 8., 12., 20.])
.at_edge(edge, true)
.0;
let pixels = rasterize(
&mut renderer,
&material.0,
&[
Uniform::new("visible_rect", [0.0f32, 0.0, 64.0, 36.0]),
Uniform::new("material_radii", radii),
Uniform::new("tint", [1.0f32; 4]),
Uniform::new("paint_mode", 0.0f32),
Uniform::new("shadow_rect", [0.0f32, 0.0, 64.0, 36.0]),
Uniform::new("shadow_values", [8.0f32, 1.0]),
],
);
let (attached_row, free_row) = if edge == ferese_config::panel::Edge::Top {
(0, 35)
} else {
(35, 0)
};
for x in 0..64 {
assert_eq!(
pixels[(attached_row * 64 + x) * 4 + 3],
255,
"wallpaper exposed at {edge:?} pixel {x}"
);
}
assert_eq!(pixels[free_row * 64 * 4 + 3], 0, "opposite corners remain rounded");
}
}
#[test]
#[ignore = "requires an EGL rendering device"]
fn asymmetric_panel_fill_and_shadow_match_each_corner() {
let mut renderer = renderer();
let mut resources = RenderResources::default();
let material = material_program_for_corners(&mut resources, &mut renderer, CornerShape::Circular).unwrap();
let rect = [2.0f32, 2.0, 60.0, 60.0];
let radii = [0.0f32, 4.0, 12.0, 20.0];
for paint_mode in [0.0f32, 2.0] {
let pixels = rasterize(
&mut renderer,
&material.0,
&[
Uniform::new("visible_rect", rect),
Uniform::new("material_radii", radii),
Uniform::new("tint", [1.0f32; 4]),
Uniform::new("paint_mode", paint_mode),
Uniform::new("shadow_rect", rect),
Uniform::new("shadow_values", [8.0f32, 1.0]),
],
);
for y in 0..64 {
for x in 0..64 {
let radius = match (x < 32, y < 32) {
(true, true) => radii[0],
(false, true) => radii[1],
(false, false) => radii[2],
(true, false) => radii[3],
};
let distance = reference_distance(
[x as f64 + 0.5, y as f64 + 0.5],
rect.map(f64::from),
radius.into(),
CornerShape::Circular,
);
let expected = if paint_mode == 0. {
coverage(distance)
} else {
(-0.5 * (distance.max(0.0) / 4.0).powi(2)).exp() * (1. - coverage(distance))
};
assert!(
pixels[(y * 64 + x) * 4 + 3].abs_diff((expected * 255.).round() as u8) <= 2,
"corner mismatch at ({x}, {y}), mode {paint_mode}"
);
}
}
}
}
#[test] #[test]
#[ignore = "requires an EGL rendering device"] #[ignore = "requires an EGL rendering device"]
fn apple_shadow_and_window_tint_match_the_same_outline() { fn apple_shadow_and_window_tint_match_the_same_outline() {
@@ -431,7 +518,7 @@ fn apple_shadow_and_window_tint_match_the_same_outline() {
&material.0, &material.0,
&[ &[
Uniform::new("visible_rect", rect), Uniform::new("visible_rect", rect),
Uniform::new("material_radius", radius), Uniform::new("material_radii", [radius; 4]),
Uniform::new("tint", [1.0f32; 4]), Uniform::new("tint", [1.0f32; 4]),
Uniform::new("paint_mode", 0.0f32), Uniform::new("paint_mode", 0.0f32),
Uniform::new("shadow_rect", rect), Uniform::new("shadow_rect", rect),
+1 -1
View File
@@ -234,7 +234,7 @@ pub(super) fn window_tint_element(
let uniforms = |p: &BorderParameters| { let uniforms = |p: &BorderParameters| {
vec![ vec![
Uniform::new("visible_rect", p.clip_rect), Uniform::new("visible_rect", p.clip_rect),
Uniform::new("material_radius", p.radius), Uniform::new("material_radii", [p.radius; 4]),
Uniform::new("tint", p.color), Uniform::new("tint", p.color),
Uniform::new("paint_mode", 0.0_f32), Uniform::new("paint_mode", 0.0_f32),
Uniform::new("shadow_rect", p.clip_rect), Uniform::new("shadow_rect", p.clip_rect),
+125 -19
View File
@@ -130,6 +130,23 @@ fn material_region_geometry(
Some((rect.to_i32_up(), corners)) Some((rect.to_i32_up(), corners))
} }
/// Apply the same output transform as framebuffer_clip_rect to corner identity.
fn framebuffer_corner_radii(radii: [f32; 4], transform: Transform) -> [f32; 4] {
let size = Size::<f64, Physical>::from((1., 1.));
let mut transformed = [0.; 4];
for (radius, point) in radii.into_iter().zip([(0., 0.), (1., 0.), (1., 1.), (0., 1.)]) {
let point = transform.transform_point_in(Point::<f64, Physical>::from(point), &size);
let index = match (point.x > 0.5, point.y > 0.5) {
(false, false) => 0,
(true, false) => 1,
(true, true) => 2,
(false, true) => 3,
};
transformed[index] = radius;
}
transformed
}
pub(super) struct MaterialSurface { pub(super) struct MaterialSurface {
pub geometry: Rectangle<i32, Logical>, pub geometry: Rectangle<i32, Logical>,
pub corners: RoundedRect, pub corners: RoundedRect,
@@ -156,6 +173,22 @@ pub(super) fn material_element(
) )
} }
fn resolve_surface_material(
role: crate::effects::SemanticRole,
settings: crate::config::ThemeSettings,
panel_opacity: Option<f32>,
) -> crate::effects::ResolvedMaterial {
let mut material = crate::effects::resolve_material(role, settings.material_style, settings.shell_opacity as f32);
if role == crate::effects::SemanticRole::Panel {
material.opacity = ferese_config::theme::effective_panel_opacity(
material.opacity,
panel_opacity,
settings.reduce_transparency,
);
}
material
}
pub(super) fn material_element_with_role( pub(super) fn material_element_with_role(
state: &mut Ferese, state: &mut Ferese,
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
@@ -171,11 +204,7 @@ pub(super) fn material_element_with_role(
capture_geometry, capture_geometry,
alpha, alpha,
} = surface_geometry; } = surface_geometry;
let material = crate::effects::resolve_material( let material = resolve_surface_material(role, state.theme_settings, state.panel_background_opacity);
role,
state.theme_settings.material_style,
state.theme_settings.shell_opacity as f32,
);
let presentation_alpha = opacity * alpha; let presentation_alpha = opacity * alpha;
let mode = output.current_mode()?; let mode = output.current_mode()?;
let scale = output.current_scale().fractional_scale(); let scale = output.current_scale().fractional_scale();
@@ -186,14 +215,43 @@ pub(super) fn material_element_with_role(
state.theme_settings.surface_base_color state.theme_settings.surface_base_color
}; };
let [red, green, blue, _] = background.0; let [red, green, blue, _] = background.0;
let radius = corners.radius; let logical_radii = if role == crate::effects::SemanticRole::Panel {
state.panel_corner_radius
} else {
None
};
let radii = logical_radii.map_or([corners.radius; 4], |radii| {
radii
.0
.map(|value| clamp_radius(f64::from(value) * scale, corners.rect.size))
});
let output_size = output
.current_transform()
.transform_size(mode.size)
.to_f64()
.to_logical(scale)
.to_i32_ceil();
let touches_top = role == crate::effects::SemanticRole::Panel && geometry.loc.y == 0;
let touches_bottom =
role == crate::effects::SemanticRole::Panel && geometry.loc.y + geometry.size.h >= output_size.h;
let radii = ferese_config::panel::CornerRadii(radii)
.at_top_edge(touches_top)
.at_edge(ferese_config::panel::Edge::Bottom, touches_bottom)
.0;
let radius = radii.into_iter().fold(0., f32::max);
let corner_radii = framebuffer_corner_radii(radii, transform);
let [offset_y, shadow_blur, shadow_opacity] = material.shadow; let [offset_y, shadow_blur, shadow_opacity] = material.shadow;
let edge_bar = role == crate::effects::SemanticRole::Panel && radius == 0.0 && geometry.loc.y == 0; let edge_bar = radius == 0.0 && (touches_top || touches_bottom);
let shadow_opacity = if edge_bar { let shadow_opacity = if edge_bar {
0.0 0.0
} else { } else {
shadow_opacity * (state.theme_settings.shadow_opacity / 0.2) * f64::from(presentation_alpha) shadow_opacity * (state.theme_settings.shadow_opacity / 0.2) * f64::from(presentation_alpha)
}; };
let shadow_opacity = if role == crate::effects::SemanticRole::Panel {
shadow_opacity * f64::from(material.opacity)
} else {
shadow_opacity
};
let shadow_geometry = Rectangle::new( let shadow_geometry = Rectangle::new(
( (
corners.rect.loc.x, corners.rect.loc.x,
@@ -204,22 +262,16 @@ pub(super) fn material_element_with_role(
); );
let background_opacity = material.opacity; let background_opacity = material.opacity;
let blur = material_blur_radius(material.style, material.opacity, state.theme_settings.backdrop_blur); let blur = material_blur_radius(material.style, material.opacity, state.theme_settings.backdrop_blur);
let output_size = output
.current_transform()
.transform_size(mode.size)
.to_f64()
.to_logical(scale)
.to_i32_ceil();
let sample_geometry = expanded_blur_region(capture_geometry, blur.ceil() as i32, output_size); let sample_geometry = expanded_blur_region(capture_geometry, blur.ceil() as i32, output_size);
let sample_physical = sample_geometry.to_physical_precise_round(scale); let sample_physical = sample_geometry.to_physical_precise_round(scale);
let mut parameters = MaterialParameters { let mut parameters = MaterialParameters {
corner_radii,
presentation_alpha, presentation_alpha,
background_opacity, background_opacity,
blur: (blur * scale) as f32, blur: (blur * scale) as f32,
sample_geometry, sample_geometry,
sample_physical, sample_physical,
sample_framebuffer: framebuffer_clip_rect(sample_physical, mode.size, transform), sample_framebuffer: framebuffer_clip_rect(sample_physical, mode.size, transform),
radius,
shadow_rect: framebuffer_clip_rect(shadow_geometry, mode.size, transform), shadow_rect: framebuffer_clip_rect(shadow_geometry, mode.size, transform),
shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32], shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32],
shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur), shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur),
@@ -323,7 +375,7 @@ pub(super) fn material_element_with_role(
if cached.parameters.shadow_rect != parameters.shadow_rect if cached.parameters.shadow_rect != parameters.shadow_rect
|| cached.parameters.shadow_values != parameters.shadow_values || cached.parameters.shadow_values != parameters.shadow_values
|| cached.parameters.shadow_bounds != parameters.shadow_bounds || cached.parameters.shadow_bounds != parameters.shadow_bounds
|| cached.parameters.radius != parameters.radius || cached.parameters.corner_radii != parameters.corner_radii
|| cached.parameters.visible_framebuffer != parameters.visible_framebuffer || cached.parameters.visible_framebuffer != parameters.visible_framebuffer
{ {
cached.shadow.resize(parameters.shadow_bounds, None); cached.shadow.resize(parameters.shadow_bounds, None);
@@ -388,7 +440,7 @@ pub(super) fn blur_program(resources: &mut RenderResources, renderer: &mut GlesR
pub(super) fn blur_uniform_names() -> [UniformName<'static>; 8] { pub(super) fn blur_uniform_names() -> [UniformName<'static>; 8] {
[ [
UniformName::new("visible_rect", UniformType::_4f), UniformName::new("visible_rect", UniformType::_4f),
UniformName::new("material_radius", UniformType::_1f), UniformName::new("material_radii", UniformType::_4f),
UniformName::new("texture_size", UniformType::_2f), UniformName::new("texture_size", UniformType::_2f),
UniformName::new("capture_origin", UniformType::_2f), UniformName::new("capture_origin", UniformType::_2f),
UniformName::new("blur_radius", UniformType::_1f), UniformName::new("blur_radius", UniformType::_1f),
@@ -401,7 +453,7 @@ pub(super) fn blur_uniform_names() -> [UniformName<'static>; 8] {
pub(super) fn blur_uniforms(p: &MaterialParameters) -> Vec<Uniform<'static>> { pub(super) fn blur_uniforms(p: &MaterialParameters) -> Vec<Uniform<'static>> {
vec![ vec![
Uniform::new("visible_rect", p.visible_framebuffer), Uniform::new("visible_rect", p.visible_framebuffer),
Uniform::new("material_radius", p.radius), Uniform::new("material_radii", p.corner_radii),
Uniform::new("texture_size", [p.sample_framebuffer[2], p.sample_framebuffer[3]]), Uniform::new("texture_size", [p.sample_framebuffer[2], p.sample_framebuffer[3]]),
Uniform::new("capture_origin", [p.sample_framebuffer[0], p.sample_framebuffer[1]]), Uniform::new("capture_origin", [p.sample_framebuffer[0], p.sample_framebuffer[1]]),
Uniform::new("blur_radius", p.blur), Uniform::new("blur_radius", p.blur),
@@ -452,7 +504,7 @@ pub(super) fn material_program_for_corners(
let uniforms = [ let uniforms = [
UniformName::new("visible_rect", UniformType::_4f), UniformName::new("visible_rect", UniformType::_4f),
UniformName::new("material_radius", UniformType::_1f), UniformName::new("material_radii", UniformType::_4f),
UniformName::new("tint", UniformType::_4f), UniformName::new("tint", UniformType::_4f),
UniformName::new("paint_mode", UniformType::_1f), UniformName::new("paint_mode", UniformType::_1f),
UniformName::new("shadow_rect", UniformType::_4f), UniformName::new("shadow_rect", UniformType::_4f),
@@ -485,7 +537,7 @@ pub(super) fn decoration_uniforms(parameters: &MaterialParameters, paint_mode: f
Uniform::new("shadow_rect", parameters.shadow_rect), Uniform::new("shadow_rect", parameters.shadow_rect),
Uniform::new("shadow_values", parameters.shadow_values), Uniform::new("shadow_values", parameters.shadow_values),
Uniform::new("visible_rect", parameters.visible_framebuffer), Uniform::new("visible_rect", parameters.visible_framebuffer),
Uniform::new("material_radius", parameters.radius), Uniform::new("material_radii", parameters.corner_radii),
Uniform::new("tint", parameters.tint), Uniform::new("tint", parameters.tint),
] ]
} }
@@ -507,6 +559,60 @@ pub(super) fn blur_damage(
mod region_geometry_tests { mod region_geometry_tests {
use super::*; use super::*;
#[test]
fn reduce_transparency_wins_over_continuous_and_island_panel_materials() {
use crate::effects::SemanticRole;
for surface in ["solid", "none"] {
for opacity in [0., 0.4, 1.] {
for reduce in [false, true] {
let source = format!(
"theme {{ material {{ style translucent; opacity 0.7; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ surface {surface}; group-surface island; background-opacity {opacity}; }}"
);
let (_, runtime, candidate) = crate::theme::prepare(&source, std::path::Path::new("/tmp")).unwrap();
let material = resolve_surface_material(
SemanticRole::Panel,
runtime.theme_settings,
runtime.panel_background_opacity,
);
assert_eq!(material.opacity, if reduce { 1. } else { opacity });
assert_eq!(material.opacity, candidate.theme.panel_opacity(Some(opacity)));
assert_eq!(
material.style,
if reduce {
crate::config::MaterialStyle::Solid
} else {
crate::config::MaterialStyle::Translucent
}
);
if reduce {
assert_eq!(runtime.theme_settings.backdrop_blur, 0.);
}
let popover = resolve_surface_material(SemanticRole::Popover, runtime.theme_settings, Some(0.));
assert!((popover.opacity - if reduce { 1. } else { 0.7 }).abs() < 0.001);
}
}
}
}
#[test]
fn framebuffer_transforms_preserve_each_logical_corner() {
let radii = [1., 2., 3., 4.];
assert_eq!(framebuffer_corner_radii(radii, Transform::Normal), radii);
assert_eq!(framebuffer_corner_radii(radii, Transform::Flipped180), [4., 3., 2., 1.]);
for (transform, expected) in [
(Transform::Normal, [1., 2., 3., 4.]),
(Transform::_90, [4., 1., 2., 3.]),
(Transform::_180, [3., 4., 1., 2.]),
(Transform::_270, [2., 3., 4., 1.]),
(Transform::Flipped, [2., 1., 4., 3.]),
(Transform::Flipped90, [1., 4., 3., 2.]),
(Transform::Flipped180, [4., 3., 2., 1.]),
(Transform::Flipped270, [3., 2., 1., 4.]),
] {
assert_eq!(framebuffer_corner_radii(radii, transform), expected);
}
}
#[test] #[test]
fn fractional_regions_snap_once_in_output_pixels() { fn fractional_regions_snap_once_in_output_pixels() {
let bounds = Rectangle::from_size((200, 100).into()); let bounds = Rectangle::from_size((200, 100).into());
+3 -2
View File
@@ -135,6 +135,7 @@ fn corner_shader_for(source: &str, shape: CornerShape) -> String {
) )
.replace("precision mediump float;", "precision highp float;"), .replace("precision mediump float;", "precision highp float;"),
} }
.replace("//_MATERIAL_CORNERS_", include_str!("shaders/material_corners.glsl"))
} }
pub(crate) fn window_corner_shape(_window: &smithay::desktop::Window) -> CornerShape { pub(crate) fn window_corner_shape(_window: &smithay::desktop::Window) -> CornerShape {
@@ -339,13 +340,13 @@ pub(crate) struct MaterialProgram(GlesPixelProgram);
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
struct MaterialParameters { struct MaterialParameters {
corner_radii: [f32; 4],
presentation_alpha: f32, presentation_alpha: f32,
background_opacity: f32, background_opacity: f32,
blur: f32, blur: f32,
sample_geometry: Rectangle<i32, Logical>, sample_geometry: Rectangle<i32, Logical>,
sample_physical: Rectangle<i32, Physical>, sample_physical: Rectangle<i32, Physical>,
sample_framebuffer: [f32; 4], sample_framebuffer: [f32; 4],
radius: f32,
shadow_rect: [f32; 4], shadow_rect: [f32; 4],
shadow_values: [f32; 2], shadow_values: [f32; 2],
shadow_bounds: Rectangle<i32, Logical>, shadow_bounds: Rectangle<i32, Logical>,
@@ -734,7 +735,7 @@ mod tests {
] { ] {
let uniforms = vec![ let uniforms = vec![
Uniform::new("visible_rect", [0.0f32, 0.0, 32.0, 32.0]), Uniform::new("visible_rect", [0.0f32, 0.0, 32.0, 32.0]),
Uniform::new("material_radius", 0.0f32), Uniform::new("material_radii", [0.0f32; 4]),
Uniform::new("texture_size", [32.0f32, 32.0]), Uniform::new("texture_size", [32.0f32, 32.0]),
Uniform::new("capture_origin", [0.0f32, 0.0]), Uniform::new("capture_origin", [0.0f32, 0.0]),
Uniform::new("blur_radius", 12.0f32), Uniform::new("blur_radius", 12.0f32),
+1 -1
View File
@@ -41,7 +41,7 @@ pub(super) fn overview_chrome_element(
} else { } else {
vec![ vec![
Uniform::new("visible_rect", p.clip_rect), Uniform::new("visible_rect", p.clip_rect),
Uniform::new("material_radius", p.radius), Uniform::new("material_radii", [p.radius; 4]),
Uniform::new("tint", p.color), Uniform::new("tint", p.color),
Uniform::new("paint_mode", 0.0_f32), Uniform::new("paint_mode", 0.0_f32),
Uniform::new("shadow_rect", p.clip_rect), Uniform::new("shadow_rect", p.clip_rect),
@@ -11,7 +11,7 @@ uniform sampler2D tex;
#endif #endif
uniform float alpha; uniform float alpha;
uniform vec4 visible_rect; uniform vec4 visible_rect;
uniform float material_radius; uniform vec4 material_radii;
uniform vec2 texture_size; uniform vec2 texture_size;
uniform vec2 capture_origin; uniform vec2 capture_origin;
uniform float blur_radius; uniform float blur_radius;
@@ -20,9 +20,10 @@ uniform float background_opacity;
uniform vec4 tint; uniform vec4 tint;
varying vec2 v_coords; varying vec2 v_coords;
//_CORNERS_ //_CORNERS_
//_MATERIAL_CORNERS_
void main() { void main() {
float sdf = rounded_rect_distance(gl_FragCoord.xy, visible_rect, material_radius); float sdf = rounded_rect_corners_distance(gl_FragCoord.xy, visible_rect, material_radii);
float coverage = alpha * presentation_alpha * edge_coverage(sdf); float coverage = alpha * presentation_alpha * edge_coverage(sdf);
if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; } if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; }
vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size; vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size;
@@ -0,0 +1,8 @@
// Radii are clockwise from the framebuffer rectangle's top-left.
float rounded_rect_corners_distance(vec2 point, vec4 rect, vec4 radii) {
vec2 center = rect.xy + rect.zw * 0.5;
float radius = point.y < center.y
? (point.x < center.x ? radii.x : radii.y)
: (point.x < center.x ? radii.w : radii.z);
return rounded_rect_distance(point, rect, radius);
}
@@ -3,22 +3,23 @@ precision highp float;
uniform float alpha; uniform float alpha;
uniform vec4 tint; uniform vec4 tint;
uniform vec4 visible_rect; uniform vec4 visible_rect;
uniform float material_radius; uniform vec4 material_radii;
uniform float paint_mode; uniform float paint_mode;
uniform vec4 shadow_rect; uniform vec4 shadow_rect;
uniform vec2 shadow_values; uniform vec2 shadow_values;
varying vec2 v_coords; varying vec2 v_coords;
//_CORNERS_ //_CORNERS_
//_MATERIAL_CORNERS_
void main() { void main() {
float sdf = rounded_rect_distance(gl_FragCoord.xy, visible_rect, material_radius); float sdf = rounded_rect_corners_distance(gl_FragCoord.xy, visible_rect, material_radii);
float coverage = edge_coverage(sdf); float coverage = edge_coverage(sdf);
if (paint_mode > 0.5) { if (paint_mode > 0.5) {
// The shadow pass is multiplied by (1 - coverage). Fully covered // The shadow pass is multiplied by (1 - coverage). Fully covered
// pixels need neither a second contour solve nor a Gaussian sample. // pixels need neither a second contour solve nor a Gaussian sample.
if (coverage >= 1.0) { gl_FragColor = vec4(0.0); return; } if (coverage >= 1.0) { gl_FragColor = vec4(0.0); return; }
float distance = max(rounded_rect_distance(gl_FragCoord.xy, shadow_rect, material_radius), 0.0); float distance = max(rounded_rect_corners_distance(gl_FragCoord.xy, shadow_rect, material_radii), 0.0);
float sigma = max(shadow_values.x * 0.5, 0.5); float sigma = max(shadow_values.x * 0.5, 0.5);
float opacity = exp(-0.5 * distance * distance / (sigma * sigma)) float opacity = exp(-0.5 * distance * distance / (sigma * sigma))
* shadow_values.y * (1.0 - coverage) * alpha; * shadow_values.y * (1.0 - coverage) * alpha;
+8
View File
@@ -464,6 +464,8 @@ pub struct Ferese {
workspace_auto_back_and_forth: bool, workspace_auto_back_and_forth: bool,
pub(crate) window_rules: Vec<WindowRule>, pub(crate) window_rules: Vec<WindowRule>,
pub(crate) theme_settings: ThemeSettings, pub(crate) theme_settings: ThemeSettings,
pub(crate) panel_corner_radius: Option<ferese_config::panel::CornerRadii>,
pub(crate) panel_background_opacity: Option<f32>,
pub(crate) focus_effect: crate::config::FocusEffectSettings, pub(crate) focus_effect: crate::config::FocusEffectSettings,
animations_enabled: bool, animations_enabled: bool,
animation_speed: f64, animation_speed: f64,
@@ -555,6 +557,8 @@ pub struct RuntimeConfig {
pub workspace_auto_back_and_forth: bool, pub workspace_auto_back_and_forth: bool,
pub window_rules: Vec<WindowRule>, pub window_rules: Vec<WindowRule>,
pub theme_settings: ThemeSettings, pub theme_settings: ThemeSettings,
pub(crate) panel_corner_radius: Option<ferese_config::panel::CornerRadii>,
pub(crate) panel_background_opacity: Option<f32>,
pub focus_effect: crate::config::FocusEffectSettings, pub focus_effect: crate::config::FocusEffectSettings,
pub default_column_width: ColumnWidth, pub default_column_width: ColumnWidth,
pub scrolling_focus_strategy: ViewportFocusStrategy, pub scrolling_focus_strategy: ViewportFocusStrategy,
@@ -740,6 +744,8 @@ impl Ferese {
workspace_auto_back_and_forth: config.workspace_auto_back_and_forth, workspace_auto_back_and_forth: config.workspace_auto_back_and_forth,
window_rules: config.window_rules, window_rules: config.window_rules,
theme_settings: config.theme_settings, theme_settings: config.theme_settings,
panel_corner_radius: config.panel_corner_radius,
panel_background_opacity: config.panel_background_opacity,
focus_effect: config.focus_effect, focus_effect: config.focus_effect,
animations_enabled: config.animations_enabled, animations_enabled: config.animations_enabled,
animation_speed: config.animation_speed, animation_speed: config.animation_speed,
@@ -928,6 +934,8 @@ impl Ferese {
let idle_policy_changed = self.idle_inhibit != config.idle_inhibit || old_rules != self.window_rules; let idle_policy_changed = self.idle_inhibit != config.idle_inhibit || old_rules != self.window_rules;
self.idle_inhibit = config.idle_inhibit; self.idle_inhibit = config.idle_inhibit;
self.theme_settings = config.theme_settings; self.theme_settings = config.theme_settings;
self.panel_corner_radius = config.panel_corner_radius;
self.panel_background_opacity = config.panel_background_opacity;
self.focus_effect = config.focus_effect; self.focus_effect = config.focus_effect;
self.column_width_presets = config.column_width_presets; self.column_width_presets = config.column_width_presets;
self.animations_enabled = config.animations_enabled; self.animations_enabled = config.animations_enabled;
+2 -1
View File
@@ -60,7 +60,8 @@ pub(crate) fn prepare_document(
let candidate = resolve_with_context(document, directory, Timestamp::now(), &auto_context(), read_source)?; let candidate = resolve_with_context(document, directory, Timestamp::now(), &auto_context(), read_source)?;
let config: Config = let config: Config =
serde_json::from_value(document.with_theme(&candidate.theme).value().clone()).map_err(|e| e.to_string())?; serde_json::from_value(document.with_theme(&candidate.theme).value().clone()).map_err(|e| e.to_string())?;
let runtime = config.runtime_config().map_err(|e| e.to_string())?; let mut runtime = config.runtime_config().map_err(|e| e.to_string())?;
runtime.theme_settings = Config::resolved_theme_settings(&candidate.theme).map_err(|e| e.to_string())?;
Ok((config, runtime, candidate)) Ok((config, runtime, candidate))
} }
+2
View File
@@ -666,6 +666,8 @@ mod tests {
let config: WallpaperConfig = ferese_config::from_str("").unwrap(); let config: WallpaperConfig = ferese_config::from_str("").unwrap();
let path = config.path.unwrap(); let path = config.path.unwrap();
assert_eq!(image::image_dimensions(&path).unwrap(), (3840, 2160)); assert_eq!(image::image_dimensions(&path).unwrap(), (3840, 2160));
let light = ferese_config::default_wallpaper_for(ferese_config::theme::Appearance::Light);
assert_eq!(image::image_dimensions(light).unwrap(), (3840, 2160));
assert!(WallpaperState::new(WallpaperConfig::default()).owns_background()); assert!(WallpaperState::new(WallpaperConfig::default()).owns_background());
} }
+1 -1
View File
@@ -173,7 +173,7 @@ mod tests {
let paths = [ let paths = [
Path::new(concat!( Path::new(concat!(
env!("CARGO_MANIFEST_DIR"), env!("CARGO_MANIFEST_DIR"),
"/../assets/wallpapers/ferese-wallpaper-dark.jpg" "/../assets/wallpapers/ferese-wallpaper-dark.png"
)), )),
Path::new(concat!( Path::new(concat!(
env!("CARGO_MANIFEST_DIR"), env!("CARGO_MANIFEST_DIR"),
+5 -6
View File
@@ -47,7 +47,6 @@ pub struct StickyNote {
pub opacity: f32, pub opacity: f32,
pub padding: f32, pub padding: f32,
pub gap: f32, pub gap: f32,
pub radius: f32,
pub alignment: Alignment, pub alignment: Alignment,
} }
@@ -73,7 +72,6 @@ impl Default for StickyNote {
opacity: 0.9, opacity: 0.9,
padding: 20., padding: 20.,
gap: 8., gap: 8.,
radius: 16.,
alignment: Alignment::Left, alignment: Alignment::Left,
} }
} }
@@ -122,7 +120,6 @@ impl StickyNote {
("opacity", self.opacity, 0., 1.), ("opacity", self.opacity, 0., 1.),
("padding", self.padding, 0., 64.), ("padding", self.padding, 0., 64.),
("gap", self.gap, 0., 64.), ("gap", self.gap, 0., 64.),
("radius", self.radius, 0., 128.),
] { ] {
if !value.is_finite() || !(min..=max).contains(&value) { if !value.is_finite() || !(min..=max).contains(&value) {
return invalid(name); return invalid(name);
@@ -208,7 +205,6 @@ pub struct Clock {
pub gap: f32, pub gap: f32,
pub padding: f32, pub padding: f32,
pub background: Option<String>, pub background: Option<String>,
pub radius: f32,
} }
impl Default for Clock { impl Default for Clock {
@@ -238,7 +234,6 @@ impl Default for Clock {
gap: 4., gap: 4.,
padding: 12., padding: 12.,
background: None, background: None,
radius: 16.,
} }
} }
} }
@@ -279,7 +274,6 @@ impl Clock {
("opacity", self.opacity, 0., 1.), ("opacity", self.opacity, 0., 1.),
("gap", self.gap, 0., 64.), ("gap", self.gap, 0., 64.),
("padding", self.padding, 0., 64.), ("padding", self.padding, 0., 64.),
("radius", self.radius, 0., 128.),
] { ] {
if !value.is_finite() || !(min..=max).contains(&value) { if !value.is_finite() || !(min..=max).contains(&value) {
return invalid(name); return invalid(name);
@@ -314,6 +308,11 @@ impl Clock {
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn widgets_reject_removed_radius_options() {
assert!(crate::from_str::<DesktopWidgets>("clock { radius 12; }").is_err());
assert!(crate::from_str::<DesktopWidgets>("note id=\"note\" { radius 12; }").is_err());
}
#[test]
fn notes_reject_duplicate_ids_and_unbounded_content() { fn notes_reject_duplicate_ids_and_unbounded_content() {
let note = StickyNote::default(); let note = StickyNote::default();
note.validate().unwrap(); note.validate().unwrap();
+113 -20
View File
@@ -1,6 +1,7 @@
pub mod desktop; pub mod desktop;
pub mod families; pub mod families;
pub mod notifications; pub mod notifications;
pub mod panel;
pub mod presets; pub mod presets;
pub mod theme; pub mod theme;
@@ -15,27 +16,16 @@ pub const DEFAULT_MATERIAL_OPACITY: f64 = 0.78;
#[derive(Clone, Copy, Debug, Default, serde::Deserialize, serde::Serialize, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum BarLayout { pub enum PanelPreset {
#[default] #[default]
Continuous, Continuous,
Islands, Islands,
} }
pub const fn default_bar_island_padding() -> f32 { pub const fn default_island_padding() -> f32 {
4.0 4.0
} }
pub fn deserialize_bar_island_padding<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<f32, D::Error> {
let padding = <f32 as serde::Deserialize>::deserialize(deserializer)?;
if !padding.is_finite() || !(0.0..=32.0).contains(&padding) {
return Err(serde::de::Error::custom(
"bar-island-padding must be between 0 and 32 logical pixels",
));
}
Ok(padding)
}
#[derive(Debug)] #[derive(Debug)]
pub struct Error(String); pub struct Error(String);
@@ -69,7 +59,7 @@ pub fn default_wallpaper_for(appearance: theme::Appearance) -> &'static str {
static DARK: std::sync::OnceLock<String> = std::sync::OnceLock::new(); static DARK: std::sync::OnceLock<String> = std::sync::OnceLock::new();
let (slot, name) = match appearance { let (slot, name) = match appearance {
theme::Appearance::Light => (&LIGHT, "ferese-wallpaper-light.png"), theme::Appearance::Light => (&LIGHT, "ferese-wallpaper-light.png"),
theme::Appearance::Dark => (&DARK, "ferese-wallpaper-dark.jpg"), theme::Appearance::Dark => (&DARK, "ferese-wallpaper-dark.png"),
}; };
slot.get_or_init(|| { slot.get_or_init(|| {
let path = std::env::current_exe() let path = std::env::current_exe()
@@ -97,6 +87,9 @@ fn field(name: &str, parent: &str) -> String {
"output-profile" => "output_profiles".into(), "output-profile" => "output_profiles".into(),
"output" => "outputs".into(), "output" => "outputs".into(),
"note" => "notes".into(), "note" => "notes".into(),
"panel" => "panels".into(),
"group" => "groups".into(),
"item" => "items".into(),
_ => name.replace('-', "_"), _ => name.replace('-', "_"),
} }
} }
@@ -104,9 +97,13 @@ fn field(name: &str, parent: &str) -> String {
fn is_records(key: &str, parent: &str) -> bool { fn is_records(key: &str, parent: &str) -> bool {
matches!( matches!(
(parent, key), (parent, key),
("", "bindings" | "window_rules" | "output_profiles" | "autostart") (
| ("output_profiles", "outputs") "",
"bindings" | "window_rules" | "output_profiles" | "autostart" | "panels"
) | ("output_profiles", "outputs")
| ("desktop_widgets", "notes") | ("desktop_widgets", "notes")
| ("start" | "center" | "end", "groups")
| ("groups", "items")
) )
} }
@@ -114,7 +111,7 @@ fn is_array(key: &str, parent: &str) -> bool {
parent == "commands" parent == "commands"
|| matches!( || matches!(
key, key,
"command" | "position" | "width_presets" | "xkb_options" | "settings_command" | "outputs" "command" | "position" | "padding" | "width_presets" | "xkb_options" | "settings_command" | "outputs"
) )
} }
@@ -190,7 +187,7 @@ fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Value, Error> {
"bindings" => &["keys", "action", "argument"], "bindings" => &["keys", "action", "argument"],
"output_profiles" => &["name"], "output_profiles" => &["name"],
"outputs" => &["match"], "outputs" => &["match"],
"notes" => &["id"], "notes" | "panels" | "groups" | "items" => &["id"],
"autostart" => &[], "autostart" => &[],
"window_rules" => &[], "window_rules" => &[],
_ => unreachable!(), _ => unreachable!(),
@@ -364,6 +361,12 @@ impl Document {
} }
let mut candidate = self.clone(); let mut candidate = self.clone();
let (name, parents) = path.rsplit_once('.').map_or((path, ""), |(p, n)| (n, p)); let (name, parents) = path.rsplit_once('.').map_or((path, ""), |(p, n)| (n, p));
if unset_property(&mut candidate.doc, &path.split('.').collect::<Vec<_>>(), "")? {
format_document(&mut candidate.doc);
candidate.refresh()?;
*self = candidate;
return Ok(());
}
let doc = section_mut( let doc = section_mut(
&mut candidate.doc, &mut candidate.doc,
&parents.split('.').filter(|s| !s.is_empty()).collect::<Vec<_>>(), &parents.split('.').filter(|s| !s.is_empty()).collect::<Vec<_>>(),
@@ -546,12 +549,99 @@ fn section_mut<'a>(doc: &'a mut KdlDocument, parts: &[&str], parent: &str) -> Re
section_mut(child, &parts[used..], key) section_mut(child, &parts[used..], key)
} }
/// Scalar settings in record nodes can be KDL properties rather than children.
fn unset_property(doc: &mut KdlDocument, parts: &[&str], parent: &str) -> Result<bool, Error> {
if parts.len() < 2 {
return Ok(false);
}
let key = parts[0];
let record = is_records(key, parent);
let used = if record { 2 } else { 1 };
let index = if record {
parts
.get(1)
.and_then(|part| part.parse::<usize>().ok())
.ok_or_else(|| Error("Record index missing".into()))?
} else {
0
};
let Some(node) = doc
.nodes_mut()
.iter_mut()
.filter(|node| field(node.name().value(), parent) == key)
.nth(index)
else {
return Ok(false);
};
if parts.len() == used + 1
&& let Some(index) = node
.entries()
.iter()
.position(|entry| entry.name().is_some_and(|name| field(name.value(), key) == parts[used]))
{
let entry = node.entries_mut().remove(index);
if let Some(format) = entry.format() {
let comments = [&format.leading, &format.trailing]
.into_iter()
.filter(|part| part.contains("//") || part.contains("/*"))
.cloned()
.collect::<String>();
if !comments.is_empty() {
let mut format = node.format().cloned().unwrap_or_default();
format.leading.push_str(&comments);
node.set_format(format);
}
}
return Ok(true);
}
if let Some(children) = node.children_mut() {
unset_property(children, &parts[used..], key)
} else {
Ok(false)
}
}
fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) -> Result<(), Error> { fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) -> Result<(), Error> {
if parts.is_empty() { if parts.is_empty() {
return Err(Error("Missing setting path".into())); return Err(Error("Missing setting path".into()));
} }
let key = parts[0]; let key = parts[0];
if parts.len() == 1 && is_records(key, parent) {
let records = value
.as_array()
.ok_or_else(|| Error("Expected a list of records".into()))?;
let mut nodes = records
.iter()
.map(|value| value_node(key, value, parent))
.collect::<Result<Vec<_>, _>>()?;
let at = doc
.nodes()
.iter()
.position(|node| field(node.name().value(), parent) == key)
.unwrap_or(doc.nodes().len());
let mut comments = String::new();
for node in doc
.nodes()
.iter()
.filter(|node| field(node.name().value(), parent) == key)
{
collect_comments(node, &mut comments);
}
doc.nodes_mut().retain(|node| field(node.name().value(), parent) != key);
if let Some(first) = nodes.first_mut() {
first.set_format(kdl::KdlNodeFormat {
leading: comments,
..Default::default()
});
} else if !comments.is_empty() {
let mut format = doc.format().cloned().unwrap_or_default();
format.trailing.push_str(&comments);
doc.set_format(format);
}
doc.nodes_mut().splice(at..at, nodes);
return Ok(());
}
let i = node_index(doc, key, parent); let i = node_index(doc, key, parent);
if parts.len() == 1 { if parts.len() == 1 {
@@ -588,7 +678,7 @@ fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) ->
"bindings" => &["keys", "action", "argument"], "bindings" => &["keys", "action", "argument"],
"outputs" => &["match"], "outputs" => &["match"],
"output_profiles" => &["name"], "output_profiles" => &["name"],
"notes" => &["id"], "notes" | "panels" | "groups" | "items" => &["id"],
_ => &[], _ => &[],
}; };
let position = positions.iter().position(|p| *p == rest[0]); let position = positions.iter().position(|p| *p == rest[0]);
@@ -681,6 +771,9 @@ fn node_name(key: &str, parent: &str) -> String {
"output_profiles" => "output-profile".into(), "output_profiles" => "output-profile".into(),
"outputs" => "output".into(), "outputs" => "output".into(),
"notes" => "note".into(), "notes" => "note".into(),
"panels" => "panel".into(),
"groups" => "group".into(),
"items" => "item".into(),
_ => key.replace('_', "-"), _ => key.replace('_', "-"),
} }
} }
@@ -694,7 +787,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
"bindings" => &["keys", "action", "argument"], "bindings" => &["keys", "action", "argument"],
"output_profiles" => &["name"], "output_profiles" => &["name"],
"outputs" => &["match"], "outputs" => &["match"],
"notes" => &["id"], "notes" | "panels" | "groups" | "items" => &["id"],
_ => &[], _ => &[],
}; };
let mut child = KdlDocument::new(); let mut child = KdlDocument::new();
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,69 @@
use serde::{Deserialize, Serialize};
/// Logical dimensions owned by a panel, independent of its color theme or edge.
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct PanelGeometry {
pub height: f32,
pub edge_margin: i32,
pub side_margins: i32,
pub window_clearance: i32,
pub inner_padding: f32,
}
impl Default for PanelGeometry {
fn default() -> Self {
Self {
height: 28.,
edge_margin: 0,
side_margins: 0,
window_clearance: 0,
inner_padding: 12.,
}
}
}
impl PanelGeometry {
pub fn validate(self) -> Result<(), String> {
if !self.height.is_finite()
|| !(24. ..=128.).contains(&self.height)
|| !self.inner_padding.is_finite()
|| !(0. ..=64.).contains(&self.inner_padding)
|| [self.edge_margin, self.side_margins, self.window_clearance]
.into_iter()
.any(|value| !(0..=4096).contains(&value))
{
return Err("panel height must be 24..128, inner padding 0..64, and margins/clearance 0..4096".into());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_geometry_rejects_nonfinite_dimensions_and_negative_clearances() {
for geometry in [
PanelGeometry {
height: f32::NAN,
..Default::default()
},
PanelGeometry {
edge_margin: -1,
..Default::default()
},
PanelGeometry {
height: 12.,
..Default::default()
},
PanelGeometry {
inner_padding: f32::INFINITY,
..Default::default()
},
] {
assert!(geometry.validate().is_err());
}
}
}
+688
View File
@@ -0,0 +1,688 @@
//! Measured panel allocation. This module has no frame timing or service ownership.
use super::{ItemId, OverflowPolicy, Panel, Representation, Zone};
use std::collections::BTreeMap;
#[derive(Clone, Debug)]
pub struct Measurement {
pub id: ItemId,
pub alternatives: Vec<(Representation, f32)>,
pub minimum: Option<f32>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
Visible { representation: Representation, width: f32 },
Overflow,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Resolution {
pub items: BTreeMap<ItemId, Placement>,
pub overflow: Vec<ItemId>,
pub zone_widths: [f32; 3],
/// Separation used when allocating these zones.
pub zone_gap: f32,
pub forced_overflow: bool,
pub overflow_trigger_width: f32,
/// Width needed by the smallest visible representations before automatic overflow.
pub minimum_width: f32,
}
fn zone_width(panel: &Panel, zone: &Zone, items: &BTreeMap<ItemId, Placement>) -> f32 {
let mut groups = Vec::new();
for group in &zone.groups {
let mut widths = Vec::new();
for item in &group.items {
if let Some(Placement::Visible { width, .. }) = items.get(&item.id)
&& *width > 0.0
{
if !widths.is_empty() {
widths.push(item.gap_before.unwrap_or(group.spacing));
}
widths.push(*width);
}
}
if !widths.is_empty() {
let padding = 2.0 * f32::from(group.padding[1]);
let island = if panel.resolved_surface(group) == super::GroupSurface::Island {
2.0 * group.island_padding
} else {
0.0
};
groups.push(widths.iter().sum::<f32>() + padding + island);
}
}
groups.iter().sum::<f32>() + zone.spacing * groups.len().saturating_sub(1) as f32
}
impl Resolution {
fn update_widths(&mut self, panel: &Panel, overflow_width: f32) {
self.zone_widths = [
zone_width(panel, &panel.start, &self.items),
zone_width(panel, &panel.center, &self.items),
zone_width(panel, &panel.end, &self.items),
];
if !self.overflow.is_empty() {
self.zone_widths[2] += overflow_width
+ if self.zone_widths[2] > 0.0 {
panel.end.spacing
} else {
0.0
};
}
}
fn required_width(&self) -> f32 {
self.required_width_with_gap(self.zone_gap)
}
fn required_width_with_gap(&self, gap: f32) -> f32 {
let [start, center, end] = self.zone_widths;
if center > 0.0 {
let side = start.max(end);
center + if side > 0.0 { 2.0 * (side + gap) } else { 0.0 }
} else {
start + end + if start > 0.0 && end > 0.0 { gap } else { 0.0 }
}
}
pub fn fits(&self, available: f32) -> bool {
self.required_width() <= available
}
/// Clamp a requested inset independently for each output. Content may still
/// overflow on an output too small to fit it even without an inset.
pub fn side_margin(&self, requested: i32, output_width: i32, padding: f32) -> i32 {
let maximum = ((output_width as f32 - 2. * padding - self.minimum_width) / 2.)
.floor()
.max(0.);
requested.max(0).min(maximum as i32)
}
}
pub fn resolve(panel: &Panel, measurements: &[Measurement], available: f32, overflow_width: f32) -> Resolution {
resolve_with_gap(panel, measurements, available, overflow_width, 8.)
}
/// Reserve separation between zones while keeping the center on the panel midpoint.
pub fn resolve_with_gap(
panel: &Panel,
measurements: &[Measurement],
available: f32,
overflow_width: f32,
zone_gap: f32,
) -> Resolution {
let available = available.max(0.0);
let zone_gap = zone_gap.max(0.);
let fits = |resolution: &Resolution| resolution.fits(available);
let mut result = Resolution {
zone_gap,
..Resolution::default()
};
let ordered: Vec<_> = [&panel.start, &panel.center, &panel.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.collect();
let measurements: BTreeMap<_, _> = measurements.iter().map(|measured| (&measured.id, measured)).collect();
// Allocation policy follows composition, never the renderer's sample order.
// Lower priorities yield first; definition order breaks equal-priority ties.
let mut candidates: Vec<_> = ordered.iter().enumerate().collect();
candidates.sort_by_key(|(index, item)| (item.priority, *index));
for item in &ordered {
let Some(measured) = measurements.get(&item.id) else {
continue;
};
let Some(&(representation, width)) = measured.alternatives.first() else {
continue;
};
if item.overflow == OverflowPolicy::Always {
result.items.insert(item.id.clone(), Placement::Overflow);
result.overflow.push(item.id.clone());
} else {
result
.items
.insert(item.id.clone(), Placement::Visible { representation, width });
}
}
result.update_widths(panel, overflow_width);
let mut minimum = result.clone();
for measured in measurements.values() {
if let Some(Placement::Visible { width, .. }) = minimum.items.get_mut(&measured.id) {
for (_, alternative) in &measured.alternatives {
*width = width.min(*alternative);
}
if let Some(flexible) = measured.minimum {
*width = width.min(flexible);
}
}
}
minimum.update_widths(panel, overflow_width);
result.minimum_width = minimum.required_width();
// Flexible content yields space without changing the stored item preference.
for (_, item) in &candidates {
let Some(measured) = measurements.get(&item.id).filter(|measured| measured.minimum.is_some()) else {
continue;
};
if fits(&result) {
break;
}
let Some(Placement::Visible { representation, width }) = result.items.get(&measured.id).cloned() else {
continue;
};
let minimum = measured.minimum.unwrap().min(width);
result.items.insert(
measured.id.clone(),
Placement::Visible {
representation,
width: minimum,
},
);
result.update_widths(panel, overflow_width);
}
for (_, item) in &candidates {
if fits(&result) {
break;
}
let Some(measured) = measurements.get(&item.id) else {
continue;
};
for &(representation, width) in measured.alternatives.iter().skip(1) {
if let Some(Placement::Visible { width: previous, .. }) = result.items.get(&item.id)
&& width < *previous
{
result
.items
.insert(item.id.clone(), Placement::Visible { representation, width });
result.update_widths(panel, overflow_width);
if fits(&result) {
break;
}
}
}
}
// Never is honored whenever the mandatory minimums fit. On physically smaller
// surfaces, retain access through overflow instead of silently clipping actions.
for force in [false, true] {
for (_, item) in &candidates {
if fits(&result) {
break;
}
if item.overflow == OverflowPolicy::Never && !force {
continue;
}
if matches!(result.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0) {
result.items.insert(item.id.clone(), Placement::Overflow);
result.overflow.push(item.id.clone());
result.forced_overflow |= force && item.overflow == OverflowPolicy::Never;
result.update_widths(panel, overflow_width);
}
}
}
// A reduction on the shorter side can leave unused room after the other
// side shrinks. Restore preferred representations before expanding titles.
for (_, item) in candidates.iter().rev() {
let Some(measured) = measurements.get(&item.id) else {
continue;
};
let Some(previous @ Placement::Visible { representation, .. }) = result.items.get(&item.id).cloned() else {
continue;
};
for &(candidate, width) in measured
.alternatives
.iter()
.take_while(|(candidate, _)| *candidate != representation)
{
result.items.insert(
item.id.clone(),
Placement::Visible {
representation: candidate,
width: measured.minimum.map_or(width, |minimum| minimum.min(width)),
},
);
result.update_widths(panel, overflow_width);
if fits(&result) {
break;
}
result.items.insert(item.id.clone(), previous.clone());
}
}
result.update_widths(panel, overflow_width);
// Restore the most protected flexible items first, reversing the yield order.
for (_, item) in candidates.iter().rev() {
let Some(measured) = measurements.get(&item.id).filter(|measured| measured.minimum.is_some()) else {
continue;
};
let Some(Placement::Visible {
representation,
width: minimum,
}) = result.items.get(&measured.id).cloned()
else {
continue;
};
let Some(maximum) = measured
.alternatives
.iter()
.find_map(|(candidate, width)| (*candidate == representation).then_some(*width))
else {
continue;
};
let mut low = minimum;
let mut high = maximum;
for _ in 0..20 {
let width = (low + high) * 0.5;
result
.items
.insert(measured.id.clone(), Placement::Visible { representation, width });
result.update_widths(panel, overflow_width);
if fits(&result) { low = width } else { high = width }
}
result.items.insert(
measured.id.clone(),
Placement::Visible {
representation,
width: low,
},
);
}
result
.overflow
.sort_by_key(|id| ordered.iter().position(|item| &item.id == id).unwrap());
result.update_widths(panel, overflow_width);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_center_only_item_keeps_its_preferred_size_without_side_gaps() {
use super::super::{Group, Item, ItemKind};
let mut panel = Panel::default();
panel.start.groups.clear();
panel.end.groups.clear();
panel.center.groups = vec![Group::new(
"center",
vec![Item::new("media", ItemKind::Media)],
[0, 0],
0.,
)];
let measurements = [Measurement {
id: ItemId("media".into()),
alternatives: vec![
(Representation::Wide, 40.),
(Representation::Compact, 24.),
(Representation::Icon, 12.),
],
minimum: None,
}];
for gap in [0., 8., 32.] {
for available in [40., 48.] {
let resolution = resolve_with_gap(&panel, &measurements, available, 24., gap);
assert_eq!(
resolution.items[&ItemId("media".into())],
Placement::Visible {
representation: Representation::Wide,
width: 40.
}
);
assert!(resolution.overflow.is_empty());
assert!(resolution.fits(available));
}
}
}
#[test]
fn center_collision_spacing_applies_only_to_occupied_sides() {
for (widths, required) in [
([0., 40., 0.], 40.),
([20., 40., 0.], 96.),
([0., 40., 20.], 96.),
([20., 40., 30.], 116.),
([20., 0., 30.], 58.),
([0., 0., 30.], 30.),
] {
let resolution = Resolution {
zone_widths: widths,
zone_gap: 8.,
..Default::default()
};
assert!(resolution.fits(required));
assert!(!resolution.fits(required - 1.));
}
}
#[test]
fn a_crowded_opposite_zone_does_not_leave_media_compacted_in_unused_space() {
use super::super::{Group, Item, ItemKind};
let mut panel = Panel::default();
panel.start.groups = vec![Group::new(
"navigation",
vec![
Item::new("overview", ItemKind::Overview),
Item::new("media", ItemKind::Media),
],
[0, 0],
0.,
)];
panel.center.groups = vec![Group::new(
"title",
vec![Item::new("title", ItemKind::FocusedWindow)],
[0, 0],
0.,
)];
panel.end.groups = vec![Group::new(
"status",
vec![Item::new("clock", ItemKind::Clock)],
[0, 0],
0.,
)];
let measured = vec![
Measurement {
id: ItemId("overview".into()),
alternatives: vec![(Representation::Icon, 28.)],
minimum: None,
},
Measurement {
id: ItemId("media".into()),
alternatives: vec![
(Representation::Wide, 180.),
(Representation::Compact, 100.),
(Representation::Icon, 28.),
],
minimum: None,
},
Measurement {
id: ItemId("title".into()),
alternatives: vec![(Representation::Wide, 48.)],
minimum: Some(48.),
},
Measurement {
id: ItemId("clock".into()),
alternatives: vec![
(Representation::Wide, 340.),
(Representation::Compact, 240.),
(Representation::Icon, 140.),
],
minimum: None,
},
];
for mirrored in [false, true] {
if mirrored {
std::mem::swap(&mut panel.start, &mut panel.end);
}
let resolution = resolve(&panel, &measured, 550., 32.);
assert!(resolution.fits(550.));
assert_eq!(
resolution.items[&ItemId("media".into())],
Placement::Visible {
representation: Representation::Wide,
width: 180.
}
);
assert_eq!(
resolution.items[&ItemId("clock".into())],
Placement::Visible {
representation: Representation::Compact,
width: 240.
}
);
}
let mut measured = measured;
measured[1].alternatives[0].1 = 400.;
measured[1].minimum = Some(112.);
for mirrored in [false, true] {
if mirrored {
std::mem::swap(&mut panel.start, &mut panel.end);
}
let resolution = resolve(&panel, &measured, 550., 32.);
assert!(resolution.fits(550.));
assert!(
matches!(resolution.items[&ItemId("media".into())], Placement::Visible { representation: Representation::Wide, width } if width > 200. && width < 400.),
"flexible media must expand into the unused room beside the center"
);
}
}
#[test]
fn fits_uses_the_gap_that_was_resolved() {
let panel = Panel::default();
let measured = measurements(&panel);
for gap in [0., 2., 16., 48.] {
let result = resolve_with_gap(&panel, &measured, 2400., 32., gap);
assert_eq!(result.zone_gap, gap);
let required = result.required_width_with_gap(gap);
assert!(result.fits(required));
assert!(!result.fits(required - 1.));
}
}
#[test]
fn flexible_restoration_stays_within_the_selected_representation() {
let mut panel = Panel::default();
panel.start.groups.clear();
panel.center.groups.clear();
panel.end.groups.retain(|group| group.id.0 == "time");
let mut clock = super::super::Item::new("clock", super::super::ItemKind::Clock);
clock.overflow = OverflowPolicy::Never;
clock.priority = 0;
let audio = super::super::Item::new("audio", super::super::ItemKind::Audio);
panel.end.groups[0].items = vec![clock, audio];
let result = resolve(
&panel,
&[
Measurement {
id: ItemId("clock".into()),
alternatives: vec![(Representation::Wide, 80.), (Representation::Compact, 40.)],
minimum: Some(60.),
},
Measurement {
id: ItemId("audio".into()),
alternatives: vec![(Representation::Icon, 100.)],
minimum: None,
},
],
96.,
32.,
);
assert_eq!(
result.items[&ItemId("clock".into())],
Placement::Visible {
representation: Representation::Compact,
width: 40.
}
);
assert_eq!(result.overflow, [ItemId("audio".into())]);
assert!(result.fits(96.));
}
#[test]
fn allocation_is_independent_of_measurement_order_even_for_flexible_items() {
let panel = Panel::default();
let mut measured = measurements(&panel);
for measurement in &mut measured {
measurement.minimum = Some(24.);
}
for width in [2400., 1400., 1000., 800., 400., 80., 32.] {
let expected = resolve_with_gap(&panel, &measured, width, 32., 16.);
for _ in 0..measured.len() {
measured.rotate_left(1);
assert_eq!(resolve_with_gap(&panel, &measured, width, 32., 16.), expected);
measured.reverse();
assert_eq!(resolve_with_gap(&panel, &measured, width, 32., 16.), expected);
measured.reverse();
}
}
}
fn measurements(panel: &Panel) -> Vec<Measurement> {
[&panel.start, &panel.center, &panel.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.map(|item| Measurement {
id: item.id.clone(),
alternatives: vec![
(Representation::Wide, 80.0),
(Representation::Compact, 40.0),
(Representation::Icon, 24.0),
],
minimum: matches!(item.kind, crate::panel::ItemKind::FocusedWindow).then_some(48.0),
})
.collect()
}
#[test]
fn narrow_panels_adapt_deterministically_and_keep_every_item_reachable() {
for background in [crate::PanelPreset::Continuous, crate::PanelPreset::Islands] {
let panel = Panel::from_preset(background);
let measured = measurements(&panel);
for width in [2400., 1200., 800., 400., 160., 80., 32.] {
let resolution = resolve(&panel, &measured, width, 32.);
assert!(resolution.fits(width));
assert_eq!(resolution, resolve(&panel, &measured, width, 32.));
assert_eq!(resolution.items.len(), measured.len());
for id in &resolution.overflow {
assert_eq!(resolution.items.get(id), Some(&Placement::Overflow));
}
if width >= 400. {
assert!(!resolution.forced_overflow);
}
}
}
}
#[test]
fn decoration_and_borders_do_not_change_background_arrangement_or_allocation() {
for background in [crate::PanelPreset::Continuous, crate::PanelPreset::Islands] {
let mut panel = Panel::from_preset(background);
let measurements = measurements(&panel);
let plain = resolve(&panel, &measurements, 1000., 32.);
assert!(!panel.border);
panel.border = true;
panel.background_opacity = Some(0.4);
assert_eq!(resolve(&panel, &measurements, 1000., 32.), plain);
assert_eq!(panel.preset(), Some(background));
}
}
#[test]
fn side_margins_stop_before_overflow_and_use_each_outputs_logical_width() {
let panel = Panel::default();
let measurements = measurements(&panel);
let full = resolve(&panel, &measurements, 4000., 32.);
assert!(full.minimum_width < full.required_width());
for output_width in [1600, 2400, 3840] {
for requested in [0, 32, 400, 4096] {
let margin = full.side_margin(requested, output_width, 12.);
let available = output_width as f32 - 2. * (margin as f32 + 12.);
let result = resolve(&panel, &measurements, available, 32.);
assert!(result.overflow.is_empty(), "output {output_width}, margin {margin}");
assert!(result.fits(available));
assert!(margin <= requested);
}
}
assert!(full.side_margin(4096, 3840, 12.) > full.side_margin(4096, 1600, 12.));
assert_eq!(full.side_margin(4096, 100, 12.), 0);
assert!(
!resolve(&panel, &measurements, 76., 32.).overflow.is_empty(),
"physical output shortage still uses existing overflow"
);
}
#[test]
fn margin_limit_includes_group_padding_and_authored_overflow_without_dependence_on_current_surface_width() {
let mut panel = Panel::default();
panel.end.groups[0].items[0].overflow = OverflowPolicy::Always;
let measurements = measurements(&panel);
let full = resolve(&panel, &measurements, 4000., 32.);
let narrow = resolve(&panel, &measurements, 100., 32.);
assert_eq!(full.minimum_width, narrow.minimum_width);
let margin = full.side_margin(4096, 2400, 12.);
let result = resolve(&panel, &measurements, 2400. - 2. * (margin as f32 + 12.), 32.);
assert_eq!(result.overflow, full.overflow);
let original = full.minimum_width;
panel.end.groups[0].padding[1] += 10;
let padded = resolve(&panel, &measurements, 4000., 32.);
assert!(padded.minimum_width > original);
assert!(padded.side_margin(4096, 2400, 12.) < margin);
}
#[test]
fn asymmetric_sides_reserve_a_real_screen_center() {
let panel = Panel::default();
let resolution = resolve(&panel, &measurements(&panel), 1500., 32.);
let [start, center, end] = resolution.zone_widths;
assert!(end > start);
assert!(start + 8. <= (1500. - center) * 0.5);
assert!((1500. + center) * 0.5 + 8. <= 1500. - end);
}
#[test]
fn always_overflow_is_reserved_and_unavailable_items_are_not_allocated() {
let mut panel = Panel::default();
panel.end.groups[0].items[2].overflow = OverflowPolicy::Always;
let mut measured = measurements(&panel);
measured.remove(3);
let resolution = resolve(&panel, &measured, 2000., 32.);
assert!(!resolution.items.contains_key(&ItemId("media".into())));
assert_eq!(resolution.overflow, [ItemId("network".into())]);
}
#[test]
fn priority_controls_displacement_and_expansion_restores_requested_sizes() {
let mut panel = Panel::default();
panel.start.groups.clear();
panel.center.groups.clear();
panel.end.groups.retain(|group| group.id.0 == "time");
let mut first = panel.end.groups[0].items[0].clone();
first.id = ItemId("low".into());
first.priority = 0;
let mut second = first.clone();
second.id = ItemId("high".into());
second.priority = 100;
let mut third = first.clone();
third.id = ItemId("middle".into());
third.priority = 50;
panel.end.groups[0].items = vec![first, second, third];
let measured: Vec<_> = panel.end.groups[0]
.items
.iter()
.map(|item| Measurement {
id: item.id.clone(),
alternatives: vec![(Representation::Wide, 50.)],
minimum: None,
})
.collect();
let initial = panel.clone();
let small = resolve(&panel, &measured, 150., 32.);
assert_eq!(small.overflow, [ItemId("low".into())]);
assert!(small.fits(150.));
for width in [200., 150., 40., 200.] {
let result = resolve(&panel, &measured, width, 32.);
assert!(result.fits(width));
if width == 200. {
assert!(result.overflow.is_empty());
assert!(result.items.values().all(|placement| matches!(placement, Placement::Visible { representation: Representation::Wide, width } if *width == 50.)));
}
assert_eq!(panel, initial, "resolution must not rewrite configuration intent");
}
}
#[test]
fn only_island_groups_reserve_island_padding() {
let mut panel = Panel::from_preset(crate::PanelPreset::Islands);
let measured = measurements(&panel);
let initial = resolve(&panel, &measured, 2400., 32.);
let title_padding = 2. * panel.center.groups[0].island_padding;
panel.center.groups[0].surface = Some(super::super::GroupSurface::None);
let plain = resolve(&panel, &measured, 2400., 32.);
assert_eq!(plain.zone_widths[1], initial.zone_widths[1] - title_padding);
assert_eq!(plain.zone_widths[0], initial.zone_widths[0]);
assert_eq!(plain.zone_widths[2], initial.zone_widths[2]);
panel.center.groups[0].surface = Some(super::super::GroupSurface::Inset);
assert_eq!(resolve(&panel, &measured, 2400., 32.), plain);
// A parent panel fill cannot change a group's allocation policy.
panel.surface = super::super::PanelSurface::Solid;
assert_eq!(resolve(&panel, &measured, 2400., 32.), plain);
}
}
+157
View File
@@ -0,0 +1,157 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Corner radii, clockwise from top-left, in logical pixels.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CornerRadii(pub [f32; 4]);
impl CornerRadii {
pub fn uniform(radius: f32) -> Self {
Self([radius; 4])
}
pub fn validate(self) -> Result<(), String> {
if self.0.iter().all(|value| value.is_finite() && *value >= 0.) {
Ok(())
} else {
Err("corner radii must be finite, nonnegative pixel values".into())
}
}
/// A panel touching the top edge must cover its top corners.
pub fn at_top_edge(mut self, touching: bool) -> Self {
if touching {
self.0[0] = 0.;
self.0[1] = 0.;
}
self
}
pub fn at_edge(mut self, edge: super::Edge, touching: bool) -> Self {
if touching {
match edge {
super::Edge::Top => self = self.at_top_edge(true),
super::Edge::Bottom => {
self.0[2] = 0.;
self.0[3] = 0.;
}
}
}
self
}
pub fn max(self) -> f32 {
self.0.into_iter().fold(0., f32::max)
}
/// Keep every corner within half the shorter surface dimension.
pub fn clamped(self, width: f32, height: f32) -> Self {
let limit = width.min(height).max(0.) / 2.;
Self(self.0.map(|value| value.min(limit)))
}
}
impl std::str::FromStr for CornerRadii {
type Err = String;
fn from_str(source: &str) -> Result<Self, Self::Err> {
let values: Vec<f32> = source
.split_whitespace()
.map(|part| {
let number = if part == "0" {
part
} else {
part.strip_suffix("px").ok_or("use pixel values such as 4px")?
};
number.parse::<f32>().map_err(|_| "invalid corner radius")
})
.collect::<Result<_, _>>()
.map_err(str::to_owned)?;
let radii = match values.as_slice() {
[a] => [*a; 4],
[a, b] => [*a, *b, *a, *b],
[a, b, c] => [*a, *b, *c, *b],
[a, b, c, d] => [*a, *b, *c, *d],
_ => return Err("enter one to four pixel values, clockwise from top-left".into()),
};
let result = Self(radii);
result.validate()?;
Ok(result)
}
}
impl std::fmt::Display for CornerRadii {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let [a, b, c, d] = self.0;
if a == b && b == c && c == d {
write!(f, "{a}px")
} else if a == c && b == d {
write!(f, "{a}px {b}px")
} else if b == d {
write!(f, "{a}px {b}px {c}px")
} else {
write!(f, "{a}px {b}px {c}px {d}px")
}
}
}
impl Serialize for CornerRadii {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for CornerRadii {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn css_shorthand_expands_clockwise_and_round_trips() {
for (source, expected) in [
("4px", [4.; 4]),
("4px 8px", [4., 8., 4., 8.]),
("4px 8px 12px", [4., 8., 12., 8.]),
("4px 8px 12px 16px", [4., 8., 12., 16.]),
("0 2.5px", [0., 2.5, 0., 2.5]),
] {
let radii: CornerRadii = source.parse().unwrap();
assert_eq!(radii.0, expected);
let value = serde_json::to_value(radii).unwrap();
assert_eq!(serde_json::from_value::<CornerRadii>(value).unwrap(), radii);
}
}
#[test]
fn docking_squares_only_the_top_corners_without_changing_the_override() {
let radii = CornerRadii([4., 8., 12., 16.]);
assert_eq!(radii.at_top_edge(true).0, [0., 0., 12., 16.]);
assert_eq!(radii.at_top_edge(false), radii);
assert_eq!(radii.at_edge(super::super::Edge::Bottom, true).0, [4., 8., 0., 0.]);
assert_eq!(radii.at_edge(super::super::Edge::Bottom, false), radii);
assert_eq!(radii.0, [4., 8., 12., 16.]);
}
#[test]
fn invalid_values_are_rejected_and_large_radii_clamp_to_surface_size() {
for source in [
"",
"4",
"4%",
"-4px",
"NaNpx",
"infpx",
"4px / 8px",
"1px 2px 3px 4px 5px",
] {
assert!(source.parse::<CornerRadii>().is_err(), "{source}");
}
assert_eq!(CornerRadii([4., 40., 0., 18.]).clamped(100., 28.).0, [4., 14., 0., 14.]);
}
}
+80 -39
View File
@@ -207,8 +207,7 @@ pub fn default_tokens() -> Tokens {
"accent":"#3D7BE6", "on_accent":"#FFFFFF", "border":"#FFFFFF18", "shadow":"#00000055"}, "accent":"#3D7BE6", "on_accent":"#FFFFFF", "border":"#FFFFFF18", "shadow":"#00000055"},
"material":{"style":"solid","opacity":0.78,"blur_radius":12.0,"tint_strength":0.5}, "material":{"style":"solid","opacity":0.78,"blur_radius":12.0,"tint_strength":0.5},
"geometry":{"border_width":1.0,"focus_ring_width":2.0,"window_radius":14.0,"shell_radius":14.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, "control_gap":12.0},
"top_bar_window_gap":0,"panel_padding":12.0,"control_gap":12.0},
"typography":{"font_family":"Inter"}, "typography":{"font_family":"Inter"},
"background":{"path":crate::default_wallpaper(),"lock_path":null,"mode":"fill"}, "background":{"path":crate::default_wallpaper(),"lock_path":null,"mode":"fill"},
"surface":{"bar":{"background":"#111821","text_primary":"#F4F7FB","text_muted":"#8793A2"}}, "surface":{"bar":{"background":"#111821","text_primary":"#F4F7FB","text_muted":"#8793A2"}},
@@ -384,17 +383,6 @@ pub fn resolve_with_context(
.map(|preset| preset.gradient_end) .map(|preset| preset.gradient_end)
.filter(|end| *end != authored_accent); .filter(|end| *end != authored_accent);
let mut explicit_surfaces = std::collections::HashSet::new(); let mut explicit_surfaces = std::collections::HashSet::new();
if root
.get("geometry")
.and_then(|value| value.get("shell_radius"))
.is_none()
&& let Some(radius) = document
.get("appearance.corner_radius")
.and_then(Value::as_f64)
.or_else(|| document.get("theme.geometry.top_bar_radius").and_then(Value::as_f64))
{
tokens.geometry.shell_radius = radius;
}
let mut value = token_value(&tokens)?; let mut value = token_value(&tokens)?;
for file in [&policy.file, &selection.file].into_iter().flatten() { for file in [&policy.file, &selection.file].into_iter().flatten() {
let path = theme_path(directory, file); let path = theme_path(directory, file);
@@ -418,16 +406,16 @@ pub fn resolve_with_context(
explicit_surfaces.insert(role); explicit_surfaces.insert(role);
} }
} }
merge(&mut value, layer, "", &mut warnings, false)?; merge(&mut value, layer, "", false)?;
} }
merge(&mut value, &root, "", &mut warnings, true)?; merge(&mut value, &root, "", true)?;
let mut overrides = selection.overrides.clone(); let mut overrides = selection.overrides.clone();
if !split && policy.family.is_some() { if !split && policy.family.is_some() {
for key in ["colors", "surface"] { for key in ["colors", "surface"] {
overrides.remove(key); overrides.remove(key);
} }
} }
merge(&mut value, &Value::Object(overrides.clone()), "", &mut warnings, false)?; merge(&mut value, &Value::Object(overrides.clone()), "", false)?;
tokens = serde_json::from_value(value).map_err(|e| e.to_string())?; tokens = serde_json::from_value(value).map_err(|e| e.to_string())?;
for layer in [&root, &Value::Object(overrides.clone())] { for layer in [&root, &Value::Object(overrides.clone())] {
for role in ["surface_raised", "application_background"] { for role in ["surface_raised", "application_background"] {
@@ -516,13 +504,21 @@ fn token_value(tokens: &Tokens) -> Result<Value, String> {
Ok(value) Ok(value)
} }
fn merge( /// Check authored tokens before replacing them with a runtime theme snapshot.
target: &mut Value, pub fn validate_overrides(document: &Document) -> Result<(), String> {
layer: &Value, let Some(root) = document.get("theme") else {
prefix: &str, return Ok(());
warnings: &mut Vec<String>, };
inline: bool, let policy: Policy = serde_json::from_value(root.clone()).map_err(|error| error.to_string())?;
) -> Result<(), String> { let mut tokens = token_value(&default_tokens())?;
merge(&mut tokens, root, "", true)?;
for selection in [&policy.light, &policy.dark] {
merge(&mut tokens, &Value::Object(selection.overrides.clone()), "", false)?;
}
Ok(())
}
fn merge(target: &mut Value, layer: &Value, prefix: &str, inline: bool) -> Result<(), String> {
let map = layer let map = layer
.as_object() .as_object()
.ok_or_else(|| format!("theme {prefix} must be a section"))?; .ok_or_else(|| format!("theme {prefix} must be a section"))?;
@@ -545,23 +541,19 @@ fn merge(
{ {
continue; continue;
} }
if prefix == "geometry" && key == "top_bar_radius" {
continue;
}
let path = if prefix.is_empty() { let path = if prefix.is_empty() {
key.clone() key.clone()
} else { } else {
format!("{prefix}.{key}") format!("{prefix}.{key}")
}; };
let Some(current) = target.get_mut(key) else { let Some(current) = target.get_mut(key) else {
warnings.push(format!("Unknown theme token: {path}")); return Err(format!("Unknown theme token: {path}"));
continue;
}; };
if current.is_null() && path.ends_with(".gradient") && value.is_object() { if current.is_null() && path.ends_with(".gradient") && value.is_object() {
*current = json!({"from": "#3D7BE6", "to": "#3D7BE6", "angle": 0.0}); *current = json!({"from": "#3D7BE6", "to": "#3D7BE6", "angle": 0.0});
} }
if current.is_object() { if current.is_object() {
merge(current, value, &path, warnings, inline)?; merge(current, value, &path, inline)?;
} else { } else {
*current = value.clone(); *current = value.clone();
} }
@@ -719,9 +711,6 @@ fn validate(tokens: &Tokens) -> Result<(), String> {
if !["fill", "fit"].contains(&tokens.background.mode.as_str()) { if !["fill", "fit"].contains(&tokens.background.mode.as_str()) {
return Err("Unknown wallpaper mode".into()); return Err("Unknown wallpaper mode".into());
} }
if tokens.geometry.top_bar_height <= 0. {
return Err("top-bar-height must be positive".into());
}
if tokens.typography.font_family.len() > 128 { if tokens.typography.font_family.len() > 128 {
return Err("font-family exceeds 128 bytes".into()); return Err("font-family exceeds 128 bytes".into());
} }
@@ -910,7 +899,7 @@ pub fn import_family(id: &str, source: &str) -> Result<ImportedFamily, String> {
appearance, appearance,
)?; )?;
let mut value = token_value(&base)?; let mut value = token_value(&base)?;
merge(&mut value, layer, "", &mut warnings, false)?; merge(&mut value, layer, "", false)?;
let tokens: Tokens = serde_json::from_value(value).map_err(|e| e.to_string())?; let tokens: Tokens = serde_json::from_value(value).map_err(|e| e.to_string())?;
validate(&tokens)?; validate(&tokens)?;
warnings.extend(authored_warnings(&tokens)); warnings.extend(authored_warnings(&tokens));
@@ -922,7 +911,7 @@ pub fn import_family(id: &str, source: &str) -> Result<ImportedFamily, String> {
} }
for key in root.as_object().ok_or("Theme file must be a section")?.keys() { for key in root.as_object().ok_or("Theme file must be a section")?.keys() {
if !matches!(key.as_str(), "name" | "light" | "dark") { if !matches!(key.as_str(), "name" | "light" | "dark") {
warnings.push(format!("Unknown theme token: {key}")); return Err(format!("Unknown theme token: {key}"));
} }
} }
let [light, dark] = variants; let [light, dark] = variants;
@@ -1295,7 +1284,7 @@ mod tests {
&document, &document,
Path::new("/tmp"), Path::new("/tmp"),
"2026-09-30T12:00:00Z".parse().unwrap(), "2026-09-30T12:00:00Z".parse().unwrap(),
|_| Ok(r##"theme { name "Test"; light { colors { accent "#8F5300"; }; future-token 2; }; }"##.into()), |_| Ok(r##"theme { name "Test"; light { colors { accent "#8F5300"; }; }; }"##.into()),
) )
.unwrap(); .unwrap();
assert_eq!(resolved.theme.requested_accent, "#8F5300"); assert_eq!(resolved.theme.requested_accent, "#8F5300");
@@ -1307,7 +1296,6 @@ mod tests {
assert_eq!(imported.name, "Test"); assert_eq!(imported.name, "Test");
assert!(imported.dark.is_none()); assert!(imported.dark.is_none());
assert!(resolved.files.contains(&PathBuf::from("/tmp/test.kdl"))); assert!(resolved.files.contains(&PathBuf::from("/tmp/test.kdl")));
assert!(resolved.warnings.iter().any(|warning| warning.contains("future_token")));
assert!( assert!(
resolve(&document, Path::new("/tmp"), Timestamp::now(), |_| Ok( resolve(&document, Path::new("/tmp"), Timestamp::now(), |_| Ok(
r#"theme { light { colors { accent "invalid"; }; }; }"#.into() r#"theme { light { colors { accent "invalid"; }; }; }"#.into()
@@ -1397,7 +1385,7 @@ mod tests {
} }
#[test] #[test]
fn partial_files_merge_per_token_and_unknown_tokens_warn() { fn partial_files_merge_per_token() {
let doc = Document::parse( let doc = Document::parse(
"theme { file \"themes/shared.kdl\"; light { file \"themes/light.kdl\"; }; mode \"light\"; }", "theme { file \"themes/shared.kdl\"; light { file \"themes/light.kdl\"; }; mode \"light\"; }",
) )
@@ -1405,7 +1393,7 @@ mod tests {
let result = resolve(&doc, Path::new("/config/ferese"), Timestamp::now(), |path| { let result = resolve(&doc, Path::new("/config/ferese"), Timestamp::now(), |path| {
match path.to_str().unwrap() { match path.to_str().unwrap() {
"/config/ferese/themes/shared.kdl" => { "/config/ferese/themes/shared.kdl" => {
Ok("colors { accent \"#12AABB\"; text-primary \"#182234\"; }; future-token 1".into()) Ok("colors { accent \"#12AABB\"; text-primary \"#182234\"; }".into())
} }
"/config/ferese/themes/light.kdl" => Ok("theme { colors { accent \"#315090\"; }; }".into()), "/config/ferese/themes/light.kdl" => Ok("theme { colors { accent \"#315090\"; }; }".into()),
_ => panic!("wrong relative path"), _ => panic!("wrong relative path"),
@@ -1415,10 +1403,63 @@ mod tests {
assert_eq!(result.theme.requested_accent, "#315090"); assert_eq!(result.theme.requested_accent, "#315090");
assert_eq!(result.theme.tokens.colors.text_primary, "#182234"); assert_eq!(result.theme.tokens.colors.text_primary, "#182234");
assert_eq!(result.files.len(), 2); assert_eq!(result.files.len(), 2);
assert!(result.warnings.iter().any(|s| s.contains("future_token")));
assert!(candidate("theme { colors { accent \"invalid\"; }; }").is_err()); assert!(candidate("theme { colors { accent \"invalid\"; }; }").is_err());
} }
#[test]
fn unknown_theme_tokens_are_rejected_in_every_authored_layer() {
for key in [
"top-bar-radius",
"top-bar-height",
"top-bar-margin-top",
"top-bar-margin-horizontal",
"top-bar-window-clearance",
"shell-raduis",
] {
for section in ["", "light", "dark"] {
let geometry = format!("geometry {{ {key} 8; }}");
let layer = if section.is_empty() {
geometry
} else {
format!("{section} {{ {geometry}; }}")
};
let document = Document::parse(&format!("theme {{ {layer}; }}")).unwrap();
assert!(
validate_overrides(&document)
.unwrap_err()
.contains(&key.replace('-', "_"))
);
assert!(
candidate(&document.to_string())
.err()
.unwrap()
.contains(&key.replace('-', "_"))
);
}
}
let document = Document::parse("theme { file \"bad.kdl\"; }").unwrap();
assert!(
resolve(&document, Path::new("/tmp"), Timestamp::now(), |_| Ok(
"future-token 1".into()
))
.err()
.unwrap()
.contains("future_token")
);
assert!(
import_family("bad", "theme { light { geometry { top-bar-radius 8; }; }; }")
.err()
.unwrap()
.contains("geometry.top_bar_radius")
);
assert!(
import_family("bad", "theme { name Test; future-token 1; light {}; }")
.err()
.unwrap()
.contains("future_token")
);
}
#[test] #[test]
fn accessibility_matrix_resolves_contrast_materials_and_accent() { fn accessibility_matrix_resolves_contrast_materials_and_accent() {
for mode in ["light", "dark"] { for mode in ["light", "dark"] {
+3
View File
@@ -6,6 +6,9 @@
pub mod effects { pub mod effects {
/// Version 1 of the semantic surface effects protocol. /// Version 1 of the semantic surface effects protocol.
pub mod v1 { pub mod v1 {
/// Maximum regions and region opacities in one effects request.
pub const MAX_REGIONS: usize = 32;
#[cfg(feature = "client")] #[cfg(feature = "client")]
pub use generated::client; pub use generated::client;
#[cfg(feature = "server")] #[cfg(feature = "server")]
-10
View File
@@ -27,11 +27,6 @@
"focus_ring_width": 2.0, "focus_ring_width": 2.0,
"window_radius": 14.0, "window_radius": 14.0,
"shell_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 "control_gap": 12.0
}, },
"typography": { "typography": {
@@ -95,11 +90,6 @@
"focus_ring_width": 2.0, "focus_ring_width": 2.0,
"window_radius": 14.0, "window_radius": 14.0,
"shell_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 "control_gap": 12.0
}, },
"typography": { "typography": {
+20 -5
View File
@@ -56,11 +56,6 @@ pub struct Geometry {
pub focus_ring_width: f64, pub focus_ring_width: f64,
pub window_radius: f64, pub window_radius: f64,
pub shell_radius: f64, pub shell_radius: f64,
pub top_bar_height: f64,
pub top_bar_margin_top: i32,
pub top_bar_margin_horizontal: i32,
pub top_bar_window_gap: i32,
pub panel_padding: f64,
pub control_gap: f64, pub control_gap: f64,
} }
@@ -159,3 +154,23 @@ pub struct ResolvedTheme {
pub accessibility: Accessibility, pub accessibility: Accessibility,
pub reduced_motion: bool, pub reduced_motion: bool,
} }
/// Accessibility takes precedence over a panel's authored opacity.
pub fn effective_panel_opacity(inherited: f32, custom: Option<f32>, reduce_transparency: bool) -> f32 {
if reduce_transparency {
1.0
} else {
custom.unwrap_or(inherited)
}
}
impl ResolvedTheme {
pub fn panel_opacity(&self, custom: Option<f32>) -> f32 {
let inherited = if self.tokens.material.style == "translucent" {
self.tokens.material.opacity as f32
} else {
1.0
};
effective_panel_opacity(inherited, custom, self.accessibility.reduce_transparency)
}
}
+3 -2
View File
@@ -8,9 +8,10 @@ rust-version.workspace = true
[dependencies] [dependencies]
jiff.workspace = true jiff.workspace = true
ferese-theme-model = { path = "../ferese-theme-model" } ferese-theme-model = { path = "../ferese-theme-model" }
ferese-config = { path = "../ferese-config" }
libcosmic = { workspace = true, features = ["a11y"] } libcosmic = { workspace = true, features = ["a11y"] }
iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.0" } iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.1" }
[dev-dependencies] [dev-dependencies]
ferese-config = { path = "../ferese-config" } tokio = { version = "1", features = ["rt"] }
+2
View File
@@ -8,7 +8,9 @@ mod geometry;
pub mod icons; pub mod icons;
pub mod menus; pub mod menus;
mod palette; mod palette;
pub mod panel;
mod typography; mod typography;
pub mod workspaces;
pub use button::accent_button; pub use button::accent_button;
pub use contrast::{accent_color, accent_pair, apply, composite, contrast, foreground, luminance}; pub use contrast::{accent_color, accent_pair, apply, composite, contrast, foreground, luminance};
+475
View File
@@ -0,0 +1,475 @@
//! Measure item representations and resolve panel allocation for shell views and previews.
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
use cosmic::{Element, Theme};
use ferese_config::panel::layout::{Measurement, Resolution, resolve_with_gap};
use ferese_config::panel::{ItemId, Panel, Representation};
use std::borrow::Cow;
/// Group outlines never paint a second fill over the panel background.
pub fn group_border(
outline: cosmic::iced::Color,
radius: impl Into<cosmic::iced::border::Radius>,
enabled: bool,
) -> cosmic::widget::container::Style {
let mut style = cosmic::widget::container::Style::default();
style.border.radius = radius.into();
if enabled {
style.border.width = 1.;
style.border.color = outline;
}
style
}
pub struct Sample<'a, M> {
pub id: ItemId,
pub representation: Representation,
pub minimum: Option<f32>,
pub view: Element<'a, M>,
}
pub struct Spacing {
pub overflow_decoration: f32,
pub zone_gap: f32,
}
impl From<f32> for Spacing {
fn from(overflow_decoration: f32) -> Self {
Self {
overflow_decoration,
zone_gap: 8.,
}
}
}
pub fn frame<'a, M: 'a>(
panel: Cow<'a, Panel>,
samples: Vec<Sample<'a, M>>,
spacing: impl Into<Spacing>,
initial: Resolution,
build: impl Fn(&Resolution) -> Element<'a, M> + 'a,
changed: impl Fn(Resolution) -> M + 'a,
) -> Element<'a, M> {
let content = build(&initial);
let spacing = spacing.into();
Element::new(Adaptive {
panel,
samples,
overflow_width: spacing.overflow_decoration,
zone_gap: spacing.zone_gap,
content,
resolution: initial,
build: Box::new(build),
changed: Box::new(changed),
})
}
#[derive(Default)]
struct State {
published: Option<Resolution>,
}
type Build<'a, M> = Box<dyn Fn(&Resolution) -> Element<'a, M> + 'a>;
struct Adaptive<'a, M> {
panel: Cow<'a, Panel>,
samples: Vec<Sample<'a, M>>,
overflow_width: f32,
zone_gap: f32,
content: Element<'a, M>,
resolution: Resolution,
build: Build<'a, M>,
changed: Box<dyn Fn(Resolution) -> M + 'a>,
}
impl<M> Widget<M, Theme, cosmic::Renderer> for Adaptive<'_, M> {
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn children(&self) -> Vec<widget::Tree> {
std::iter::once(widget::Tree::new(&self.content))
.chain(self.samples.iter().map(|sample| widget::Tree::new(&sample.view)))
.collect()
}
fn diff(&mut self, tree: &mut widget::Tree) {
tree.children.truncate(self.samples.len() + 1);
if tree.children.is_empty() {
tree.children.push(widget::Tree::new(&self.content));
}
tree.children[0].diff(self.content.as_widget_mut());
for (index, sample) in self.samples.iter_mut().enumerate() {
if tree.children.len() == index + 1 {
tree.children.push(widget::Tree::new(&sample.view));
}
tree.children[index + 1].diff(sample.view.as_widget_mut());
}
}
fn size(&self) -> Size<Length> {
Size::new(Length::Fill, Length::Fill)
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &cosmic::Renderer,
limits: &layout::Limits,
) -> layout::Node {
let mut measurements: Vec<Measurement> = Vec::new();
// Measure preferred content before imposing the panel's width. Otherwise
// a clipped wide control can look as small as its compact alternative.
let measurement_limits = layout::Limits::new(Size::ZERO, Size::new(f32::INFINITY, limits.max().height));
for (index, sample) in self.samples.iter_mut().enumerate() {
let node = sample
.view
.as_widget_mut()
.layout(&mut tree.children[index + 1], renderer, &measurement_limits);
let width = node.size().width;
if let Some(measurement) = measurements.iter_mut().find(|measurement| measurement.id == sample.id) {
measurement.alternatives.push((sample.representation, width));
} else {
measurements.push(Measurement {
id: sample.id.clone(),
alternatives: vec![(sample.representation, width)],
minimum: sample.minimum,
});
}
}
let trigger = measurements
.iter()
.find(|measurement| measurement.id.0 == "_overflow")
.map_or(0.0, |measurement| measurement.alternatives[0].1);
let trigger = trigger.min((limits.max().width - self.overflow_width).max(0.0));
self.resolution = resolve_with_gap(
&self.panel,
&measurements,
limits.max().width,
(self.overflow_width + trigger).min(limits.max().width),
self.zone_gap,
);
self.resolution.overflow_trigger_width = trigger;
self.content = (self.build)(&self.resolution);
tree.children[0].diff(self.content.as_widget_mut());
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn a11y_nodes(
&self,
layout: Layout<'_>,
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &cosmic::Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, M>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
let state = tree.state.downcast_mut::<State>();
if matches!(event, Event::Window(cosmic::iced::window::Event::RedrawRequested(_)))
&& state.published.as_ref() != Some(&self.resolution)
{
state.published = Some(self.resolution.clone());
shell.publish((self.changed)(self.resolution.clone()));
}
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut cosmic::Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &cosmic::Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &cosmic::Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::advanced::{clipboard, renderer::Headless};
use cosmic::iced::{Font, Pixels, Point};
use cosmic::widget::{button, text};
use ferese_config::panel::OverflowPolicy;
use ferese_config::panel::layout::Placement;
#[derive(Clone, Debug)]
enum Probe {
Resolved(Resolution),
Click,
MeasurementClick,
}
#[test]
fn group_borders_do_not_add_a_fill_or_change_content_colors() {
use cosmic::iced::Color;
let outline = Color::from_rgb(0.4, 0.5, 0.6);
let enabled = group_border(outline, [0., 0., 12., 16.], true);
let disabled = group_border(outline, [0., 0., 12., 16.], false);
assert!(enabled.background.is_none() && disabled.background.is_none());
assert_eq!(enabled.border.width, 1.);
assert_eq!(enabled.border.color, outline);
assert_eq!(disabled.border.width, 0.);
assert_eq!(disabled.border.radius, enabled.border.radius);
assert!(enabled.text_color.is_none() && enabled.icon_color.is_none());
assert!(disabled.text_color.is_none() && disabled.icon_color.is_none());
}
#[test]
fn drawing_an_outlined_group_does_not_raise_panel_background_alpha() {
use cosmic::iced::advanced::{Renderer, renderer};
use cosmic::iced::{Color, widget as widgets};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let bounds = Rectangle::new(Point::ORIGIN, Size::new(40., 40.));
for opacity in [0., 0.5, 1.] {
for border in [false, true] {
renderer.reset(bounds);
let group = cosmic::widget::container(widgets::Space::new().width(40).height(40)).class(
cosmic::theme::Container::custom(move |_| group_border(Color::WHITE, 8., border)),
);
let mut view: Element<'_, ()> = cosmic::widget::container(group)
.class(cosmic::theme::Container::custom(move |_| {
crate::controls::surface_appearance(
Color {
a: opacity,
..Color::WHITE
},
8.,
)
}))
.into();
let mut tree = widget::Tree::new(&view);
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
view.as_widget().draw(
&tree,
&mut renderer,
&Theme::default(),
&renderer::Style {
text_color: Color::WHITE,
icon_color: Color::WHITE,
scale_factor: 1.,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
&bounds,
);
let pixels = Headless::screenshot(&mut renderer, Size::new(40, 40), 1., Color::TRANSPARENT);
let alpha = pixels[(20 * 40 + 20) * 4 + 3];
assert!(
i16::from(alpha).abs_diff((opacity * 255.).round() as i16) <= 1,
"opacity={opacity}, border={border}, rendered alpha={alpha}"
);
}
}
}
#[test]
fn measured_widgets_adapt_publish_once_and_only_visible_controls_receive_input() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let mut panel = Panel::default();
panel.start.groups.clear();
panel.center.groups.clear();
panel.end.groups.retain(|group| group.id.0 == "time");
let id = ItemId("clock".into());
let mut tree = None;
for (width, always) in [(500., false), (75., false), (100., true), (500., false)] {
panel.end.groups[0].items[0].overflow = if always {
OverflowPolicy::Always
} else {
OverflowPolicy::Auto
};
let samples = [
(Representation::Wide, "Wednesday, 7 October · 14:35"),
(Representation::Compact, "14:35"),
(Representation::Icon, "+"),
]
.into_iter()
.map(|(representation, label)| Sample {
id: id.clone(),
representation,
minimum: None,
view: button::custom(text(label).wrapping(cosmic::iced::widget::text::Wrapping::None))
.height(28)
.on_press(Probe::MeasurementClick)
.into(),
})
.chain(std::iter::once(Sample {
id: ItemId("_overflow".into()),
representation: Representation::Icon,
minimum: None,
view: button::custom(text("+"))
.height(28)
.on_press(Probe::MeasurementClick)
.into(),
}))
.collect();
let id = id.clone();
let mut view = frame(
Cow::Borrowed(&panel),
samples,
0.,
Resolution::default(),
move |resolution| {
let width = match resolution.items.get(&id) {
Some(Placement::Visible { width, .. }) => *width,
_ => resolution.overflow_trigger_width,
};
button::custom(text("+"))
.width(width)
.height(28)
.on_press(Probe::Click)
.into()
},
Probe::Resolved,
);
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
tree.diff(&mut view);
let viewport = Rectangle::new(Point::ORIGIN, Size::new(width, 28.));
let limits = layout::Limits::new(Size::ZERO, viewport.size());
let node = view.as_widget_mut().layout(tree, &renderer, &limits);
let mut messages = Vec::new();
for _ in 0..4 {
view.as_widget_mut().update(
tree,
&Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())),
Layout::new(&node),
mouse::Cursor::Unavailable,
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&viewport,
);
}
assert_eq!(messages.len(), 1);
let Probe::Resolved(resolution) = &messages[0] else {
panic!("expected measured resolution")
};
assert!(resolution.fits(width));
if always {
assert_eq!(resolution.overflow, [ItemId("clock".into())]);
} else if width == 500. {
assert!(matches!(
resolution.items.get(&ItemId("clock".into())),
Some(Placement::Visible {
representation: Representation::Wide,
..
})
));
} else {
assert!(matches!(
resolution.items.get(&ItemId("clock".into())),
Some(Placement::Visible {
representation: Representation::Compact | Representation::Icon,
..
})
));
}
for event in [
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
view.as_widget_mut().update(
tree,
&Event::Mouse(event),
Layout::new(&node),
mouse::Cursor::Available(Point::new(5., 10.)),
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&viewport,
);
}
assert_eq!(
messages
.iter()
.filter(|message| matches!(message, Probe::Click))
.count(),
1
);
assert!(
!messages
.iter()
.any(|message| matches!(message, Probe::MeasurementClick))
);
}
}
}
+323
View File
@@ -0,0 +1,323 @@
use std::borrow::Cow;
use cosmic::iced::{Alignment, Color, Length};
use cosmic::widget::{Space, container, row};
use cosmic::{Element, theme};
use ferese_config::panel::WorkspaceStyle;
use crate::Palette;
#[derive(Clone)]
pub struct Workspace<'a> {
pub name: Cow<'a, str>,
pub index: u32,
pub window_count: u32,
pub active: bool,
pub visible_elsewhere: bool,
}
pub fn width(style: WorkspaceStyle) -> f32 {
match style {
WorkspaceStyle::Numbers | WorkspaceStyle::Dots => 24.,
WorkspaceStyle::Tabs => 48.,
WorkspaceStyle::WindowStacks => 32.,
WorkspaceStyle::AppIcons => 48.,
}
}
pub fn tooltip_label(workspace: &Workspace<'_>) -> String {
let name = if workspace.name.is_empty() || workspace.name == workspace.index.to_string() {
String::new()
} else {
format!(" · {}", workspace.name)
};
format!(
"Workspace {}{name} · {} {}{}",
workspace.index,
workspace.window_count,
if workspace.window_count == 1 {
"window"
} else {
"windows"
},
if workspace.active { " · Active" } else { "" },
)
}
pub fn indicator<'a, M: 'a>(
style: WorkspaceStyle,
workspace: Workspace<'a>,
palette: Palette,
font: cosmic::font::Font,
height: f32,
progress: f32,
apps: Vec<Element<'a, M>>,
) -> Element<'a, M> {
let progress = progress.clamp(0., 1.);
let occupied = workspace.window_count > 0;
let neutral = if occupied { palette.text } else { palette.muted };
let foreground = crate::mix(neutral, palette.accent, progress);
let content: Element<'a, M> = match style {
WorkspaceStyle::Numbers | WorkspaceStyle::Tabs => {
let label = if style == WorkspaceStyle::Tabs && !workspace.name.trim().is_empty() {
workspace.name.into_owned()
} else {
workspace.index.to_string()
};
crate::text(label, font)
.size(if style == WorkspaceStyle::Tabs { 11 } else { 12 })
.width(width(style) - 8.)
.align_x(cosmic::iced::alignment::Horizontal::Center)
.wrapping(cosmic::iced::widget::text::Wrapping::None)
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
))
.class(theme::Text::Color(foreground))
.into()
}
WorkspaceStyle::Dots => container(Space::new())
.width(6. + 10. * progress)
.height(6)
.class(theme::Container::custom(move |_| {
let mut paint = crate::controls::surface_appearance(
foreground.scale_alpha(if occupied { 0.65 + 0.35 * progress } else { progress }),
3.,
);
paint.border.width = 1.;
paint.border.color = foreground.scale_alpha(if occupied { 0.65 } else { 0.55 });
paint
}))
.into(),
WorkspaceStyle::WindowStacks => {
let back = match workspace.window_count {
0 | 1 => "",
2 => "<path d=\"M6 4h13a2 2 0 0 1 2 2v9\"/>",
_ => "<path d=\"M6 4h13a2 2 0 0 1 2 2v9M9 1h13a2 2 0 0 1 2 2v9\"/>",
};
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 26 22\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\">{back}<rect x=\"2\" y=\"7\" width=\"16\" height=\"12\" rx=\"2.5\"/>{}</svg>",
if occupied { "<path d=\"M2 11h16\"/>" } else { "" }
);
cosmic::widget::icon::from_svg_bytes(crate::icons::tint_svg(svg.as_bytes(), foreground, foreground))
.symbolic(false)
.icon()
.size(20)
.class(theme::Svg::custom(|_| cosmic::iced::widget::svg::Style { color: None }))
.into()
}
WorkspaceStyle::AppIcons => {
let mut icons = row::with_capacity(2).spacing(3).align_y(Alignment::Center);
if apps.is_empty() {
icons = icons.push(crate::icons::tinted(crate::icons::OVERVIEW, 16, foreground));
} else {
for app in apps.into_iter().take(2) {
icons = icons.push(app);
}
}
icons.into()
}
};
let face = container(content).center_x(width(style)).center_y(height);
let face: Element<'a, M> = if style == WorkspaceStyle::Dots {
face.into()
} else {
let marker = container(Space::new())
.width(10)
.height(2)
.class(crate::controls::surface(foreground.scale_alpha(progress), 1.));
cosmic::iced::widget::stack![
face,
container(marker)
.width(Length::Fill)
.height(Length::Fill)
.align_x(cosmic::iced::alignment::Horizontal::Center)
.align_y(cosmic::iced::alignment::Vertical::Bottom)
.padding([1, 0]),
]
.into()
};
container(face)
.width(width(style))
.height(height)
.clip_to_border(true)
.class(theme::Container::custom(move |_| {
let fill = if style == WorkspaceStyle::Dots {
Color::TRANSPARENT
} else if progress > 0. {
palette.accent.scale_alpha(0.16 * progress)
} else if style == WorkspaceStyle::Tabs || occupied && style == WorkspaceStyle::Numbers {
palette.text.scale_alpha(0.045)
} else {
Color::TRANSPARENT
};
let mut paint = crate::controls::surface_appearance(fill, palette.radius.min(8.));
if workspace.visible_elsewhere {
paint.border.width = 1.;
paint.border.color = palette.accent.scale_alpha(0.55);
}
paint
}))
.into()
}
pub fn sample<'a, M: 'a>(
style: WorkspaceStyle,
palette: Palette,
font: cosmic::font::Font,
height: f32,
) -> Element<'a, M> {
let mut samples = row::with_capacity(3).spacing(1).align_y(Alignment::Center);
for (index, count, active) in [(1, 2, true), (2, 1, false), (3, 0, false)] {
let apps = if style == WorkspaceStyle::AppIcons && count > 0 {
let mut apps = vec![crate::icons::tinted(crate::icons::SETTINGS, 16, palette.text).into()];
if count > 1 {
apps.push(crate::icons::tinted(crate::icons::FERESE, 16, palette.accent).into());
}
apps
} else {
Vec::new()
};
samples = samples.push(indicator(
style,
Workspace {
name: Cow::Owned(index.to_string()),
index,
window_count: count,
active,
visible_elsewhere: false,
},
palette,
font,
height,
if active { 1. } else { 0. },
apps,
));
}
samples.into()
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::advanced::{Layout, Renderer, layout, renderer, renderer::Headless, widget::Tree};
use cosmic::iced::{Font, Pixels, Rectangle, Size, mouse};
#[test]
fn indicators_keep_their_allocation_and_active_shape_in_every_style() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let palette = Palette::from_resolved(&ferese_config::theme::default_theme());
let theme = palette.native_theme();
for style in WorkspaceStyle::ALL {
for height in [20., 28., 36.] {
let bounds = Rectangle::with_size(Size::new(width(style), height));
let mut active_alpha = Vec::new();
let mut inactive_alpha = Vec::new();
for (count, progress, name) in [
(0, 0., ""),
(1, 0., "1"),
(3, 0., "Design and development"),
(3, 0.5, "Design and development"),
(3, 1., "Design and development"),
] {
let apps = (0..count.min(2))
.map(|_| crate::icons::tinted(crate::icons::SETTINGS, 16, palette.text).into())
.collect();
let mut view: Element<'_, ()> = indicator(
style,
Workspace {
name: name.into(),
index: 1,
window_count: count,
active: progress == 1.,
visible_elsewhere: false,
},
palette,
Font::default(),
height,
progress,
apps,
);
let mut tree = Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, bounds.size()),
);
assert_eq!(
node.bounds().size(),
bounds.size(),
"{style:?}, height={height}, count={count}, progress={progress}"
);
renderer.reset(bounds);
view.as_widget().draw(
&tree,
&mut renderer,
&theme,
&renderer::Style {
text_color: palette.text,
icon_color: palette.text,
scale_factor: 1.,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
&bounds,
);
let pixels = Headless::screenshot(
&mut renderer,
Size::new(width(style) as u32, height as u32),
1.,
Color::TRANSPARENT,
);
let alpha: Vec<_> = pixels.chunks_exact(4).map(|p| p[3]).collect();
assert!(alpha.iter().any(|a| *a > 0), "{style:?} rendered empty");
if count == 3 && progress == 0. {
inactive_alpha = alpha;
} else if progress == 1. {
active_alpha = alpha;
}
}
assert_ne!(
active_alpha, inactive_alpha,
"{style:?} needs an active shape that survives removal of color"
);
}
}
}
#[test]
fn hover_details_include_names_and_pluralize_window_counts() {
let mut workspace = Workspace {
name: "Design".into(),
index: 2,
window_count: 1,
active: true,
visible_elsewhere: false,
};
assert_eq!(tooltip_label(&workspace), "Workspace 2 · Design · 1 window · Active");
workspace.name = "2".into();
workspace.window_count = 0;
workspace.active = false;
assert_eq!(tooltip_label(&workspace), "Workspace 2 · 0 windows");
}
}
+206 -39
View File
@@ -39,16 +39,21 @@ output-profile "docked" {
} }
``` ```
Use repeated `binding`, `window-rule`, `output-profile`, `output`, `note` and Lists use repeated `panel`, `group`, `item`, `binding`, `window-rule`,
`autostart` nodes for lists of settings. Bindings accept keys, an action and its `output-profile`, `output`, `note`, and `autostart` nodes. Bindings take their keys,
argument positionally. Profiles, outputs and notes accept a positional name, action, and argument positionally; profiles, outputs, and notes take a positional
match or ID; other fields can be properties or child nodes. Autostart commands name, match, or ID. Panels, groups, and items use positional IDs, while autostart
use positional arguments. Duplicate fields are rejected. commands use positional arguments. Other fields can be properties or child nodes,
The dotted paths in the reference tables below describe nested sections. but duplicate fields are rejected. The dotted paths in the reference tables
below describe nested sections.
Settings preserves comments and custom fields when editing, while normalizing Settings preserves comments and custom fields when editing, while normalizing
indentation. indentation.
The Settings sidebar groups pages under Desktop, Interaction, and System, with
Motion under Windows and Shortcuts under Keyboard & mouse. Search includes both
views and opens the matching setting directly.
## Saving and validation ## Saving and validation
Settings saves text when you press Enter or leave the field, and sliders when Settings saves text when you press Enter or leave the field, and sliders when
@@ -57,9 +62,14 @@ without merging unfinished drafts. Wallpaper browsing needs `zenity`, but you
can also enter a path directly. can also enter a path directly.
Settings keeps `config.kdl.settings-backup` before saving. Settings keeps `config.kdl.settings-backup` before saving.
File edits reload automatically, including atomic editor saves. Invalid changes File edits reload automatically, including atomic editor saves, so changes to
keep the last working configuration. Appearance, wallpaper, motion, input, appearance, wallpaper, motion, input, bindings, rules, layouts, displays, widgets,
bindings, rules, layouts, displays, widgets and login items update live. and login items take effect live. If a reload is invalid, Ferese reports the error
and keeps the last working configuration.
When the shell starts, it loads your configuration or, if no file exists, uses
the defaults. An invalid file stops startup with an error message, leaving your
configuration untouched.
```sh ```sh
feresectl reload-config # reload with error feedback feresectl reload-config # reload with error feedback
@@ -115,7 +125,7 @@ Changing default width preserves manually resized columns.
## Animations ## Animations
Continuum is Ferese's shared compositor and shell motion system. Set animation Continuum is Ferese's shared compositor and shell motion system. Set animation
speed in Settings → Motion or edit the `animations` block in speed in Settings → Windows → Motion or edit the `animations` block in
`~/.config/ferese/config.kdl`. Changes apply live. Start with `speed` if you only `~/.config/ferese/config.kdl`. Changes apply live. Start with `speed` if you only
want faster or slower transitions; you do not need to change the springs. want faster or slower transitions; you do not need to change the springs.
@@ -273,7 +283,6 @@ one appearance.
| `theme.schedule.light-at`, `theme.schedule.dark-at` | `HH:MM` | `"07:00"`, `"19:00"` | Auto boundaries; times must differ | | `theme.schedule.light-at`, `theme.schedule.dark-at` | `HH:MM` | `"07:00"`, `"19:00"` | Auto boundaries; times must differ |
| `theme.accessibility.increase-contrast` | boolean | `false` | Strengthen text and borders | | `theme.accessibility.increase-contrast` | boolean | `false` | Strengthen text and borders |
| `theme.accessibility.reduce-transparency` | boolean | `false` | Solid surfaces; skip background blur | | `theme.accessibility.reduce-transparency` | boolean | `false` | Solid surfaces; skip background blur |
| `appearance.corner-radius` | number ≥ 0 | unset | Legacy fallback for shell radius |
| `appearance.focus-effect.enabled` | boolean | `true` | Apply window opacity and dimming; disabling keeps the configured values | | `appearance.focus-effect.enabled` | boolean | `true` | Apply window opacity and dimming; disabling keeps the configured values |
| `appearance.focus-effect.active-opacity` | number 0–1 | `1` | Active application opacity, including fullscreen | | `appearance.focus-effect.active-opacity` | number 0–1 | `1` | Active application opacity, including fullscreen |
| `appearance.focus-effect.inactive-opacity` | number 0–1 | `1` | Other application window opacity | | `appearance.focus-effect.inactive-opacity` | number 0–1 | `1` | Other application window opacity |
@@ -331,12 +340,6 @@ Filled controls choose a contrasting text color automatically.
| `focus-ring-width` | number ≥ 0 | `2` | Focused border thickness | | `focus-ring-width` | number ≥ 0 | `2` | Focused border thickness |
| `window-radius` | number ≥ 0 | `14` | Managed-window corners, independent of the shell | | `window-radius` | number ≥ 0 | `14` | Managed-window corners, independent of the shell |
| `shell-radius` | number ≥ 0 | `14` | All shell surfaces, cards, widgets and interaction backgrounds; fractional radii are preserved; 0 makes them square | | `shell-radius` | number ≥ 0 | `14` | All shell surfaces, cards, widgets and interaction backgrounds; fractional radii are preserved; 0 makes them square |
| `top-bar-height` | number > 0 | `28` | Menu-bar height |
| `top-bar-margin-top` | integer ≥ 0 | `0` | Space above bar |
| `top-bar-window-gap` | integer ≥ 0 | `0` | Clearance below bar |
| `top-bar-margin-horizontal` | integer ≥ 0 | `0` | Bar side margins |
| `top-bar-radius` | number ≥ 0 | unset | Legacy fallback for shell radius |
| `panel-padding` | number ≥ 0 | `12` | Bar inner padding |
| `control-gap` | number ≥ 0 | `12` | Right-side control spacing | | `control-gap` | number ≥ 0 | `12` | Right-side control spacing |
| Section / key | Type | Default | Meaning | | Section / key | Type | Default | Meaning |
@@ -469,7 +472,7 @@ by other monitors and stop at the first/last workspace. Short or cancelled navig
swipes slide back; diagonal swipes do not navigate. Navigation pauses while locked, during swipes slide back; diagonal swipes do not navigate. Navigation pauses while locked, during
window grabs, or when an application inhibits shortcuts. window grabs, or when an application inhibits shortcuts.
Assign swipes in **Settings → Shortcuts** using the same actions and arguments as Assign swipes in **Settings → Keyboard & mouse → Shortcuts** using the same actions and arguments as
keyboard bindings. Choose a "Customize swipe" button to add an override. The keyboard bindings. Choose a "Customize swipe" button to add an override. The
gesture keys are `Swipe3Up`, `Swipe3Down`, `Swipe3Left`, and `Swipe3Right`; 4 or 5 gesture keys are `Swipe3Up`, `Swipe3Down`, `Swipe3Left`, and `Swipe3Right`; 4 or 5
fingers are also supported. Changes reload live: fingers are also supported. Changes reload live:
@@ -803,30 +806,196 @@ test-only validation. Hardware failures can still prevent every monitor from
working; Ferese tries preferred modes to recover a display instead of working; Ferese tries preferred modes to recover a display instead of
intentionally turning off the last working one. intentionally turning off the last working one.
## Panel composition
Ferese shows one panel on each output, at the top by default. You can use the
default arrangement or define a `panel` block with its own geometry, surface
defaults, ordered groups, and item settings.
In Settings → **Panel & Shell**, **Panels** contains the position, background,
size, spacing, and corner-radius controls. Choose **Edit panel** to open
**Arrange**, where you can organize groups and items while the composition preview
stays visible above the independently scrolling editor and inspector.
**Back to Panels** returns to the panel controls.
Use **Position** to choose Top or Bottom, or set `edge "top"` or `edge "bottom"`
inside the KDL panel block. Menus and notifications open toward the desktop, with
window clearance and **Edge margin** following that screen edge. Panel dimensions
live in a separate `geometry` block, so appearance changes do not resize the
panel. Its fields are `height` (24–128), `edge-margin`, `side-margins`, and
`window-clearance` (0–4096), plus `inner-padding` (0–64); their defaults are 28, 0,
0, 0, and 12 logical pixels respectively.
Each group has an **Add item** menu. Select a control to open its inspector, where
you can change visibility, overflow, preferred size, and placement, or remove the
item. To reorder it, use the **Up / Down** arrow buttons or drag its handle; dragging
also lets you move it to another group, and dropping into an empty zone creates a
group. Hidden controls remain in the editor with faded icons and labels. These
changes use the existing save and Undo controls, and `ferese-settings --page bar`
opens the page directly.
Use **Add group** in Start, Center, or End to create an empty group, then select
its name to change placement, surface, spacing, and padding. Arrow buttons reorder
groups within a zone; before removing a group, move or remove its controls.
The **Background** presets set the panel fill and default group surface together:
Continuous uses `solid` for the panel and `none` for groups, while Islands uses
`none` for the panel and `island` for groups. Applying either preset clears group
surface overrides while keeping your arrangement intact.
Use **Default group surface** to choose what groups inherit, or select Default,
None, Inset, or Island in an individual group's inspector. None adds no fill,
Inset wraps the padded content in a rounded surface, and Island adds a full-height
surface with island padding. Groups on a filled panel use a subtle local tint,
allowing arrangements such as a continuous panel with one media island or
navigation and status islands around an undecorated title. With mixed settings,
neither preset button is selected.
**Group borders** controls the outlines of all groups and is off by default,
while opacity and corner radii apply across the panel.
**Background opacity** overrides shell opacity for the panel background in either
arrangement, without fading controls or changing popups. **Use shell opacity**
restores inheritance.
Choose **Customize corners** to override the radius for the whole panel,
including its groups. The four sliders control top left, top right, bottom right,
and bottom left, saving each change when you release the slider.
In KDL, pixel values follow CSS shorthand: one value sets all corners; two set
top-left/bottom-right and top-right/bottom-left; three set
top-left, top-right/bottom-left, and bottom-right; four run clockwise from
top-left. For example, `4px 8px 12px 16px` gives each corner a different radius.
Values must be finite and nonnegative; percentages and elliptical `/` syntax are
not supported. Radii are clamped to half the shorter surface dimension.
At zero edge margin, panel backgrounds reach the selected screen edge with square
corners where they touch it, without changing the saved radii. Islands preserve
the gaps between groups, while Continuous fills the whole panel.
**Side margins** moves the left and right ends inward together over a range of
0–4096 px. The shell limits the requested inset for each output using measured
content widths: controls may compact, but the inset stops before causing
additional overflow. As content grows or the output gets smaller, the applied
inset shrinks to make room. If the controls cannot fit even at zero margin, the
panel uses its normal overflow behavior.
The panel follows the shell radius unless you set an override, which stays in
place when the shell radius changes. **Use shell radius** clears that override
without changing the arrangement; **Remove custom panel** restores both the
default arrangement and its inherited radius.
Dragging changes the drop hint until release, then saves the complete move once.
Escape, focus loss, and release outside a drop target cancel without saving.
The composition preview follows your draft and adapts to the Settings window's
width using sample content with every service available. Select a preview control
to edit that instance, or use the chevron to reveal overflow items. Browsing and
selection do not save anything: the first edit saves the composition and the
change together. The preview shares the shell's sizing resolver, while live
service state and exact vertical styling can differ.
```kdl
panel "main" {
surface "none"
group-surface "island"
geometry { height 30; edge-margin 6; side-margins 18; window-clearance 4; }
border #false
background-opacity 0.85
start {
group "navigation" {
item "overview" kind="overview" overflow="never"
item "workspaces" kind="workspaces" overflow="never"
}
}
center {
group "title" {
surface "none"
item "title" kind="focused-window" overflow="never"
}
}
end {
group "status" {
item "network" kind="network" overflow="always"
item "audio" kind="audio"
item "battery" kind="battery" percentage=#true
item "clock" kind="clock" representation="compact"
}
}
}
```
Start, center, and end contain ordered groups of item instances. Their IDs must
be unique within the panel, contain 1–128 ASCII letters/digits or `-_.:@`, and not
start with `_`. Exactly one panel can be configured, without per-output overrides.
Built-in kinds are `overview`, `workspaces`, `focused-window`, `media`,
`quick-settings`, `network`, `audio`, `recording`, `notifications`, `battery`,
`clock`, and `display-mode`. Availability still follows the associated service
or hardware. Duplicate kinds are allowed when their IDs differ; clock instances
currently share the same clock format and timezone.
| Scope / key | Values / default | Meaning |
| --- | --- | --- |
| Panel `surface` | `solid` (default), `none` | Full panel fill or transparent space between decorated groups |
| Panel `group-surface` | `none` (default), `inset`, `island` | Inherited group surface |
| Group `surface` | omit to inherit, `none`, `inset`, `island` | Override surface independently of other groups |
| Panel `geometry` | height 28, edge/side margins 0, window clearance 0, inner padding 12 | Dimensions independent of the theme |
| Panel `border` | boolean; default `false` | Show group outlines |
| Panel `corner-radius` | optional px string, e.g. `"4px 8px"` | Override shell radius for all panel surfaces |
| Zone `spacing` | 0–64; default 8 | Gap between groups |
| Panel `background-opacity` | optional 0–1; inherits shell opacity | Override panel background opacity in either arrangement |
| Groups per panel | at most 31 | Reserves one of the 32 material regions for overflow |
| Group `spacing` | 0–64; default 1 | Gap between items |
| Group `padding` | two integers 0–32; default `2 3` | Vertical and horizontal inner padding |
| Group `island-padding` | 0–32; default 4 | Additional horizontal padding for islands |
| Item `visible` | boolean; default `true` | Allow the item when available |
| Item `overflow` | `auto` (default), `never`, `always` | Move when needed, keep visible, or place in overflow |
| Item `priority` | integer 0–100; default 50 | Lower priorities yield space first |
| Item `representation` | omit, `wide`, `compact`, `icon` | Preferred supported form; smaller forms may be used |
| Item `gap-before` | 0–64; unset | Override the preceding gap within a group |
| Item `metadata` | optional section | Custom data preserved by the editor; no shell behavior |
| Workspaces `style` | `numbers`, `dots` (default), `tabs`, `window-stacks`, `app-icons` | Workspace indicators; choose in the Workspaces item inspector |
| Battery `percentage` | boolean; default `true` | Include percentage in its full form |
| Focused window `visible` | boolean; default `true` | Include the title |
Media and clock support all three representations. Battery with a percentage
supports `wide` and `icon`; other controls keep their existing form. Unsupported
preferences use the kind's normal form. The title can shrink and ellipsize;
workspace controls scroll within their allocation. The panel itself does not scroll.
Workspace indicators keep the same width as their state changes. Dots use a pill
for the active workspace, a filled dot for an occupied workspace, and an outline
for an empty one. Tabs show workspace names, window stacks show up to three
window outlines, and app icons show up to two distinct running applications.
Hover for the workspace name and window count. Active transitions follow the
animation and reduced-motion settings. Explicitly set `style="numbers"`
to retain numbered indicators.
Item-specific settings belong to their kind: Workspaces uses `style`, Battery
uses `percentage`, and Focused window uses the shared `visible` flag. Unrecognized
options, including misspellings, are rejected, so custom data belongs in an
explicit `metadata` section such as
`item "clock" kind="clock" { metadata { note "Desk clock"; }; }`.
The center stays screen-centered. When controls cannot fit, lower-priority items
adapt first, with definition order breaking ties. The overflow chevron appears
before the end controls and opens displaced items through their usual menus.
`never` moves to overflow only when the mandatory controls cannot fit. A surface
narrower than the chevron's padded minimum clips that control.
## Status controls ## Status controls
| `status` key | Type | Default | Meaning | | `status` key | Type | Default | Meaning |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `bar-layout` | `"continuous"` or `"islands"` | `"continuous"` | One bar background or separate backgrounds around its sections | | `keybinding-guide` | boolean | `true` | Show the active shortcut guide at login until disabled in Settings → Keyboard & mouse → Shortcuts. |
| `bar-island-padding` | number 0–32 | `4` | Horizontal space on each side between an island's background and its bordered controls, in logical pixels |
| `keybinding-guide` | boolean | `true` | Show the active shortcut guide at login until disabled in Settings → Shortcuts. |
| `window-title` | boolean | `true` | Focused window title in the bar center when space allows |
| `battery-percentage` | boolean | `true` | Show percentage beside icon |
| `low-battery-threshold` | integer 0–100 | `20` | Warning-color threshold | | `low-battery-threshold` | integer 0–100 | `20` | Warning-color threshold |
| `settings-command` | argument array | `["ferese-settings"]` | Settings launcher; `[]` hides the action | | `settings-command` | argument array | `["ferese-settings"]` | Settings launcher; `[]` hides the action |
Choose **Islands** in Settings → Bar, or set it in KDL: Configure panel surfaces in **Panel & Shell** or in the `panel` block above.
Transparent gaps pass clicks through. Group islands use panel opacity and corner
```kdl radii; changing their surface leaves modal transparency and window clearance
status { unchanged. Island padding belongs to each group and applies only to islands.
bar-layout "islands"
bar-island-padding 4
}
```
The gaps between islands are transparent and let clicks pass through. Each island uses the bar background around the existing section's border and fill, with the theme's bar radius and material opacity. Changing the bar layout leaves modal transparency unchanged; there is no need to set the shared opacity to zero. The bar reserves the same space above windows in either layout.
Use **Island side padding** in Settings → Bar to tighten the space around each section. It applies only to islands; continuous bars keep their existing padding.
### Now Playing ### Now Playing
@@ -970,10 +1139,8 @@ The shell and compositor use the same squircle profile for fills, borders, shado
and blur masks. Small controls cap the radius to fit their size; circles and pills and blur masks. Small controls cap the radius to fit their size; circles and pills
keep circular outlines. The measured profile has small tangent and curvature keep circular outlines. The measured profile has small tangent and curvature
discontinuities at its internal segment joins. Window rounding remains under discontinuities at its internal segment joins. Window rounding remains under
Settings → Windows. `theme.geometry.shell-radius` takes precedence over the old Settings → Windows. Shell rounding comes from `theme.geometry.shell-radius`,
`appearance.corner-radius` and then `theme.geometry.top-bar-radius` keys; when which defaults to 14 px, with an optional corner override for each panel.
none are set, the shell uses 14 px. Legacy clock/note radius fields no longer
override shell rounding.
## X11 support (xwayland-satellite) ## X11 support (xwayland-satellite)
+5 -2
View File
@@ -15,8 +15,11 @@ this at fractional scaling too.
## Compare renderers ## Compare renderers
Repeat with `ICED_BACKEND=wgpu` and `ICED_BACKEND=tiny-skia`. The shell enables Repeat the check with `ICED_BACKEND=wgpu` and `ICED_BACKEND=tiny-skia` to compare
wgpu; software fallback can be slower when resampling wallpaper. renderers. The shell and Settings prefer software rendering with GPU fallback,
which keeps Settings labels visible inside scrolling content but can be slower
when resampling wallpaper. Setting `ICED_BACKEND` explicitly overrides that
preference.
Follow the trace from host resize through layer configure, client acknowledgement, Follow the trace from host resize through layer configure, client acknowledgement,
new buffer commit and compositor rendering. A quick acknowledgement does not mean new buffer commit and compositor rendering. A quick acknowledgement does not mean
+1 -1
View File
@@ -1,7 +1,7 @@
# Shortcuts and gestures # Shortcuts and gestures
`Super` is usually the Windows key. These are the built-in defaults. Open `Super` is usually the Windows key. These are the built-in defaults. Open
**Settings → Shortcuts** to change bindings and gestures or disable the login **Settings → Keyboard & mouse → Shortcuts** to change bindings and gestures or disable the login
guide. Your saved bindings can override these defaults. guide. Your saved bindings can override these defaults.
The [example config](../packaging/config.kdl) adds the app, media and arrow-key The [example config](../packaging/config.kdl) adds the app, media and arrow-key
shortcuts listed below. shortcuts listed below.
+53 -7
View File
@@ -129,11 +129,6 @@ theme {
shell-radius 12 shell-radius 12
border-width 1 border-width 1
focus-ring-width 1 focus-ring-width 1
top-bar-height 30
top-bar-margin-horizontal 18
top-bar-margin-top 6
top-bar-window-gap 4
panel-padding 12
control-gap 12 control-gap 12
} }
shadow { shadow {
@@ -148,11 +143,62 @@ theme {
status { status {
keybinding-guide #true keybinding-guide #true
window-title #true
battery-percentage #true
low-battery-threshold 20 low-battery-threshold 20
} }
// Panel composition is explicit; visibility and item options belong to each item.
// Background opacity and corner radius inherit from the shell unless overridden here.
panel "main" {
geometry {
height 30
side-margins 18
edge-margin 6
window-clearance 4
}
edge "top"
surface "solid"
group-surface "none"
border #false
start {
spacing 3
group "overview" {
padding 0 0
item "overview" kind="overview" overflow="never" priority=100
}
group "workspaces" {
padding 2 3
item "workspaces" kind="workspaces" overflow="never" priority=100
}
}
center {
group "title" {
padding 0 0
item "focused-window" kind="focused-window" visible=#true overflow="never"
}
}
end {
spacing 8
group "status" {
padding 2 3
item "media" kind="media"
item "quick-settings" kind="quick-settings"
item "network" kind="network"
item "audio" kind="audio"
item "recording" kind="recording"
item "notifications" kind="notifications"
item "battery" kind="battery" percentage=#true
}
group "time" {
padding 2 3
item "clock" kind="clock"
}
group "display" {
padding 0 0
item "display-mode" kind="display-mode"
}
}
}
window-rule app-id="dev.ferese.Settings" floating=#true window-rule app-id="dev.ferese.Settings" floating=#true
window-rule app-id="dev.ferese.ScreenShare" floating=#true window-rule app-id="dev.ferese.ScreenShare" floating=#true
window-rule app-id="dev.ferese.Authentication" floating=#true window-rule app-id="dev.ferese.Authentication" floating=#true
+3 -1
View File
@@ -145,6 +145,8 @@ def validate_bundle(bundle, *, installed=False):
allowed = expected.get(target) allowed = expected.get(target)
# Older releases bundled this retired source asset. Keep their manifests # Older releases bundled this retired source asset. Keep their manifests
# verifiable for upgrades and rollback without adding it to new bundles. # 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': if allowed is None and target == 'wallpapers/ferese.svg':
allowed = {'target': target, 'mode': '0644'} allowed = {'target': target, 'mode': '0644'}
if allowed is None and re.fullmatch(r'licenses/theme-[A-Za-z0-9._-]+\.txt', target): 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. # Installed releases may predate the appearance wallpapers and license.
# New bundles must include them; upgrades and rollback verify the older # New bundles must include them; upgrades and rollback verify the older
# release's recorded files without requiring these later additions. # 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'} 'licenses/Ferese-LICENSE.txt'}
if not required <= seen or actual != seen | {'manifest.json'}: if not required <= seen or actual != seen | {'manifest.json'}:
+2 -2
View File
@@ -106,8 +106,8 @@
"mode": "0644" "mode": "0644"
}, },
{ {
"source": "assets/wallpapers/ferese-wallpaper-dark.jpg", "source": "assets/wallpapers/ferese-wallpaper-dark.png",
"target": "wallpapers/ferese-wallpaper-dark.jpg", "target": "wallpapers/ferese-wallpaper-dark.png",
"mode": "0644" "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}, "material": {"style": "solid", "opacity": 1.0, "blur_radius": 0.0, "tint_strength": 1.0},
"geometry": { "geometry": {
"border_width": 1.0, "focus_ring_width": 2.0, "window_radius": 14.0, "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, "shell_radius": 14.0, "control_gap": 12.0,
"top_bar_margin_horizontal": 0, "top_bar_window_gap": 0,
"panel_padding": 12.0, "control_gap": 12.0,
}, },
"typography": {"font_family": "Inter"}, "typography": {"font_family": "Inter"},
"background": {"path": "", "lock_path": None, "mode": "fill"}, "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) self.assertEqual((self.bundles[0] / name).stat().st_mode & 0o777, 0o755)
def without_appearance_wallpapers(self, bundle): 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): def without_files(self, bundle, removed):
manifest_path = bundle / 'manifest.json' manifest_path = bundle / 'manifest.json'
@@ -153,6 +153,37 @@ m.main()
with self.assertRaisesRegex(ValueError, 'Checksum mismatch'): with self.assertRaisesRegex(ValueError, 'Checksum mismatch'):
installer.Installation(self.root).manifest('first') 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): def test_new_bundle_requires_appearance_wallpapers(self):
incomplete = Path(self.temporary.name) / 'incomplete' incomplete = Path(self.temporary.name) / 'incomplete'
shutil.copytree(self.bundles[0], incomplete) shutil.copytree(self.bundles[0], incomplete)
+28 -7
View File
@@ -16,6 +16,18 @@ import time
import unittest 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") @unittest.skipUnless(os.environ.get("FERESE_TEST_SHELL_MOTION") == "1", "opt-in nested shell test")
class ShellMotionTest(unittest.TestCase): class ShellMotionTest(unittest.TestCase):
def test_notifications_and_modals_follow_frames_and_finish_closing(self): 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 = config / "config.kdl"
config_file.write_text( config_file.write_text(
'animations { speed 0.5; }\n' '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"]) display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute(): if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
@@ -104,7 +116,7 @@ class ShellMotionTest(unittest.TestCase):
return None return None
updates = re.findall( 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 return int(updates[-1]) // 20 if updates else None
def bar_input_rectangles(surface): def bar_input_rectangles(surface):
@@ -121,9 +133,20 @@ class ShellMotionTest(unittest.TestCase):
if not creations: if not creations:
return [] 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+)\)', r'wl_region[@#]' + region + r'\.add\((-?\d+), (-?\d+), (\d+), (\d+)\)',
trace[creations[-1].start():update.start()])] 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: with log_path.open("w") as log:
try: try:
@@ -179,9 +202,7 @@ class ShellMotionTest(unittest.TestCase):
if bar_layout == "islands": if bar_layout == "islands":
for layout_name, expected_count in [("continuous", 1), ("islands", None)]: for layout_name, expected_count in [("continuous", 1), ("islands", None)]:
source = config_file.read_text() config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n' + panel_config(layout_name))
config_file.write_text(re.sub(
r'bar-layout "[^"]+"', f'bar-layout "{layout_name}"', source))
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True) 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)) wait_for(lambda: bar_region_count(bar) == (expected_count or 2))
@@ -192,7 +213,7 @@ class ShellMotionTest(unittest.TestCase):
for padding in [4, 0]: for padding in [4, 0]:
source = config_file.read_text() source = config_file.read_text()
config_file.write_text(re.sub( 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) subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True)
expected_width = original_input[0][2] - 2 * (12 - padding) expected_width = original_input[0][2] - 2 * (12 - padding)
wait_for(lambda: len(bar_input_rectangles(bar)) == 2 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) rejected = until(lambda: (snapshot := command("theme", "status"))["error"] and snapshot)
self.assertEqual(rejected["theme"], ready["theme"]) self.assertEqual(rejected["theme"], ready["theme"])
replace(shared, 'colors { accent "#8F5300"; }; future-token 1\n') 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) 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) time.sleep(.35)
self.assertEqual(command("theme", "get")["revision"], repaired["revision"]) self.assertEqual(command("theme", "get")["revision"], repaired["revision"])
replace(source, source.read_text().replace('file "themes/shared.kdl"', 'file "themes/missing.kdl"')) replace(source, source.read_text().replace('file "themes/shared.kdl"', 'file "themes/missing.kdl"'))
+11 -2
View File
@@ -422,6 +422,7 @@ impl cosmic::Application for Prompt {
fn view(&self) -> Element<'_, Message> { fn view(&self) -> Element<'_, Message> {
let palette = &self.appearance; let palette = &self.appearance;
let material_opacity = if palette.high_contrast { 1.0 } else { palette.surface.a };
let mut input = widget::text_input(&self.question, self.answer.as_str()) let mut input = widget::text_input(&self.question, self.answer.as_str())
.id(widget::Id::new("auth-response")) .id(widget::Id::new("auth-response"))
.font(palette.font) .font(palette.font)
@@ -487,7 +488,11 @@ impl cosmic::Application for Prompt {
) )
.size(12), .size(12),
) )
.class(ferese_theme::controls::button_style(palette.palette(), false)) .class(ferese_theme::controls::material_button_style(
palette.palette(),
false,
material_opacity,
))
.padding([6, 10]) .padding([6, 10])
.on_press(Message::ToggleDetails), .on_press(Message::ToggleDetails),
); );
@@ -551,7 +556,11 @@ impl cosmic::Application for Prompt {
body = body.push( body = body.push(
row![ row![
button::custom(ferese_theme::text("Cancel", palette.font).size(13)) button::custom(ferese_theme::text("Cancel", palette.font).size(13))
.class(ferese_theme::controls::button_style(palette.palette(), false)) .class(ferese_theme::controls::material_button_style(
palette.palette(),
false,
material_opacity,
))
.padding([8, 14]) .padding([8, 14])
.on_press(Message::Cancel), .on_press(Message::Cancel),
action action
+1
View File
@@ -10,6 +10,7 @@ ferese-theme-client = { path = "../../crates/ferese-theme-client" }
ferese-theme = { path = "../../crates/ferese-theme" } ferese-theme = { path = "../../crates/ferese-theme" }
ferese-config = { path = "../../crates/ferese-config" } ferese-config = { path = "../../crates/ferese-config" }
libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] } libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] }
iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.1" }
ferese-ipc = { path = "../../crates/ferese-ipc" } ferese-ipc = { path = "../../crates/ferese-ipc" }
serde_json.workspace = true serde_json.workspace = true
tempfile.workspace = true tempfile.workspace = true
+15
View File
@@ -36,6 +36,8 @@ pub struct Display {
pub connector: String, pub connector: String,
pub identity: String, pub identity: String,
pub profile: Option<String>, pub profile: Option<String>,
pub logical_width: Option<i32>,
pub focused: bool,
pub current: Option<Mode>, pub current: Option<Mode>,
pub modes: Vec<Mode>, pub modes: Vec<Mode>,
} }
@@ -51,6 +53,11 @@ pub fn load() -> Result<Vec<Display>, String> {
connector: value["connector"].as_str()?.to_owned(), connector: value["connector"].as_str()?.to_owned(),
identity: value["identity"].as_str().unwrap_or("").to_owned(), identity: value["identity"].as_str().unwrap_or("").to_owned(),
profile: value["profile"].as_str().map(str::to_owned), profile: value["profile"].as_str().map(str::to_owned),
logical_width: value["width"]
.as_i64()
.and_then(|width| width.try_into().ok())
.filter(|width| *width > 0),
focused: value["focused"].as_bool().unwrap_or(false),
current: Mode::from_value(&value["current_mode"]), current: Mode::from_value(&value["current_mode"]),
modes: value["available_modes"] modes: value["available_modes"]
.as_array() .as_array()
@@ -264,6 +271,8 @@ mod tests {
connector: "eDP-1".into(), connector: "eDP-1".into(),
identity: "panel".into(), identity: "panel".into(),
profile: None, profile: None,
logical_width: Some(1440),
focused: true,
current: Some(Mode { current: Some(Mode {
width: 2880, width: 2880,
height: 1800, height: 1800,
@@ -344,6 +353,8 @@ mod tests {
connector: "eDP-1".into(), connector: "eDP-1".into(),
identity: "panel".into(), identity: "panel".into(),
profile: Some("laptop".into()), profile: Some("laptop".into()),
logical_width: Some(1440),
focused: true,
current: None, current: None,
modes: vec![], modes: vec![],
}]; }];
@@ -369,6 +380,8 @@ mod tests {
connector: "eDP-1".into(), connector: "eDP-1".into(),
identity: "panel".into(), identity: "panel".into(),
profile: None, profile: None,
logical_width: Some(1440),
focused: true,
current: None, current: None,
modes: vec![], modes: vec![],
}]; }];
@@ -384,6 +397,8 @@ mod tests {
connector: "eDP-1".into(), connector: "eDP-1".into(),
identity: "panel".into(), identity: "panel".into(),
profile: None, profile: None,
logical_width: Some(1440),
focused: true,
current: Some(mode(2880, 1800, 60000)), current: Some(mode(2880, 1800, 60000)),
modes: vec![ modes: vec![
mode(2880, 1800, 60000), mode(2880, 1800, 60000),
+22 -6
View File
@@ -21,15 +21,31 @@ pub fn families() -> &'static [String] {
.map(str::to_owned) .map(str::to_owned)
.collect(); .collect();
families.extend([ families.extend(["sans-serif".into(), "serif".into(), "monospace".into()]);
"Comfortaa".into(),
"sans-serif".into(),
"serif".into(),
"monospace".into(),
]);
families.sort_unstable_by_key(|family| family.to_lowercase()); families.sort_unstable_by_key(|family| family.to_lowercase());
families.dedup(); families.dedup();
families.insert(0, "Default font".into()); families.insert(0, "Default font".into());
families families
}) })
} }
/// Missing theme fonts must fall back to a proportional interface font.
/// Iced does not apply fontconfig's named-family substitutions.
pub fn interface_font(family: &str) -> cosmic::iced::Font {
match family {
"sans-serif" => cosmic::iced::Font::DEFAULT,
"serif" => cosmic::iced::Font {
family: cosmic::iced::font::Family::Serif,
..cosmic::iced::Font::DEFAULT
},
"monospace" => cosmic::iced::Font::MONOSPACE,
_ if families()
.iter()
.skip(1)
.any(|installed| installed.eq_ignore_ascii_case(family)) =>
{
ferese_theme::font(Some(family))
}
_ => cosmic::iced::Font::DEFAULT,
}
}
+51 -8
View File
@@ -14,13 +14,39 @@ impl App {
ferese_theme::text(text, self.font).size(size) ferese_theme::text(text, self.font).size(size)
} }
pub(super) fn panel_heading(
&self,
text: impl Into<std::borrow::Cow<'static, str>> + 'static,
size: f32,
) -> widget::Text<'static, cosmic::Theme, cosmic::Renderer> {
self.label(text, size).font(cosmic::iced::Font {
weight: cosmic::iced::font::Weight::Semibold,
..self.font
})
}
pub(super) fn note(&self, text: &str) -> Element<'static, Message> { pub(super) fn note(&self, text: &str) -> Element<'static, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented); let palette = visuals::Palette::from_resolved(&self.resolved.presented);
self.label(text.to_owned(), 11.) self.label(
text.to_owned(),
if self.page == crate::schema::Page::Bar {
12.
} else {
11.
},
)
.class(cosmic::theme::Text::Color(palette.muted)) .class(cosmic::theme::Text::Color(palette.muted))
.into() .into()
} }
fn control_button_style(&self, palette: visuals::Palette, selected: bool) -> cosmic::theme::Button {
if self.page == crate::schema::Page::Bar {
visuals::panel_button(palette, selected)
} else {
visuals::button_style(palette, selected)
}
}
pub(super) fn settings_button( pub(super) fn settings_button(
&self, &self,
label: &str, label: &str,
@@ -33,12 +59,15 @@ impl App {
row([]) row([])
.spacing(5) .spacing(5)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(visuals::action_icon(icon, palette.text)) .push(visuals::action_icon(
icon,
if message.is_some() { palette.text } else { palette.muted },
))
.push(self.label(label.to_owned(), 12.)), .push(self.label(label.to_owned(), 12.)),
) )
.name(label.to_owned()) .name(label.to_owned())
.padding([4, 8]) .padding([4, 8])
.class(visuals::button_style(palette, selected)) .class(self.control_button_style(palette, selected))
.on_press_maybe(message) .on_press_maybe(message)
.into() .into()
} }
@@ -51,10 +80,13 @@ impl App {
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented); let palette = visuals::Palette::from_resolved(&self.resolved.presented);
widget::tooltip( widget::tooltip(
button::custom(visuals::action_icon(icon, palette.text)) button::custom(visuals::action_icon(
icon,
if message.is_some() { palette.text } else { palette.muted },
))
.name(label.to_owned()) .name(label.to_owned())
.padding(4) .padding(4)
.class(visuals::button_style(palette, false)) .class(self.control_button_style(palette, false))
.on_press_maybe(message), .on_press_maybe(message),
self.label(label.to_owned(), 11.), self.label(label.to_owned(), 11.),
widget::tooltip::Position::Top, widget::tooltip::Position::Top,
@@ -146,21 +178,31 @@ impl App {
Kind::Range { Kind::Range {
default, default,
min, min,
max, mut max,
step, step,
suffix, suffix,
integer, integer,
} => { } => {
let margin_limit = (path == "panels.0.geometry.side_margins").then(|| self.panel_side_margin_limit());
if let Some(limit) = margin_limit {
max = f64::from(limit.unwrap_or(0));
}
let value = self let value = self
.ranges .ranges
.get(&path) .get(&path)
.copied() .copied()
.unwrap_or_else(|| self.draft.number(&path, default)); .unwrap_or_else(|| self.draft.number(&path, default))
.clamp(min, max);
let display = if integer || step >= 1. { let display = if integer || step >= 1. {
format!("{value:.0}{suffix}") format!("{value:.0}{suffix}")
} else { } else {
format!("{value:.2}{suffix}") format!("{value:.2}{suffix}")
}; };
if margin_limit == Some(None) {
self.label("—", 12.).into()
} else if max <= min {
self.label(display, 12.).into()
} else {
let release = field.clone(); let release = field.clone();
row([]) row([])
.align_y(Alignment::Center) .align_y(Alignment::Center)
@@ -175,6 +217,7 @@ impl App {
.push(self.label(display, 12.).width(65)) .push(self.label(display, 12.).width(65))
.into() .into()
} }
}
Kind::Choice { default, choices } => { Kind::Choice { default, choices } => {
let value = self.wallpaper_field_value(&path, default); let value = self.wallpaper_field_value(&path, default);
let mut options = row([]).spacing(2); let mut options = row([]).spacing(2);
@@ -182,7 +225,7 @@ impl App {
options = options.push( options = options.push(
button::custom(self.label(*label, 12.)) button::custom(self.label(*label, 12.))
.padding([4, 8]) .padding([4, 8])
.class(visuals::button_style(palette, value == *key)) .class(self.control_button_style(palette, value == *key))
.on_press(Message::Change(set(&path, *key))), .on_press(Message::Change(set(&path, *key))),
); );
} }
+413 -14
View File
@@ -2,6 +2,11 @@ mod connections;
mod form_controls; mod form_controls;
mod navigation; mod navigation;
mod pages; mod pages;
mod panel_controls;
mod panel_drag;
mod panel_edit;
mod panel_inspector;
mod panel_preview;
mod theme_controls; mod theme_controls;
use theme_controls::*; use theme_controls::*;
@@ -30,6 +35,11 @@ use schema::{Field, Kind, Page};
use store::{Edit, Snapshot, set}; use store::{Edit, Snapshot, set};
fn main() -> cosmic::iced::Result { fn main() -> cosmic::iced::Result {
if std::env::var_os("ICED_BACKEND").is_none() {
// Settings repaints large scrolling surfaces; prefer GPU rendering.
// SAFETY: process entry, before the toolkit or font workers start threads.
unsafe { std::env::set_var("ICED_BACKEND", "wgpu,tiny-skia") };
}
let mut args = std::env::args_os().skip(1); let mut args = std::env::args_os().skip(1);
let mut path = store::config_path(); let mut path = store::config_path();
let mut initial_page = None; let mut initial_page = None;
@@ -47,14 +57,15 @@ fn main() -> cosmic::iced::Result {
Some("connections" | "wifi") => Some(InitialPage::Connections(connections::Tab::Wifi)), Some("connections" | "wifi") => Some(InitialPage::Connections(connections::Tab::Wifi)),
Some("bluetooth") => Some(InitialPage::Connections(connections::Tab::Bluetooth)), Some("bluetooth") => Some(InitialPage::Connections(connections::Tab::Bluetooth)),
Some("wallpaper") => Some(InitialPage::Wallpaper), Some("wallpaper") => Some(InitialPage::Wallpaper),
Some("bar") => Some(InitialPage::Bar),
_ => { _ => {
eprintln!("--page accepts connections, wifi, bluetooth, or wallpaper"); eprintln!("--page accepts connections, wifi, bluetooth, wallpaper, or bar");
return Ok(()); return Ok(());
} }
}; };
} }
_ => { _ => {
eprintln!("Usage: ferese-settings [--config PATH] [--page connections|wifi|bluetooth|wallpaper]"); eprintln!("Usage: ferese-settings [--config PATH] [--page connections|wifi|bluetooth|wallpaper|bar]");
return Ok(()); return Ok(());
} }
} }
@@ -78,6 +89,7 @@ fn main() -> cosmic::iced::Result {
enum InitialPage { enum InitialPage {
Connections(connections::Tab), Connections(connections::Tab),
Wallpaper, Wallpaper,
Bar,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -98,7 +110,15 @@ enum Message {
PagePresented(Page), PagePresented(Page),
RowVisibility(&'static str, usize, bool), RowVisibility(&'static str, usize, bool),
Search(String), Search(String),
Sidebar(bool),
Change(Edit), Change(Edit),
PanelClearSelection,
PanelPage(panel_controls::PanelPage),
PanelSelect(ferese_config::panel::ItemId),
PanelGroupSelect(ferese_config::panel::GroupId),
PanelPreviewResolved(ferese_config::panel::layout::Resolution),
PanelPreviewOverflow,
PanelEdit(panel_edit::Action),
SelectFont(String, String), SelectFont(String, String),
Draft(String, String), Draft(String, String),
Commit(Field), Commit(Field),
@@ -159,6 +179,7 @@ struct App {
visible_rows: std::collections::HashSet<(&'static str, usize)>, visible_rows: std::collections::HashSet<(&'static str, usize)>,
hidden_binding_rows: std::collections::HashSet<usize>, hidden_binding_rows: std::collections::HashSet<usize>,
search: String, search: String,
sidebar_open: bool,
inputs: HashMap<String, String>, inputs: HashMap<String, String>,
ranges: HashMap<String, f64>, ranges: HashMap<String, f64>,
new_command: String, new_command: String,
@@ -171,6 +192,11 @@ struct App {
undo_revision: u64, undo_revision: u64,
auto_details: bool, auto_details: bool,
advanced_theme: bool, advanced_theme: bool,
panel_selection: Option<ferese_config::panel::ItemId>,
panel_page: panel_controls::PanelPage,
panel_group_selection: Option<ferese_config::panel::GroupId>,
panel_preview_resolution: ferese_config::panel::layout::Resolution,
panel_preview_overflow: bool,
theme_file_target: usize, theme_file_target: usize,
wallpaper: wallpaper_controls::State, wallpaper: wallpaper_controls::State,
} }
@@ -233,7 +259,7 @@ impl cosmic::Application for App {
let error = initial.as_ref().err().cloned(); let error = initial.as_ref().err().cloned();
let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap()); let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap());
let resolved = ferese_theme_client::service::current(); let resolved = ferese_theme_client::service::current();
let font = ferese_theme::font(Some(&resolved.presented.tokens.typography.font_family)); let font = fonts::interface_font(&resolved.presented.tokens.typography.font_family);
let native_palette = visuals::Palette::from_resolved(&resolved.presented); let native_palette = visuals::Palette::from_resolved(&resolved.presented);
let family_sections = theme_controls::FamilySections::new(&resolved.families, &current); let family_sections = theme_controls::FamilySections::new(&resolved.families, &current);
let mut app = Self { let mut app = Self {
@@ -261,11 +287,13 @@ impl cosmic::Application for App {
page: match initial_page { page: match initial_page {
Some(InitialPage::Connections(_)) => Page::Connections, Some(InitialPage::Connections(_)) => Page::Connections,
Some(InitialPage::Wallpaper) => Page::Wallpaper, Some(InitialPage::Wallpaper) => Page::Wallpaper,
Some(InitialPage::Bar) => Page::Bar,
None => Page::Appearance, None => Page::Appearance,
}, },
profile_pages: std::env::var_os("FERESE_PROFILE_SETTINGS").is_some(), profile_pages: std::env::var_os("FERESE_PROFILE_SETTINGS").is_some(),
page_transition: None, page_transition: None,
search: String::new(), search: String::new(),
sidebar_open: false,
visible_rows: initial_visible_rows(), visible_rows: initial_visible_rows(),
hidden_binding_rows: Default::default(), hidden_binding_rows: Default::default(),
inputs: HashMap::new(), inputs: HashMap::new(),
@@ -280,6 +308,11 @@ impl cosmic::Application for App {
undo_revision: 0, undo_revision: 0,
auto_details: false, auto_details: false,
advanced_theme: false, advanced_theme: false,
panel_selection: None,
panel_page: Default::default(),
panel_group_selection: None,
panel_preview_resolution: Default::default(),
panel_preview_overflow: false,
theme_file_target: 0, theme_file_target: 0,
wallpaper: Default::default(), wallpaper: Default::default(),
}; };
@@ -296,7 +329,12 @@ impl cosmic::Application for App {
Task::none() Task::none()
}; };
let thumbnails = app.load_thumbnail(); let thumbnails = app.load_thumbnail();
(app, Task::batch([task, connections, thumbnails])) let displays = if app.page == Page::Bar {
app.load_displays()
} else {
Task::none()
};
(app, Task::batch([task, connections, thumbnails, displays]))
} }
fn update(&mut self, message: Message) -> Task<Message> { fn update(&mut self, message: Message) -> Task<Message> {
@@ -345,7 +383,7 @@ impl cosmic::Application for App {
} }
self.resolved = *snapshot; self.resolved = *snapshot;
if font_changed { if font_changed {
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family)); self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
} }
let theme = self.update_theme(); let theme = self.update_theme();
@@ -426,7 +464,7 @@ impl cosmic::Application for App {
self.family_sections = self.family_sections =
theme_controls::FamilySections::new(&self.resolved.families, &self.draft); theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
self.sync_notes(); self.sync_notes();
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family)); self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
self.undo = None; self.undo = None;
self.error = None; self.error = None;
self.status = "Updated from your config".into(); self.status = "Updated from your config".into();
@@ -463,7 +501,14 @@ impl cosmic::Application for App {
Message::LockPreviewStarted(Err(error)) => { Message::LockPreviewStarted(Err(error)) => {
self.error = Some(error); self.error = Some(error);
} }
Message::Sidebar(open) => {
self.sidebar_open = open;
if open {
return cosmic::iced::widget::operation::focus(widget::Id::new("settings-search"));
}
}
Message::Page(page) => { Message::Page(page) => {
self.sidebar_open = false;
if self.page == page && self.search.is_empty() { if self.page == page && self.search.is_empty() {
return Task::none(); return Task::none();
} }
@@ -482,7 +527,7 @@ impl cosmic::Application for App {
if leaving { if leaving {
let cleanup = self.leave_connections(); let cleanup = self.leave_connections();
let load = match page { let load = match page {
Page::Displays => self.load_displays(), Page::Displays | Page::Bar => self.load_displays(),
Page::Wallpaper => self.load_thumbnail(), Page::Wallpaper => self.load_thumbnail(),
_ => Task::none(), _ => Task::none(),
}; };
@@ -491,7 +536,7 @@ impl cosmic::Application for App {
if page == Page::Connections { if page == Page::Connections {
return Task::batch([reset, self.refresh_connections()]); return Task::batch([reset, self.refresh_connections()]);
} }
if page == Page::Displays { if matches!(page, Page::Displays | Page::Bar) {
return Task::batch([reset, self.load_displays()]); return Task::batch([reset, self.load_displays()]);
} }
if page == Page::Wallpaper { if page == Page::Wallpaper {
@@ -548,7 +593,23 @@ impl cosmic::Application for App {
} }
Message::Release(field) => { Message::Release(field) => {
if let Some(value) = self.ranges.remove(&field.path) { if let Some(value) = self.ranges.remove(&field.path) {
if field.path == "panels.0.background_opacity" {
return self.update(Message::PanelEdit(panel_edit::Action::SetOpacity(value as f32 / 100.)));
}
if let Some(index) = field.path.strip_prefix("panels.0.corner_radius.")
&& let Ok(index) = index.parse()
{
return self.update(Message::PanelEdit(panel_edit::Action::SetCorner(index, value as f32)));
}
let integer = matches!(field.kind, Kind::Range { integer: true, .. }); let integer = matches!(field.kind, Kind::Range { integer: true, .. });
if let Some(key) = field.path.strip_prefix("panels.0.geometry.") {
let value = if integer {
serde_json::Value::from(value.round() as i64)
} else {
value.into()
};
return self.update(Message::PanelEdit(panel_edit::Action::SetGeometry(key.into(), value)));
}
return self.change(if integer { return self.change(if integer {
set(&field.path, value.round() as i64) set(&field.path, value.round() as i64)
} else { } else {
@@ -589,14 +650,80 @@ impl cosmic::Application for App {
return self.change(edit); return self.change(edit);
} }
} }
Message::Change(edit) => return self.change(edit), Message::PanelSelect(id) => {
let changed = self.panel_selection.as_ref() != Some(&id);
self.panel_page = panel_controls::PanelPage::Arrange;
self.panel_group_selection = None;
self.panel_selection = Some(id);
self.panel_preview_overflow = false;
if changed {
return cosmic::iced::widget::scrollable::snap_to(
widget::Id::new("panel-inspector-scroll"),
cosmic::iced::widget::scrollable::RelativeOffset::START.into(),
);
}
}
Message::PanelGroupSelect(id) => {
let changed = self.panel_group_selection.as_ref() != Some(&id);
self.panel_page = panel_controls::PanelPage::Arrange;
self.panel_selection = None;
self.panel_group_selection = Some(id);
if changed {
return cosmic::iced::widget::scrollable::snap_to(
widget::Id::new("panel-inspector-scroll"),
cosmic::iced::widget::scrollable::RelativeOffset::START.into(),
);
}
}
Message::PanelClearSelection => {
self.panel_selection = None;
self.panel_group_selection = None;
}
Message::PanelPreviewResolved(resolution) => self.panel_preview_resolution = resolution,
Message::PanelPreviewOverflow => self.panel_preview_overflow = !self.panel_preview_overflow,
Message::PanelPage(tab) => {
self.panel_page = tab;
return cosmic::iced::widget::scrollable::snap_to(
widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::RelativeOffset::START.into(),
);
}
Message::PanelEdit(action) => match panel_edit::plan(&self.draft, action) {
Ok(edits) => {
self.inputs.retain(|path, _| !path.starts_with("panels."));
self.ranges.retain(|path, _| !path.starts_with("panels."));
let task = self.edit_many(edits);
if let Ok(panel) = self.preview_panel() {
if self.panel_selection.as_ref().is_some_and(|id| panel.item(id).is_none()) {
self.panel_selection = None;
}
if self.panel_group_selection.as_ref().is_some_and(|id| {
!panel_edit::Zone::ALL
.into_iter()
.any(|zone| zone.definition(&panel).groups.iter().any(|group| &group.id == id))
}) {
self.panel_group_selection = None;
}
}
return task;
}
Err(error) => self.error = Some(error),
},
Message::Change(edit) => {
if matches!(&edit, Edit::Unset(path) if path == "panels") {
self.inputs.retain(|path, _| !path.starts_with("panels."));
self.panel_selection = None;
self.panel_group_selection = None;
}
return self.change(edit);
}
Message::Saved(result) => { Message::Saved(result) => {
self.saving = false; self.saving = false;
match result { match result {
Ok((snapshot, live)) => { Ok((snapshot, live)) => {
self.undo = Some(std::mem::take(&mut self.saving_previous)); self.undo = Some(std::mem::take(&mut self.saving_previous));
self.current = snapshot; self.current = snapshot;
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family)); self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
self.draft = self.current.clone(); self.draft = self.current.clone();
for edit in &self.pending { for edit in &self.pending {
@@ -640,7 +767,7 @@ impl cosmic::Application for App {
self.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft); self.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
self.note_editors.clear(); self.note_editors.clear();
self.sync_notes(); self.sync_notes();
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family)); self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
self.pending.clear(); self.pending.clear();
self.inputs.clear(); self.inputs.clear();
self.ranges.clear(); self.ranges.clear();
@@ -840,9 +967,25 @@ impl cosmic::Application for App {
fn subscription(&self) -> cosmic::iced::Subscription<Message> { fn subscription(&self) -> cosmic::iced::Subscription<Message> {
cosmic::iced::Subscription::batch([ cosmic::iced::Subscription::batch([
if self.sidebar_open {
cosmic::iced::event::listen_with(|event, _, _| match event {
cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
..
}) => Some(Message::Sidebar(false)),
cosmic::iced::Event::Window(cosmic::iced::window::Event::Resized(size))
if size.width >= pages::SIDEBAR_BREAKPOINT =>
{
Some(Message::Sidebar(false))
}
_ => None,
})
} else {
cosmic::iced::Subscription::none()
},
cosmic::iced::Subscription::run_with(self.path.clone(), |path| watch::changes(path)), cosmic::iced::Subscription::run_with(self.path.clone(), |path| watch::changes(path)),
ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
if self.page == Page::Displays { if matches!(self.page, Page::Displays | Page::Bar) {
cosmic::iced::time::every(std::time::Duration::from_secs(2)).map(|_| Message::RefreshDisplays) cosmic::iced::time::every(std::time::Duration::from_secs(2)).map(|_| Message::RefreshDisplays)
} else { } else {
cosmic::iced::Subscription::none() cosmic::iced::Subscription::none()
@@ -1038,6 +1181,214 @@ mod tests {
1 + tree.children.iter().map(tree_nodes).sum::<usize>() 1 + tree.children.iter().map(tree_nodes).sum::<usize>()
} }
#[test]
fn corner_sliders_hold_changes_until_release_and_preserve_other_corners() {
let mut app = app();
for edit in panel_edit::plan(&app.draft, panel_edit::Action::SetRadius("4px 8px 12px 16px".into())).unwrap() {
app.draft.edit(&edit).unwrap();
}
app.draft = Snapshot::parse(app.draft.source.clone()).unwrap();
app.current = app.draft.clone();
app.saving = true; // Keep this test independent of disk and the live compositor.
let before = app.draft.source.clone();
let field = Field::new(
"panels.0.corner_radius.2",
"Bottom right",
"",
Kind::Range {
default: 12.,
min: 0.,
max: 64.,
step: 1.,
suffix: " px",
integer: false,
},
);
let _ = app.update(Message::Range(field.clone(), 23.));
assert_eq!(app.draft.source, before);
let _ = app.update(Message::Release(field));
let radii: ferese_config::panel::CornerRadii =
serde_json::from_value(app.draft.item("panels.0.corner_radius").unwrap().clone()).unwrap();
assert_eq!(radii.0, [4., 8., 23., 16.]);
assert!(app.ranges.is_empty());
assert_eq!(app.current.source, before);
}
#[test]
fn pending_panel_moves_save_both_groups_together_and_keep_the_selected_instance() {
use ferese_config::panel::{GroupId, ItemId};
let mut app = app();
let mut initial = Snapshot::parse(String::new()).unwrap();
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
app.current = initial.clone();
app.draft = initial.clone();
app.saving = true; // An earlier save has not completed yet.
let id = ItemId("network".into());
let _ = app.update(Message::PanelSelect(id.clone()));
let _ = app.update(Message::PanelEdit(panel_edit::Action::Move(
id.clone(),
panel_edit::Destination {
zone: "start",
group: GroupId("workspaces".into()),
},
)));
let _ = app.update(Message::PanelEdit(panel_edit::Action::Set(
id.clone(),
"overflow".into(),
"always".into(),
)));
assert_eq!(app.pending.len(), 3);
assert_eq!(app.panel_selection, Some(id.clone()));
let expected = app.draft.doc.value().clone();
let _ = app.update(Message::Saved(Ok((initial, false))));
assert!(app.saving, "the complete queued draft is saved once");
assert!(app.pending.is_empty());
assert_eq!(app.draft.doc.value(), &expected);
let panels: Vec<ferese_config::panel::Panel> =
serde_json::from_value(app.draft.item("panels").unwrap().clone()).unwrap();
ferese_config::panel::validate(&panels).unwrap();
assert_eq!(panels[0].start.groups[1].items[1].id, id);
assert_eq!(
panels[0].start.groups[1].items[1].overflow,
ferese_config::panel::OverflowPolicy::Always
);
assert!(!panels[0].end.groups[0].items.iter().any(|item| item.id == id));
}
#[test]
fn panel_inspector_builds_only_for_the_selected_instance_and_survives_removal() {
let mut app = app();
app.page = Page::Bar;
let edit = panel_controls::initialize(&app.draft).unwrap();
app.draft.edit(&edit).unwrap();
use cosmic::iced::advanced::{layout, renderer::Headless, widget::Tree};
use cosmic::iced::{Font, Pixels, Size};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let nodes = |app: &App| {
let mut view = app.page_view();
let mut tree = Tree::new(&view);
view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1120., 840.)),
);
tree_nodes(&tree)
};
app.panel_page = panel_controls::PanelPage::Arrange;
let collapsed = nodes(&app);
let id = ferese_config::panel::ItemId("clock".into());
let _ = app.update(Message::PanelSelect(id.clone()));
let expanded = nodes(&app);
assert!(expanded > collapsed);
let _ = app.update(Message::PanelEdit(panel_edit::Action::Remove(id)));
let _ = app.page_view();
assert!(app.error.is_none());
assert_eq!(app.draft.string("panels.0.end.groups.1.items.0.id", ""), "");
}
#[test]
fn pending_group_moves_and_style_changes_save_one_coherent_draft() {
use ferese_config::panel::GroupId;
let mut app = app();
let mut initial = Snapshot::parse(String::new()).unwrap();
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
app.current = initial.clone();
app.draft = initial.clone();
app.saving = true;
let id = GroupId("status".into());
let _ = app.update(Message::PanelGroupSelect(id.clone()));
let _ = app.update(Message::PanelEdit(panel_edit::Action::MoveGroup(
id.clone(),
panel_edit::Zone::Start,
)));
let _ = app.update(Message::PanelEdit(panel_edit::Action::SetGroup(
id.clone(),
"padding_horizontal".into(),
12.into(),
)));
let expected = app.draft.doc.value().clone();
let _ = app.update(Message::Saved(Ok((initial, false))));
assert!(app.saving);
assert!(app.pending.is_empty());
assert_eq!(app.draft.doc.value(), &expected);
assert_eq!(app.panel_group_selection, Some(id));
assert_eq!(app.draft.number("panels.0.start.groups.2.padding.1", 0.), 12.);
let _ = app.page_view();
}
#[test]
fn selecting_a_generated_preview_item_does_not_save_the_composition() {
let mut app = app();
app.saving = true;
let id = ferese_config::panel::ItemId("clock".into());
let _ = app.update(Message::PanelSelect(id.clone()));
assert_eq!(app.panel_selection, Some(id));
assert!(app.draft.item("panels").is_none());
assert!(app.pending.is_empty());
}
#[test]
fn panel_pages_do_not_save_and_selection_opens_the_items_inspector() {
let mut app = app();
app.page = Page::Bar;
for tab in [panel_controls::PanelPage::Arrange, panel_controls::PanelPage::Panels] {
let _ = app.update(Message::PanelPage(tab));
let _ = app.page_view();
assert_eq!(app.panel_page, tab);
assert!(app.pending.is_empty());
assert!(app.draft.item("panels").is_none());
}
app.saving = true;
let _ = app.update(Message::PanelSelect(ferese_config::panel::ItemId("clock".into())));
assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange);
assert!(app.panel_group_selection.is_none());
let _ = app.update(Message::PanelGroupSelect(ferese_config::panel::GroupId(
"status".into(),
)));
assert!(app.panel_selection.is_none());
assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange);
}
#[test]
fn dropped_items_follow_queued_group_moves_and_are_saved_once() {
use ferese_config::panel::{GroupId, ItemId};
let mut app = app();
let mut initial = Snapshot::parse(String::new()).unwrap();
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
app.current = initial.clone();
app.draft = initial.clone();
app.saving = true;
let _ = app.update(Message::PanelEdit(panel_edit::Action::MoveGroup(
GroupId("time".into()),
panel_edit::Zone::Center,
)));
let _ = app.update(Message::PanelEdit(panel_edit::Action::Place(
ItemId("network".into()),
panel_edit::Destination {
zone: "end",
group: GroupId("time".into()),
},
Some(ItemId("clock".into())),
)));
assert_eq!(app.pending.len(), 4);
let expected = app.draft.doc.value().clone();
let _ = app.update(Message::Saved(Ok((initial, false))));
assert_eq!(app.draft.doc.value(), &expected);
assert!(app.saving && app.pending.is_empty());
assert_eq!(app.draft.string("panels.0.center.groups.1.items.0.id", ""), "network");
assert_eq!(app.draft.string("panels.0.center.groups.1.items.1.id", ""), "clock");
}
#[test] #[test]
fn wallpaper_preview_uses_presented_state_and_tracks_mode_changes() { fn wallpaper_preview_uses_presented_state_and_tracks_mode_changes() {
let mut app = app(); let mut app = app();
@@ -1102,9 +1453,32 @@ mod tests {
fn shortcuts_build_controls_only_for_mounted_rows() { fn shortcuts_build_controls_only_for_mounted_rows() {
let mut app = shortcuts(100); let mut app = shortcuts(100);
app.visible_rows.extend((0..4).map(|index| ("bindings", index))); app.visible_rows.extend((0..4).map(|index| ("bindings", index)));
let visible = tree_nodes(&cosmic::iced::advanced::widget::Tree::new(app.page_view().as_widget())); let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
use cosmic::iced::advanced::{layout, renderer::Headless, widget::Tree};
use cosmic::iced::{Font, Pixels, Size};
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let nodes = |app: &App| {
let mut view = app.page_view();
let mut tree = Tree::new(&view);
view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1100., 860.)),
);
tree_nodes(&tree)
};
let visible = nodes(&app);
app.visible_rows.extend((0..100).map(|index| ("bindings", index))); app.visible_rows.extend((0..100).map(|index| ("bindings", index)));
let eager = tree_nodes(&cosmic::iced::advanced::widget::Tree::new(app.page_view().as_widget())); let eager = nodes(&app);
assert!(eager > visible + 96 * 10, "visible={visible}, eager={eager}"); assert!(eager > visible + 96 * 10, "visible={visible}, eager={eager}");
} }
@@ -1121,6 +1495,31 @@ mod tests {
assert_eq!(app.draft.string("bindings.5.argument", ""), "my command"); assert_eq!(app.draft.string("bindings.5.argument", ""), "my command");
} }
#[test]
fn merged_navigation_finds_subpages_and_browsing_does_not_save() {
for (parent, query, destination) in [
(Page::Windows, "motion", Page::Motion),
(Page::Windows, "animation speed", Page::Motion),
(Page::Keyboard, "shortcuts", Page::Shortcuts),
(Page::Keyboard, "swipe actions", Page::Shortcuts),
(Page::Keyboard, "touchpad", Page::Keyboard),
] {
assert!(parent.matches(query));
assert_eq!(parent.search_destination(query), destination);
assert_eq!(destination.navigation_page(), parent);
let mut app = app();
let before = app.draft.source.clone();
let _ = app.update(Message::Page(parent));
let _ = app.update(Message::Search(query.into()));
let _ = app.update(Message::Page(destination));
assert_eq!(app.page, destination);
assert!(app.search.is_empty());
assert_eq!(app.draft.source, before);
assert!(app.pending.is_empty());
assert!(!app.saving);
}
}
#[test] #[test]
fn navigation_resets_scroll_but_reselecting_the_same_page_does_not() { fn navigation_resets_scroll_but_reselecting_the_same_page_does_not() {
let mut app = app(); let mut app = app();
+4 -3
View File
@@ -24,10 +24,11 @@ pub(super) struct RevealRow {
impl RevealRow { impl RevealRow {
pub(super) fn new(list: &str, row: usize) -> Self { pub(super) fn new(list: &str, row: usize) -> Self {
Self { Self::for_target(gallery_row_id(list, row))
target: gallery_row_id(list, row),
bounds: None,
} }
pub(super) fn for_target(target: Id) -> Self {
Self { target, bounds: None }
} }
} }
+149 -26
View File
@@ -3,10 +3,19 @@ use super::{
text_input, visuals, widget, text_input, visuals, widget,
}; };
pub(super) const SIDEBAR_BREAKPOINT: f32 = 960.;
impl App { impl App {
pub(super) fn page_view(&self) -> Element<'_, Message> { pub(super) fn page_view(&self) -> Element<'_, Message> {
cosmic::iced::widget::responsive(move |size| self.page_layout(size.width < SIDEBAR_BREAKPOINT, size.height))
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn page_layout(&self, compact: bool, height: f32) -> Element<'_, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented); let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let sidebar = column([]) let mut sidebar = column([])
.spacing(3) .spacing(3)
.push( .push(
widget::mouse_area( widget::mouse_area(
@@ -14,7 +23,10 @@ impl App {
.spacing(8) .spacing(8)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(visuals::brand_icon()) .push(visuals::brand_icon())
.push(self.label("Ferese", 16.)) .push(
self.label("Ferese", 16.)
.class(cosmic::theme::Text::Color(palette.text)),
)
.width(Length::Fill), .width(Length::Fill),
) )
.on_press(Message::DragWindow) .on_press(Message::DragWindow)
@@ -23,37 +35,51 @@ impl App {
.push(widget::Space::new().height(14)) .push(widget::Space::new().height(14))
.push( .push(
text_input("Search settings", self.search.clone()) text_input("Search settings", self.search.clone())
.id(widget::Id::new("settings-search"))
.on_input(Message::Search) .on_input(Message::Search)
.font(self.font) .font(self.font)
.style(visuals::input_style(palette)) .style(visuals::input_style(palette))
.size(12), .size(12),
) )
.push(widget::Space::new().height(8)); .push(widget::Space::new().height(8));
let mut navigation = column([]).spacing(3); if compact {
for page in Page::ALL { sidebar = sidebar.push(self.settings_button(
navigation = navigation.push( "Close navigation",
"M6 6l12 12 M6 18L18 6",
Some(Message::Sidebar(false)),
false,
));
}
let mut navigation = column([]).spacing(16);
for (title, pages) in Page::NAVIGATION {
let mut section = column([]).spacing(3).push(
container(self.label(title, 11.).class(cosmic::theme::Text::Color(palette.muted))).padding([0, 8]),
);
for &page in pages {
let selected = self.page.navigation_page() == page;
section = section.push(
button::custom( button::custom(
row([]) row([])
.spacing(8) .spacing(8)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(visuals::icon( .push(visuals::icon(
page, page,
if self.page == page { if selected { palette.accent } else { palette.muted },
palette.accent
} else {
palette.muted
},
)) ))
.push(self.label(page.title(), 13.)), .push(self.label(page.title(), 13.)),
) )
.width(Length::Fill) .width(Length::Fill)
.padding([8, 8]) .padding([8, 8])
.class(visuals::navigation_style(palette, self.page == page)) .class(visuals::navigation_style(palette, selected))
.on_press(Message::Page(page)), .name(page.title())
.on_press(Message::Page(if selected { self.page } else { page })),
); );
} }
navigation = navigation.push(section);
}
let sidebar = container(sidebar.push(scrollable(navigation).height(Length::Fill))) let sidebar = container(sidebar.push(scrollable(navigation).height(Length::Fill)))
.width(204) .id("settings-sidebar")
.width(if compact { 244 } else { 204 })
.height(Length::Fill) .height(Length::Fill)
.padding([20, 10]) .padding([20, 10])
.class(visuals::surface(palette.sidebar, 0.)); .class(visuals::surface(palette.sidebar, 0.));
@@ -62,7 +88,7 @@ impl App {
.spacing(3) .spacing(3)
.push(self.label( .push(self.label(
if self.search.is_empty() { if self.search.is_empty() {
self.page.title() self.page.navigation_page().title()
} else { } else {
"Search" "Search"
}, },
@@ -137,25 +163,31 @@ impl App {
let mut body = column([]).spacing(6); let mut body = column([]).spacing(6);
if !self.search.is_empty() { if !self.search.is_empty() {
if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) { let pages = || {
Page::NAVIGATION
.into_iter()
.flat_map(|(_, pages)| pages.iter().copied())
};
if !pages().any(|p| p.matches(&self.search)) {
body = body.push(self.note("No matching settings. Try wallpaper, keyboard, or motion.")); body = body.push(self.note("No matching settings. Try wallpaper, keyboard, or motion."));
} }
for page in Page::ALL.into_iter().filter(|p| p.matches(&self.search)) { for page in pages().filter(|p| p.matches(&self.search)) {
let destination = page.search_destination(&self.search);
body = body.push( body = body.push(
button::custom( button::custom(
column([]) column([])
.spacing(3) .spacing(3)
.push(self.label(page.title(), 16.)) .push(self.label(page.title(), 16.))
.push(self.label(page.subtitle(), 12.)), .push(self.label(destination.subtitle(), 12.)),
) )
.width(Length::Fill) .width(Length::Fill)
.padding(10) .padding(10)
.class(visuals::button_style(palette, false)) .class(visuals::button_style(palette, false))
.on_press(Message::Page(page)), .on_press(Message::Page(destination)),
); );
} }
} else { } else {
if matches!(self.page, Page::Bar | Page::Windows) { if self.page == Page::Windows {
body = body.push(visuals::preview_resolved(&self.draft, &self.resolved.presented)); body = body.push(visuals::preview_resolved(&self.draft, &self.resolved.presented));
} }
if self.page == Page::Appearance { if self.page == Page::Appearance {
@@ -181,7 +213,12 @@ impl App {
if self.page == Page::Wallpaper { if self.page == Page::Wallpaper {
body = body.push(self.wallpaper_controls()); body = body.push(self.wallpaper_controls());
} }
let fields = if self.page == Page::Wallpaper { if self.page == Page::Bar && self.panel_page != crate::panel_controls::PanelPage::Arrange {
body = body.push(self.panel_controls());
}
let fields = if self.page == Page::Bar {
Vec::new()
} else if self.page == Page::Wallpaper {
self.wallpaper_fields() self.wallpaper_fields()
} else { } else {
schema::fields(self.page) schema::fields(self.page)
@@ -427,11 +464,48 @@ impl App {
} }
} }
let mut content = column([]).spacing(8).push( let mut page_heading = row([]).spacing(12).align_y(Alignment::Center);
if compact {
page_heading = page_heading.push(
container(self.settings_icon_button(
"Show settings navigation",
"M3 5h18v14H3z M9 5v14",
Some(Message::Sidebar(true)),
))
.id("settings-navigation-toggle"),
);
}
page_heading = page_heading.push(
widget::mouse_area(heading.width(Length::Fill)) widget::mouse_area(heading.width(Length::Fill))
.on_press(Message::DragWindow) .on_press(Message::DragWindow)
.interaction(cosmic::iced::mouse::Interaction::Grab), .interaction(cosmic::iced::mouse::Interaction::Grab),
); );
let mut content = column([])
.spacing(if self.page == Page::Bar { 16 } else { 8 })
.push(page_heading);
if self.search.is_empty() && !self.page.subpages().is_empty() {
content = content.push(
container(
row(self.page.subpages().iter().map(|&(page, title)| {
button::custom(self.label(title, 13.))
.name(format!("{}: {title}", self.page.navigation_page().title()))
.padding([7, 12])
.class(visuals::panel_button(palette, self.page == page))
.on_press(Message::Page(page))
.into()
}))
.spacing(2),
)
.padding(3)
.class(visuals::surface(palette.sidebar, 9.)),
);
}
if self.page == Page::Bar && self.search.is_empty() {
if self.panel_page == crate::panel_controls::PanelPage::Arrange {
content = content.push(self.panel_navigation());
}
content = content.push(self.panel_preview());
}
if let Some(error) = &self.error { if let Some(error) = &self.error {
content = content.push( content = content.push(
@@ -442,6 +516,12 @@ impl App {
); );
} }
if self.page == Page::Bar
&& self.search.is_empty()
&& self.panel_page == crate::panel_controls::PanelPage::Arrange
{
content = content.push(self.panel_workspace());
} else {
content = content.push( content = content.push(
scrollable( scrollable(
container(cosmic::iced::widget::keyed_column([(self.page as usize, body.into())])) container(cosmic::iced::widget::keyed_column([(self.page as usize, body.into())]))
@@ -461,6 +541,7 @@ impl App {
)) ))
.height(Length::Fill), .height(Length::Fill),
); );
}
let footer = row([]) let footer = row([])
.spacing(4) .spacing(4)
.align_y(Alignment::Center) .align_y(Alignment::Center)
@@ -503,12 +584,41 @@ impl App {
)); ));
let footer: Element<'_, Message> = if self.page == Page::Connections { let footer: Element<'_, Message> = if self.page == Page::Connections {
self.note("Connections are managed by the system. Passwords are not saved in your Ferese config.") self.note("Connections are managed by the system. Passwords are not saved in your Ferese config.")
} else if self.page == Page::Bar {
container(
row([])
.spacing(12)
.align_y(Alignment::Center)
.push(self.note(if self.saving { "Saving…" } else { &self.status }))
.push(widget::Space::new().width(Length::Fill))
.push(self.settings_button(
"Undo",
"M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7",
(self.undo.is_some() && !self.saving).then_some(Message::Undo),
false,
))
.push(self.settings_button(
"Reload configuration",
"M21 3v6h-6 M3 21v-6h6 M3 9a9 9 0 0 1 15-6l3 6 M21 15a9 9 0 0 1-15 6l-3-6",
(!self.saving).then_some(Message::Reload),
false,
)),
)
.padding([10, 0])
.into()
} else { } else {
footer.into() footer.into()
}; };
let view: Element<'_, Message> = row([]) let mut drawer = None;
.push(sidebar) let sidebar: Element<'_, Message> = if compact {
.push( if self.sidebar_open {
drawer = Some(sidebar);
}
widget::Space::new().width(0).into()
} else {
sidebar.into()
};
let view = row([]).push(sidebar).push(
container( container(
container(column([]).push(content.height(Length::Fill)).push(footer).spacing(6)) container(column([]).push(content.height(Length::Fill)).push(footer).spacing(6))
.padding(cosmic::iced::Padding { .padding(cosmic::iced::Padding {
@@ -517,16 +627,29 @@ impl App {
bottom: 8., bottom: 8.,
left: 20., left: 20.,
}) })
.max_width(840) .max_width(if self.page == Page::Bar { 1120 } else { 840 })
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill), .height(Length::Fill),
) )
.width(Length::Fill) .width(Length::Fill)
.center_x(Length::Fill) .center_x(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.id("settings-page")
.class(visuals::surface(palette.background, 0.)), .class(visuals::surface(palette.background, 0.)),
);
// Keep the page mounted and full-width while compact navigation is open.
let mut view = widget::popover(view).modal(true);
if let Some(sidebar) = drawer {
let dismiss = widget::mouse_area(
container(widget::Space::new().width(Length::Fill).height(Length::Fill))
.class(visuals::surface(cosmic::iced::Color::BLACK.scale_alpha(0.20), 0.)),
) )
.into(); .on_press(Message::Sidebar(false));
view = view
.position(widget::popover::Position::Point(cosmic::iced::Point::ORIGIN))
.popup(row([]).push(sidebar).push(dismiss).width(Length::Fill).height(height));
}
let view: Element<'_, Message> = view.into();
if self.profile_pages { if self.profile_pages {
let page = self.page; let page = self.page;
cosmic::iced::widget::sensor(view) cosmic::iced::widget::sensor(view)
+886
View File
@@ -0,0 +1,886 @@
use ferese_config::panel::{Edge, ItemKind, Panel};
use crate::panel_edit::{Action, Destination, Zone};
use crate::{App, Element, Message, schema::Page, store, widget};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) enum PanelPage {
#[default]
Panels,
Arrange,
}
pub(super) fn initialize(_snapshot: &store::Snapshot) -> Result<store::Edit, String> {
let panel = Panel::default();
ferese_config::panel::validate(std::slice::from_ref(&panel))?;
Ok(store::set(
"panels",
serde_json::to_value(vec![panel]).map_err(|error| error.to_string())?,
))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct CatalogItem(ItemKind);
impl std::fmt::Display for CatalogItem {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.0.label())
}
}
const CATALOG: [CatalogItem; 12] = [
CatalogItem(ItemKind::Overview),
CatalogItem(ItemKind::Workspaces {
style: ferese_config::panel::WorkspaceStyle::Dots,
}),
CatalogItem(ItemKind::FocusedWindow),
CatalogItem(ItemKind::Media),
CatalogItem(ItemKind::QuickSettings),
CatalogItem(ItemKind::Network),
CatalogItem(ItemKind::Audio),
CatalogItem(ItemKind::Recording),
CatalogItem(ItemKind::Notifications),
CatalogItem(ItemKind::Battery { percentage: true }),
CatalogItem(ItemKind::Clock),
CatalogItem(ItemKind::DisplayMode),
];
impl App {
pub(super) fn panel_navigation(&self) -> Element<'static, Message> {
self.settings_button(
"Back to Panels",
"M15 18l-6-6 6-6",
Some(Message::PanelPage(PanelPage::Panels)),
false,
)
}
pub(super) fn panel_controls(&self) -> Element<'_, Message> {
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let edge = self.preview_panel().map_or(Edge::Top, |panel| panel.edge);
let mut rows = widget::column([]).spacing(12);
match self.panel_page {
PanelPage::Panels => {
rows = rows.push(
widget::container(
widget::row([])
.spacing(12)
.align_y(cosmic::iced::Alignment::Center)
.push(crate::visuals::action_icon(
if edge == Edge::Top {
"M3 5h18v14H3z M3 9h18"
} else {
"M3 5h18v14H3z M3 15h18"
},
palette.accent,
))
.push(
widget::column([])
.spacing(3)
.push(self.panel_heading(
if edge == Edge::Top { "Top panel" } else { "Bottom panel" },
14.,
))
.push(self.note("Shown on all displays"))
.width(cosmic::iced::Length::Fill),
)
.push(self.settings_button(
"Edit panel",
"M4 20l4-1 12-12-3-3L5 16z",
Some(Message::PanelPage(PanelPage::Arrange)),
false,
))
.push(
self.settings_icon_button(
"Remove custom panel",
"M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13 M10 11v5 M14 11v5",
self.draft
.item("panels")
.is_some()
.then(|| Message::Change(store::Edit::Unset("panels".into()))),
),
),
)
.padding(14)
.width(cosmic::iced::Length::Fill)
.class(crate::visuals::surface(palette.card, 14.)),
);
let preset = self.preview_panel().ok().and_then(|panel| panel.preset());
let preset_key = match preset {
Some(ferese_config::PanelPreset::Continuous) => "continuous",
Some(ferese_config::PanelPreset::Islands) => "islands",
None => "custom",
};
let position = widget::row([Edge::Top, Edge::Bottom].into_iter().map(|position| {
let label = if position == Edge::Top { "Top" } else { "Bottom" };
widget::button::custom(self.label(label, 12.))
.name(format!("Panel position: {label}"))
.padding([8, 10])
.width(cosmic::iced::Length::Fill)
.class(crate::visuals::panel_button(palette, edge == position))
.on_press_maybe((edge != position).then_some(Message::PanelEdit(Action::SetEdge(position))))
.into()
}))
.spacing(2);
rows = rows.push(
widget::container(
self.panel_appearance_row(
"Position",
"Choose the screen edge for the panel.",
widget::container(position)
.padding(3)
.class(crate::visuals::surface(palette.sidebar, 9.))
.into(),
),
)
.padding(16)
.class(crate::visuals::surface(palette.card, 14.)),
);
let appearance = widget::column([])
.spacing(16)
.push(self.panel_heading("Appearance", 15.))
.push(self.panel_appearance_row(
"Background",
"Apply a preset, then customize each group.",
self.panel_surface_choices(
preset_key,
&[("continuous", "Continuous"), ("islands", "Islands")],
move |key| {
Message::PanelEdit(Action::ApplyPreset(if key == "islands" {
ferese_config::PanelPreset::Islands
} else {
ferese_config::PanelPreset::Continuous
}))
},
),
))
.push(
self.panel_appearance_row(
"Default group surface",
"Groups can override this in their inspector.",
self.panel_surface_choices(
self.preview_panel()
.map_or(ferese_config::panel::GroupSurface::None, |panel| panel.group_surface)
.key(),
&[("none", "None"), ("inset", "Inset"), ("island", "Island")],
|key| {
Message::PanelEdit(Action::SetDefaultSurface(match key {
"inset" => ferese_config::panel::GroupSurface::Inset,
"island" => ferese_config::panel::GroupSurface::Island,
_ => ferese_config::panel::GroupSurface::None,
}))
},
),
),
)
.push(self.panel_divider())
.push(self.panel_opacity_controls())
.push(self.panel_divider())
.push(
self.panel_appearance_row(
"Group borders",
"Outline each group of items.",
widget::container(
ferese_theme::controls::switch(self.draft.boolean("panels.0.border", false), palette)
.name("Group borders")
.on_press(Message::PanelEdit(Action::SetBorder(
!self.draft.boolean("panels.0.border", false),
))),
)
.align_right(cosmic::iced::Length::Fill)
.into(),
),
)
.push(self.panel_divider())
.push(self.panel_radius_controls());
rows = rows.push(
widget::container(appearance)
.id("panel-appearance-settings")
.padding(16)
.width(cosmic::iced::Length::Fill)
.class(crate::visuals::surface(palette.card, 14.)),
);
let mut settings = widget::column([])
.spacing(4)
.push(widget::container(self.panel_heading("Size & spacing", 15.)).padding([4, 10]));
for field in crate::schema::fields(Page::Bar)
.into_iter()
.filter(|field| field.path.starts_with("panels.0.geometry."))
{
let mut field = field;
if let crate::schema::Kind::Range { default, .. } = &mut field.kind {
let geometry =
serde_json::to_value(self.preview_panel().map(|panel| panel.geometry).unwrap_or_default())
.unwrap();
*default = geometry[field.path.rsplit('.').next().unwrap()].as_f64().unwrap();
}
settings = settings.push(self.field(field));
}
rows = rows.push(
widget::container(settings)
.padding(12)
.class(crate::visuals::surface(palette.card, 14.)),
);
}
PanelPage::Arrange => return self.panel_workspace(),
}
rows.into()
}
pub(super) fn panel_side_margin_limit(&self) -> Option<i32> {
let display = self
.displays
.iter()
.filter(|display| display.logical_width.is_some())
.find(|display| display.focused)
.or_else(|| {
self.displays
.iter()
.filter(|display| display.logical_width.is_some())
.min_by_key(|display| display.logical_width)
})?;
if self.panel_preview_resolution.items.is_empty() {
return None;
}
let panel = self.preview_panel().ok()?;
Some(
self.panel_preview_resolution
.side_margin(4096, display.logical_width?, panel.geometry.inner_padding),
)
}
fn panel_appearance_row(
&self,
label: &str,
description: &str,
control: Element<'static, Message>,
) -> Element<'static, Message> {
widget::row([])
.spacing(24)
.align_y(cosmic::iced::Alignment::Center)
.push(
widget::column([])
.spacing(4)
.push(self.label(label.to_owned(), 13.))
.push(self.note(description))
.width(cosmic::iced::Length::Fill),
)
.push(widget::container(control).width(280))
.width(cosmic::iced::Length::Fill)
.into()
}
fn panel_opacity_controls(&self) -> Element<'static, Message> {
use cosmic::iced::{Alignment, Length};
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let tokens = &self.resolved.presented.tokens;
let inherited = if tokens.material.style == "translucent" {
tokens.material.opacity
} else {
1.
};
let value = self
.ranges
.get("panels.0.background_opacity")
.copied()
.unwrap_or_else(|| self.draft.number("panels.0.background_opacity", inherited) * 100.);
let custom = self.draft.item("panels.0.background_opacity").is_some();
let field = crate::Field::new(
"panels.0.background_opacity",
"Background opacity",
"",
crate::Kind::Range {
default: inherited * 100.,
min: 0.,
max: 100.,
step: 1.,
suffix: "%",
integer: false,
},
);
let release = field.clone();
let control = widget::row([])
.spacing(10)
.align_y(Alignment::Center)
.push(
crate::slider(0. ..=100., value, move |value| Message::Range(field.clone(), value))
.step(1.)
.class(ferese_theme::menus::slider(palette.accent, 1.))
.on_release(Message::Release(release))
.width(Length::Fill),
)
.push(
widget::container(self.label(format!("{value:.0}%"), 12.).align_x(Alignment::Center))
.padding([6, 0])
.width(48),
)
.push(self.settings_icon_button(
"Use shell opacity",
"M3 12a9 9 0 1 0 3-6 M3 3v6h6",
custom.then_some(Message::PanelEdit(Action::ClearOpacity)),
));
self.panel_appearance_row(
"Background opacity",
if custom {
"Panel only. Text and icons stay opaque."
} else {
"Using shell opacity. Text and icons stay opaque."
},
control.into(),
)
}
fn panel_radius_controls(&self) -> Element<'static, Message> {
use cosmic::iced::{Alignment, Length};
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let shell_radius = self.resolved.presented.tokens.geometry.shell_radius as f32;
let custom = self
.draft
.item("panels.0.corner_radius")
.and_then(|value| serde_json::from_value::<ferese_config::panel::CornerRadii>(value.clone()).ok());
let radii = custom.unwrap_or_else(|| ferese_config::panel::CornerRadii::uniform(shell_radius));
let modes = widget::row([("Shell default", false), ("Custom", true)].map(|(label, selected)| {
widget::button::custom(self.label(label, 12.).align_x(Alignment::Center))
.name(format!("Corner radius: {label}"))
.padding([8, 12])
.width(Length::Fill)
.class(crate::visuals::panel_button(palette, custom.is_some() == selected))
.on_press_maybe((custom.is_some() != selected).then(|| {
Message::PanelEdit(if selected {
Action::SetRadius(format!("{shell_radius}px"))
} else {
Action::ClearRadius
})
}))
.into()
}))
.spacing(2);
let modes = widget::container(modes)
.padding(3)
.class(crate::visuals::surface(palette.sidebar, 9.));
let mut corners = Vec::new();
// Match the physical corners: top row first, then bottom-left / bottom-right.
for (index, label, icon) in [
(0, "Top left", "M5 19V9a4 4 0 0 1 4-4h10"),
(1, "Top right", "M5 5h10a4 4 0 0 1 4 4v10"),
(3, "Bottom left", "M5 5v10a4 4 0 0 0 4 4h10"),
(2, "Bottom right", "M5 19h10a4 4 0 0 0 4-4V5"),
] {
let value = radii.0[index];
let tint = if custom.is_some() { palette.text } else { palette.muted };
corners.push(
widget::container(
widget::row([])
.spacing(8)
.align_y(Alignment::Center)
.push(crate::visuals::action_icon(icon, palette.muted))
.push(self.label(label, 12.).width(Length::Fill))
.push(
self.settings_icon_button(
&format!("Decrease {label} radius"),
"M5 12h14",
(custom.is_some() && value > 0.)
.then_some(Message::PanelEdit(Action::SetCorner(index, (value - 1.).max(0.)))),
),
)
.push(
self.label(format!("{value} px"), 12.)
.width(52)
.align_x(Alignment::Center)
.class(cosmic::theme::Text::Color(tint)),
)
.push(
self.settings_icon_button(
&format!("Increase {label} radius"),
"M12 5v14 M5 12h14",
custom
.is_some()
.then_some(Message::PanelEdit(Action::SetCorner(index, value + 1.))),
),
),
)
.padding([8, 10])
.width(Length::Fill)
.class(crate::visuals::surface(
ferese_theme::mix(palette.card, palette.sidebar, 0.5),
8.,
))
.into(),
);
}
let bottom_corners = corners.split_off(2);
let mut content = widget::column([])
.spacing(12)
.push(self.panel_appearance_row("Corner radius", "Set the rounding of each corner.", modes.into()))
.push(
widget::column([])
.spacing(8)
.push(
widget::flex_row(corners)
.min_item_width(220.)
.spacing(8)
.width(Length::Fill),
)
.push(
widget::flex_row(bottom_corners)
.min_item_width(220.)
.spacing(8)
.width(Length::Fill),
),
);
if self.preview_panel().is_ok_and(|panel| panel.geometry.edge_margin == 0) {
let bottom = self.preview_panel().is_ok_and(|panel| panel.edge == Edge::Bottom);
content = content.push(self.note(if bottom {
"Bottom corners stay square while the panel touches the bottom edge."
} else {
"Top corners stay square while the panel touches the top edge."
}));
}
content.into()
}
pub(super) fn panel_workspace(&self) -> Element<'_, Message> {
let panel = match self.preview_panel() {
Ok(panel) => panel,
Err(error) => return self.note(&error),
};
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
cosmic::iced::widget::responsive(move |size| {
let editor = widget::column([])
.spacing(12)
.push(self.panel_heading("Arrangement", 15.))
.push(self.note("Drag items to reorder. Select an item or group to edit."))
.push(
widget::scrollable(self.panel_editor(&panel))
.id(widget::Id::new("settings-content"))
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
cosmic::iced::widget::scrollable::Scrollbar::new()
.width(8)
.scroller_width(3),
))
.height(cosmic::iced::Length::Fill),
);
let inspector = widget::scrollable(self.panel_inspector(&panel))
.id(widget::Id::new("panel-inspector-scroll"))
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
cosmic::iced::widget::scrollable::Scrollbar::new()
.width(8)
.scroller_width(3),
))
.height(cosmic::iced::Length::Fill);
widget::row([])
.spacing(16)
.push(
widget::container(editor)
.width(cosmic::iced::Length::Fill)
.height(cosmic::iced::Length::Fill),
)
.push(
widget::container(inspector)
.width((size.width * 0.48).clamp(220., 320.))
.height(cosmic::iced::Length::Fill)
.class(crate::visuals::surface(palette.card, 12.)),
)
.height(cosmic::iced::Length::Fill)
.into()
})
.height(cosmic::iced::Length::Fill)
.into()
}
fn panel_inspector(&self, panel: &Panel) -> Element<'static, Message> {
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let mut inspector = None;
for placement in Zone::ALL {
for (group_index, group) in placement.definition(panel).groups.iter().enumerate() {
if self.panel_group_selection.as_ref() == Some(&group.id) {
inspector = Some(self.group_inspector(panel, group, placement));
}
if let Some((item_index, item)) = group
.items
.iter()
.enumerate()
.find(|(_, item)| self.panel_selection.as_ref() == Some(&item.id))
{
inspector = Some(self.item_inspector(
panel,
item,
Destination {
zone: placement.key(),
group: group.id.clone(),
},
format!("panels.0.{}.groups.{group_index}.items.{item_index}", placement.key()),
));
}
}
}
widget::container(inspector.unwrap_or_else(|| {
widget::container(
widget::column([])
.spacing(12)
.align_x(cosmic::iced::Alignment::Center)
.push(crate::visuals::action_icon(
"M4 4h16v16H4z M14 4v16 M7 8h4 M7 12h4",
palette.muted,
))
.push(self.panel_heading("Make it your own", 15.))
.push(self.note(
"Select an item to adjust how it appears, or a group to edit its placement and spacing.",
)),
)
.padding([40, 8])
.into()
}))
.id("panel-inspector")
.padding(16)
.width(cosmic::iced::Length::Fill)
.class(crate::visuals::surface(palette.card, 14.))
.into()
}
fn panel_surface_choices(
&self,
selected: &str,
options: &'static [(&'static str, &'static str)],
message: impl Fn(&'static str) -> Message,
) -> Element<'static, Message> {
use cosmic::iced::{Alignment, Length};
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let options = widget::row(options.iter().map(|&(key, label)| {
widget::button::custom(
widget::row([])
.spacing(8)
.align_y(Alignment::Center)
.push(crate::visuals::action_icon(
match key {
"continuous" => "M3 7h18v10H3z",
"none" => "M4 4l16 16 M4 20L20 4",
"inset" => "M3 5h18v14H3z M7 9h10v6H7z",
_ => "M2 7h5v10H2z M10 7h4v10h-4z M17 7h5v10h-5z",
},
palette.muted,
))
.push(self.label(label, 12.)),
)
.name(format!("Background: {label}"))
.padding([8, 10])
.width(Length::Fill)
.class(crate::visuals::panel_button(palette, selected == key))
.on_press(message(key))
.into()
}))
.spacing(2);
widget::container(options)
.padding(3)
.width(Length::Fill)
.class(crate::visuals::surface(palette.sidebar, 9.))
.into()
}
fn panel_editor(&self, panel: &Panel) -> Element<'static, Message> {
use cosmic::iced::{Alignment, Length};
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let mut zones = widget::column([]).spacing(18);
if panel.group_count() == ferese_config::panel::MAX_GROUPS {
zones = zones.push(self.label(format!("{}-group limit reached", ferese_config::panel::MAX_GROUPS), 12.));
}
for placement in Zone::ALL {
let zone = placement.definition(panel);
let mut groups = widget::column([]).spacing(8).push(
widget::row([])
.align_y(Alignment::Center)
.push(self.panel_heading(placement.to_string(), 13.).width(Length::Fill))
.push(
self.settings_button(
"Add group",
"M12 5v14 M5 12h14",
(panel.group_count() < ferese_config::panel::MAX_GROUPS)
.then_some(Message::PanelEdit(Action::AddGroup(placement))),
false,
),
),
);
let mut small_groups = Vec::new();
for group in &zone.groups {
let destination = Destination {
zone: placement.key(),
group: group.id.clone(),
};
let heading = widget::row([]).spacing(6).align_y(Alignment::Center).push(
widget::button::custom(
widget::row([])
.spacing(6)
.align_y(Alignment::Center)
.push(crate::visuals::action_icon("M3 7h18v13H3z M3 7V4h7l3 3", palette.muted))
.push(self.label(group_name(&group.id.0), 13.)),
)
.name(format!("Edit group {}", group.id.0))
.padding([7, 6])
.width(Length::Fill)
.class(crate::visuals::panel_button(
palette,
self.panel_group_selection.as_ref() == Some(&group.id),
))
.on_press(Message::PanelGroupSelect(group.id.clone())),
);
let add_item = crate::visuals::panel_select(
cosmic::iced::widget::pick_list(CATALOG.to_vec(), None::<CatalogItem>, move |kind| {
Message::PanelEdit(Action::Add(kind.0, destination.clone()))
})
.placeholder("Add item")
.font(self.font)
.text_size(12)
.padding([6, 8])
.width(Length::Shrink),
palette,
);
let mut items = Vec::new();
for item in &group.items {
let grip = widget::container(crate::visuals::action_icon(
"M9 5h.01 M15 5h.01 M9 12h.01 M15 12h.01 M9 19h.01 M15 19h.01",
palette.muted,
))
.padding([8, 0])
.id(crate::panel_drag::grip_id(&item.id));
let select = widget::button::custom(
widget::row([])
.spacing(6)
.align_y(Alignment::Center)
.push(item_icon(
item.kind,
if item.visible {
palette.text
} else {
palette.muted.scale_alpha(0.6)
},
))
.push(self.label(item.kind.label(), 13.).width(Length::Fill).class(
cosmic::theme::Text::Color(if item.visible {
palette.text
} else {
palette.muted.scale_alpha(0.6)
}),
)),
)
.name(format!("Edit {} · {}", item.kind.label(), item.id.0))
.padding([8, 6])
.width(Length::Fill)
.class(crate::visuals::panel_button(
palette,
self.panel_selection.as_ref() == Some(&item.id),
))
.on_press(Message::PanelSelect(item.id.clone()));
items.push(
widget::container(
widget::row([])
.align_y(Alignment::Center)
.push(widget::mouse_area(grip).interaction(cosmic::iced::mouse::Interaction::Grab))
.push(select),
)
.id(crate::panel_drag::item_id(&item.id))
.width(Length::Fill)
.into(),
);
}
let mut contents = widget::column([]).spacing(4).push(heading);
if items.is_empty() {
contents = contents.push(widget::container(self.note("Drop items here")).padding([8, 6]));
} else {
contents = contents.push(
widget::flex_row(items)
.min_item_width(148.)
.spacing(4)
.width(Length::Fill),
);
}
contents = contents.push(widget::container(add_item).padding([4, 0]));
let card: Element<'static, Message> = widget::container(contents)
.id(crate::panel_drag::group_id(&group.id))
.padding(6)
.width(Length::Fill)
.class(crate::visuals::surface(palette.card, 10.))
.into();
if group.items.len() > 2 {
if !small_groups.is_empty() {
groups = groups.push(
widget::flex_row(std::mem::take(&mut small_groups))
.min_item_width(210.)
.spacing(8)
.width(Length::Fill),
);
}
groups = groups.push(card);
} else {
small_groups.push(card);
}
}
if !small_groups.is_empty() {
groups = groups.push(
widget::flex_row(small_groups)
.min_item_width(210.)
.spacing(8)
.width(Length::Fill),
);
}
if zone.groups.is_empty() {
groups = groups.push(
widget::container(self.note("Drop an item here, or add a group."))
.id(crate::panel_drag::empty_zone_id(placement))
.width(Length::Fill)
.height(64)
.padding(12)
.class(crate::visuals::surface(palette.card, 10.)),
);
}
zones = zones.push(widget::container(groups).padding([0, 4]).width(Length::Fill));
}
crate::panel_drag::frame(zones.into(), panel, palette.accent)
}
}
pub(super) fn group_name(id: &str) -> String {
let mut name = id.replace(['-', '_'], " ");
if let Some(first) = name.get_mut(..1) {
first.make_ascii_uppercase();
}
name
}
pub(super) fn item_icon(kind: ItemKind, tint: cosmic::iced::Color) -> widget::icon::Icon {
use ferese_theme::icons;
let source = match kind {
ItemKind::Overview => icons::FERESE,
ItemKind::Workspaces { .. } => icons::OVERVIEW,
ItemKind::FocusedWindow => icons::DISPLAY,
ItemKind::Media => icons::MEDIA,
ItemKind::QuickSettings => icons::CONTROL_CENTER,
ItemKind::Network => icons::WIFI_FULL,
ItemKind::Audio => icons::VOLUME_HIGH,
ItemKind::Recording => icons::RECORD,
ItemKind::Notifications => icons::NOTIFICATIONS,
ItemKind::Battery { .. } => icons::BATTERY_75,
ItemKind::Clock => icons::CALENDAR,
ItemKind::DisplayMode => icons::DISPLAY_EXTEND,
ItemKind::Overflow => icons::CHEVRON_DOWN,
};
icons::tinted(source, 16, tint)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn side_margin_range_tracks_logical_display_width_and_measured_content() {
use cosmic::Application;
let mut app = App::init(
cosmic::app::Core::default(),
("/unused/range.kdl".into(), store::Snapshot::parse(String::new()), None),
)
.0;
assert_eq!(app.panel_side_margin_limit(), None);
app.displays = vec![crate::displays::Display {
connector: "eDP-1".into(),
identity: "panel".into(),
profile: None,
logical_width: Some(1440),
focused: true,
current: None,
modes: Vec::new(),
}];
app.panel_preview_resolution.items.insert(
ferese_config::panel::ItemId("clock".into()),
ferese_config::panel::layout::Placement::Overflow,
);
app.panel_preview_resolution.minimum_width = 600.;
assert_eq!(app.panel_side_margin_limit(), Some(408));
app.displays[0].logical_width = Some(960);
assert_eq!(app.panel_side_margin_limit(), Some(168));
app.panel_preview_resolution.minimum_width = 1000.;
assert_eq!(app.panel_side_margin_limit(), Some(0));
app.displays[0].logical_width = None;
assert_eq!(app.panel_side_margin_limit(), None);
}
#[test]
fn customization_uses_canonical_defaults_and_retains_other_fields() {
let mut snapshot = store::Snapshot::parse("// keep\nanimations { speed 0.8; }\n".into()).unwrap();
snapshot.edit(&initialize(&snapshot).unwrap()).unwrap();
let snapshot = store::Snapshot::parse(snapshot.source).unwrap();
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
assert_eq!(panels[0].preset(), Some(ferese_config::PanelPreset::Continuous));
assert_eq!(panels[0].geometry, ferese_config::panel::PanelGeometry::default());
assert!(panels[0].center.groups[0].items[0].visible);
assert_eq!(snapshot.number("animations.speed", 1.), 0.8);
assert!(snapshot.source.contains("// keep"));
let id = snapshot.string("panels.0.end.groups.0.items.2.id", "");
let mut changed = snapshot;
changed
.edit(&store::set("panels.0.end.groups.0.items.2.overflow", "always"))
.unwrap();
let changed = store::Snapshot::parse(changed.source).unwrap();
assert_eq!(changed.string("panels.0.end.groups.0.items.2.id", ""), id);
}
#[test]
fn editor_zones_wrap_without_putting_groups_outside_the_available_width() {
use cosmic::Application;
use cosmic::iced::advanced::{Layout, layout, renderer::Headless, widget as advanced};
use cosmic::iced::{Font, Pixels, Rectangle, Size};
struct Bounds {
ids: Vec<advanced::Id>,
groups: Vec<Rectangle>,
}
impl advanced::Operation for Bounds {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn advanced::Operation)) {
children(self);
}
fn container(&mut self, id: Option<&advanced::Id>, bounds: Rectangle) {
if id.is_some_and(|id| self.ids.contains(id)) {
self.groups.push(bounds);
}
}
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let app = App::init(
crate::Core::default(),
("/unused/editor.kdl".into(), store::Snapshot::parse(String::new()), None),
)
.0;
let panel = Panel::default();
for width in [280., 560., 900.] {
let mut view = app.panel_editor(&panel);
let mut tree = advanced::Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, 2000.)),
);
let mut bounds = Bounds {
ids: Zone::ALL
.into_iter()
.flat_map(|zone| &zone.definition(&panel).groups)
.map(|group| crate::panel_drag::group_id(&group.id))
.collect(),
groups: Vec::new(),
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut bounds);
assert_eq!(bounds.groups.len(), bounds.ids.len());
for group in bounds.groups {
assert!(
group.x >= -0.1 && group.x + group.width <= width + 0.1,
"{group:?} outside {width}"
);
}
}
}
}
+738
View File
@@ -0,0 +1,738 @@
//! Local item dragging. Pointer motion changes only the drop hint; release saves once.
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Border, Color, Event, Length, Point, Rectangle, Size, Vector, keyboard};
use cosmic::{Element, Renderer, Theme};
use ferese_config::panel::{GroupId, ItemId, Panel};
use crate::Message;
use crate::panel_edit::{Action, Destination, Zone};
pub(super) fn item_id(id: &ItemId) -> widget::Id {
widget::Id::new(format!("panel-editor:item:{}", id.0))
}
pub(super) fn grip_id(id: &ItemId) -> widget::Id {
widget::Id::new(format!("panel-editor:grip:{}", id.0))
}
pub(super) fn group_id(id: &GroupId) -> widget::Id {
widget::Id::new(format!("panel-editor:group:{}", id.0))
}
pub(super) fn empty_zone_id(zone: Zone) -> widget::Id {
widget::Id::new(format!("panel-editor:empty:{}", zone.key()))
}
#[derive(Clone)]
enum Region {
Grip(ItemId),
Item(ItemId, Destination),
Group(Destination),
Empty(Zone),
}
pub(super) fn frame<'a>(content: Element<'a, Message>, panel: &Panel, accent: Color) -> Element<'a, Message> {
let mut regions = Vec::new();
for zone in Zone::ALL {
if zone.definition(panel).groups.is_empty() {
regions.push((empty_zone_id(zone), Region::Empty(zone)));
}
for group in &zone.definition(panel).groups {
let destination = Destination {
zone: zone.key(),
group: group.id.clone(),
};
regions.push((group_id(&group.id), Region::Group(destination.clone())));
for item in &group.items {
regions.push((item_id(&item.id), Region::Item(item.id.clone(), destination.clone())));
regions.push((grip_id(&item.id), Region::Grip(item.id.clone())));
}
}
}
Element::new(Editor {
content,
regions,
accent,
})
}
struct Editor<'a> {
content: Element<'a, Message>,
regions: Vec<(widget::Id, Region)>,
accent: Color,
}
#[derive(Default)]
struct State {
drag: Option<Drag>,
}
struct Drag {
item: ItemId,
origin: Point,
cursor: Point,
source: Rectangle,
moved: bool,
target: Option<Target>,
}
#[derive(Clone)]
enum Target {
Group {
destination: Destination,
before: Option<ItemId>,
marker: Rectangle,
},
Empty(Zone, Rectangle),
}
impl Target {
fn action(self, item: ItemId) -> Action {
match self {
Self::Group {
destination, before, ..
} => Action::Place(item, destination, before),
Self::Empty(zone, _) => Action::PlaceInZone(item, zone),
}
}
fn marker(&self) -> Rectangle {
match self {
Self::Group { marker, .. } | Self::Empty(_, marker) => *marker,
}
}
}
struct Bounds<'a> {
regions: &'a [(widget::Id, Region)],
found: Vec<(Region, Rectangle)>,
}
impl widget::Operation for Bounds<'_> {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if let Some((_, region)) = self.regions.iter().find(|(target, _)| Some(target) == id) {
self.found.push((region.clone(), bounds));
}
}
}
fn target_at(regions: &[(Region, Rectangle)], cursor: Point, viewport: &Rectangle) -> Option<Target> {
if !viewport.contains(cursor) {
return None;
}
for (region, bounds) in regions {
if !bounds.contains(cursor) {
continue;
}
match region {
Region::Empty(zone) => return Some(Target::Empty(*zone, *bounds)),
Region::Group(destination) => {
let items: Vec<_> = regions
.iter()
.filter_map(|(region, bounds)| match region {
Region::Item(id, group) if group.group == destination.group => Some((id, *bounds)),
_ => None,
})
.collect();
let next = items.iter().find(|(_, item)| {
cursor.y < item.y || cursor.y <= item.y + item.height && cursor.x < item.center_x()
});
let (before, marker) = next.map_or((None, *bounds), |(id, item)| {
(
Some((*id).clone()),
Rectangle {
x: item.x - 2.,
y: item.y,
width: 3.,
height: item.height,
},
)
});
return Some(Target::Group {
destination: destination.clone(),
before,
marker,
});
}
_ => {}
}
}
None
}
impl Widget<Message, Theme, Renderer> for Editor<'_> {
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.content)]
}
fn diff(&mut self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_mut(&mut self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut widget::Tree, renderer: &Renderer, limits: &layout::Limits) -> layout::Node {
if tree.state.downcast_ref::<State>().drag.as_ref().is_some_and(|drag| {
!self
.regions
.iter()
.any(|(_, region)| matches!(region, Region::Grip(id) if id == &drag.item))
}) {
tree.state.downcast_mut::<State>().drag = None;
}
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let mut bounds = Bounds {
regions: &self.regions,
found: Vec::new(),
};
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, &mut bounds);
let state = tree.state.downcast_mut::<State>();
if (matches!(event, Event::Window(cosmic::iced::window::Event::Unfocused))
|| matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
..
})
))
&& state.drag.take().is_some()
{
shell.capture_event();
shell.request_redraw();
return;
}
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if let Some(cursor) = cursor.position().filter(|point| viewport.contains(*point))
&& let Some((Region::Grip(item), _)) = bounds
.found
.iter()
.find(|(region, bounds)| matches!(region, Region::Grip(_)) && bounds.contains(cursor))
{
let source = bounds
.found
.iter()
.find_map(|(region, bounds)| {
matches!(region, Region::Item(id, _) if id == item).then_some(*bounds)
})
.unwrap();
state.drag = Some(Drag {
item: item.clone(),
origin: cursor,
cursor,
source,
moved: false,
target: None,
});
shell.capture_event();
return;
}
}
Event::Mouse(mouse::Event::CursorMoved { .. }) if state.drag.is_some() => {
let drag = state.drag.as_mut().unwrap();
if let Some(cursor) = cursor.position() {
drag.cursor = cursor;
drag.moved |= (cursor.x - drag.origin.x).hypot(cursor.y - drag.origin.y) >= 6.;
drag.target = if drag.moved {
target_at(&bounds.found, cursor, viewport)
} else {
None
};
} else {
drag.target = None;
}
shell.capture_event();
shell.request_redraw();
return;
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.drag.is_some() => {
let drag = state.drag.take().unwrap();
if drag.moved {
if let Some(target) = cursor
.position()
.and_then(|cursor| target_at(&bounds.found, cursor, viewport))
{
shell.publish(Message::PanelEdit(target.action(drag.item)));
}
} else if cursor
.position()
.is_some_and(|point| drag.source.contains(point) && viewport.contains(point))
{
shell.publish(Message::PanelSelect(drag.item));
}
shell.capture_event();
shell.request_redraw();
return;
}
_ => {}
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
use cosmic::iced::advanced::Renderer as _;
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
if let Some(drag) = tree
.state
.downcast_ref::<State>()
.drag
.as_ref()
.filter(|drag| drag.moved)
{
renderer.with_layer(*viewport, |renderer| {
if let Some(target) = &drag.target {
renderer.fill_quad(
renderer::Quad {
bounds: target.marker(),
border: Border {
color: self.accent,
width: 2.,
radius: 6.into(),
..Border::default()
},
..renderer::Quad::default()
},
Color { a: 0.12, ..self.accent },
);
}
let ghost = Rectangle {
x: drag.source.x + drag.cursor.x - drag.origin.x,
y: drag.source.y + drag.cursor.y - drag.origin.y,
..drag.source
};
renderer.fill_quad(
renderer::Quad {
bounds: ghost,
border: Border {
color: self.accent,
width: 1.,
radius: 8.into(),
..Border::default()
},
..renderer::Quad::default()
},
Color { a: 0.12, ..self.accent },
);
});
}
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
if tree.state.downcast_ref::<State>().drag.is_some() {
mouse::Interaction::Grabbing
} else {
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
}
}
fn a11y_nodes(
&self,
layout: Layout<'_>,
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, Message, Theme, Renderer>> {
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::{Snapshot, set};
use cosmic::iced::advanced::{clipboard, renderer::Headless};
use cosmic::iced::{Font, Pixels};
use cosmic::widget::{button, column, container, row, text};
fn panel() -> Panel {
serde_json::from_value(serde_json::json!({"id":"main", "start":{"groups":[{"id":"one", "items":[{"id":"a", "kind":"clock"},{"id":"b", "kind":"clock"}]}]}, "end":{"groups":[{"id":"two", "items":[{"id":"c", "kind":"clock"}]}]}})).unwrap()
}
fn view(panel: &Panel) -> Element<'static, Message> {
let zones = Zone::ALL.into_iter().map(|zone| {
let mut groups = column([]).spacing(8);
for group in &zone.definition(panel).groups {
let items = group.items.iter().map(|item| {
container(
row([])
.push(container(text("::")).width(20).height(32).id(grip_id(&item.id)))
.push(
button::custom(text(item.id.0.clone()))
.width(60)
.height(32)
.on_press(Message::PanelSelect(item.id.clone())),
),
)
.id(item_id(&item.id))
.into()
});
groups = groups.push(
container(column(items).spacing(8))
.width(140)
.padding(8)
.id(group_id(&group.id)),
);
}
if zone.definition(panel).groups.is_empty() {
groups = groups.push(
container(text("Drop here"))
.width(140)
.height(100)
.id(empty_zone_id(zone)),
);
}
groups.into()
});
frame(row(zones).spacing(20).into(), panel, Color::WHITE)
}
fn renderer() -> Renderer {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime
.block_on(<Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap()
}
fn send(
view: &mut Element<'_, Message>,
tree: &mut widget::Tree,
node: &layout::Node,
renderer: &Renderer,
event: Event,
point: Point,
messages: &mut Vec<Message>,
) {
let viewport = Rectangle::with_size(Size::new(500., 200.));
view.as_widget_mut().update(
tree,
&event,
Layout::new(node),
mouse::Cursor::Available(point),
renderer,
&mut clipboard::Null,
&mut Shell::new(messages),
&viewport,
);
}
#[test]
fn dragging_moves_across_groups_only_on_release_and_empty_zones_are_targets() {
let panel = panel();
let renderer = renderer();
let mut view = view(&panel);
let mut tree = widget::Tree::new(&view);
let viewport = Rectangle::with_size(Size::new(500., 200.));
let node = view
.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
let mut snapshot = Snapshot::parse(String::new()).unwrap();
snapshot.edit(&set("panels", serde_json::json!([panel]))).unwrap();
for (destination, empty) in [(Point::new(332., 20.), false), (Point::new(180., 20.), true)] {
let mut messages = Vec::new();
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Point::new(12., 20.),
&mut messages,
);
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::CursorMoved { position: destination }),
destination,
&mut messages,
);
assert!(messages.is_empty(), "motion must not save");
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
destination,
&mut messages,
);
assert_eq!(messages.len(), 1);
let Message::PanelEdit(action) = messages.remove(0) else {
panic!("expected drop action")
};
if empty {
assert!(matches!(&action, Action::PlaceInZone(id, Zone::Center) if id.0 == "a"));
} else {
assert!(
matches!(&action, Action::Place(id, destination, Some(before)) if id.0 == "a" && destination.group.0 == "two" && before.0 == "c")
);
}
let mut draft = snapshot.clone();
for edit in crate::panel_edit::plan(&draft, action).unwrap() {
draft.edit(&edit).unwrap();
}
Snapshot::parse(draft.source).unwrap();
}
}
#[test]
fn cancellation_outside_viewport_and_focus_loss_never_save_and_clicks_select() {
let panel = panel();
let renderer = renderer();
let mut view = view(&panel);
let mut tree = widget::Tree::new(&view);
let viewport = Rectangle::with_size(Size::new(500., 200.));
let node = view
.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
for cancel in [true, false] {
let mut messages = Vec::new();
let outside = Point::new(600., 20.);
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Point::new(12., 20.),
&mut messages,
);
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::CursorMoved { position: outside }),
outside,
&mut messages,
);
if cancel {
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Window(cosmic::iced::window::Event::Unfocused),
outside,
&mut messages,
);
}
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
outside,
&mut messages,
);
assert!(messages.is_empty());
}
for cancel in [
Event::Window(cosmic::iced::window::Event::Unfocused),
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
modified_key: keyboard::Key::Named(keyboard::key::Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
}),
] {
let mut messages = Vec::new();
let destination = Point::new(332., 20.);
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Point::new(12., 20.),
&mut messages,
);
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::CursorMoved { position: destination }),
destination,
&mut messages,
);
send(
&mut view,
&mut tree,
&node,
&renderer,
cancel,
destination,
&mut messages,
);
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
destination,
&mut messages,
);
assert!(messages.is_empty(), "cancellation must not save a valid drop");
}
for point in [Point::new(12., 20.), Point::new(60., 20.)] {
let mut messages = Vec::new();
for event in [
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(event),
point,
&mut messages,
);
}
assert_eq!(messages.len(), 1);
assert!(matches!(&messages[0], Message::PanelSelect(id) if id.0 == "a"));
}
}
#[test]
fn drop_hint_follows_wrapped_reading_order_and_ignores_clipped_targets() {
let destination = Destination {
zone: "start",
group: GroupId("one".into()),
};
let regions = vec![
(
Region::Group(destination.clone()),
Rectangle {
x: 0.,
y: 0.,
width: 140.,
height: 100.,
},
),
(
Region::Item(ItemId("a".into()), destination.clone()),
Rectangle {
x: 8.,
y: 8.,
width: 60.,
height: 32.,
},
),
(
Region::Item(ItemId("b".into()), destination.clone()),
Rectangle {
x: 76.,
y: 8.,
width: 60.,
height: 32.,
},
),
(
Region::Item(ItemId("c".into()), destination),
Rectangle {
x: 8.,
y: 48.,
width: 60.,
height: 32.,
},
),
];
let viewport = Rectangle::with_size(Size::new(140., 100.));
for (point, expected) in [
(Point::new(20., 20.), Some("a")),
(Point::new(70., 20.), Some("b")),
(Point::new(130., 20.), Some("c")),
(Point::new(80., 70.), None),
] {
let Some(Target::Group { before, .. }) = target_at(&regions, point, &viewport) else {
panic!("expected group")
};
assert_eq!(before.as_ref().map(|id| id.0.as_str()), expected);
}
assert!(
target_at(
&regions,
Point::new(20., 70.),
&Rectangle::with_size(Size::new(140., 40.))
)
.is_none()
);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,390 @@
use crate::panel_edit::{Action, Destination, Zone};
use crate::{App, Element, Field, Kind, Message, widget};
use cosmic::iced::{Alignment, Length};
use ferese_config::panel::{Group, Item, ItemKind, Panel, Representation};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Choice(&'static str, &'static str);
impl std::fmt::Display for Choice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.1)
}
}
impl App {
fn inspector_heading(&self, title: &str, subtitle: &str) -> Element<'static, Message> {
widget::column([])
.spacing(5)
.push(
widget::row([])
.align_y(Alignment::Center)
.push(self.panel_heading(title.to_owned(), 16.).width(Length::Fill))
.push(self.settings_icon_button(
"Deselect",
"M6 6l12 12 M6 18L18 6",
Some(Message::PanelClearSelection),
)),
)
.push(self.note(subtitle))
.push(self.panel_divider())
.into()
}
pub(super) fn panel_divider(&self) -> Element<'static, Message> {
let p = crate::visuals::Palette::from_resolved(&self.resolved.presented);
widget::container(widget::Space::new().height(1).width(Length::Fill))
.class(crate::visuals::surface(ferese_theme::mix(p.card, p.text, 0.09), 0.))
.into()
}
pub(super) fn item_inspector(
&self,
panel: &Panel,
item: &Item,
destination: Destination,
path: String,
) -> Element<'static, Message> {
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let mut rows = widget::column([])
.spacing(12)
.push(self.inspector_heading(item.kind.label(), "Item settings"));
let item_id = item.id.clone();
rows = rows.push(
widget::column([])
.spacing(8)
.push(self.label("Placement", 13.))
.push(crate::visuals::panel_select(
cosmic::iced::widget::pick_list(
crate::panel_edit::destinations(panel),
Some(destination.clone()),
move |destination| Message::PanelEdit(Action::Move(item_id.clone(), destination)),
)
.font(self.font)
.text_size(13)
.padding([8, 10])
.width(Length::Fill),
palette,
)),
);
rows = rows.push(self.panel_field(
item,
Field::new(format!("{path}.visible"), "Show item", "", Kind::Toggle(true)),
));
rows = rows.push(self.panel_divider());
if matches!(item.kind, ItemKind::Workspaces { .. }) {
rows = rows.push(self.workspace_style_picker(item));
rows = rows.push(self.panel_divider());
}
rows = rows.push(self.panel_field(
item,
Field::new(
format!("{path}.overflow"),
"Overflow",
"Choose when to use the overflow menu.",
Kind::Choice {
default: "auto",
choices: &[("never", "Keep visible"), ("auto", "When needed"), ("always", "Always")],
},
),
));
let representations = item.kind.representations();
if representations.len() > 1 {
let choices: &'static [(&'static str, &'static str)] = match representations {
[Representation::Wide, Representation::Icon] => &[("wide", "Full"), ("icon", "Icon")],
_ => &[("wide", "Full"), ("compact", "Compact"), ("icon", "Icon")],
};
rows = rows.push(self.panel_field(
item,
Field::new(
format!("{path}.representation"),
"Preferred size",
"Adapts to available space.",
Kind::Choice {
default: "wide",
choices,
},
),
));
}
if matches!(item.kind, ItemKind::Battery { .. }) {
rows = rows.push(self.panel_field(
item,
Field::new(
format!("{path}.percentage"),
"Battery percentage",
"",
Kind::Toggle(true),
),
));
}
let group = Zone::ALL
.into_iter()
.flat_map(|zone| &zone.definition(panel).groups)
.find(|group| group.id == destination.group)
.unwrap();
let index = group
.items
.iter()
.position(|candidate| candidate.id == item.id)
.unwrap();
rows.push(self.panel_divider())
.push(self.inspector_actions(
(index > 0).then(|| Message::PanelEdit(Action::Earlier(item.id.clone()))),
(index + 1 < group.items.len()).then(|| Message::PanelEdit(Action::Later(item.id.clone()))),
"Remove item",
Some(Message::PanelEdit(Action::Remove(item.id.clone()))),
))
.into()
}
fn workspace_style_picker(&self, item: &Item) -> Element<'static, Message> {
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let ItemKind::Workspaces { style: selected } = item.kind else {
unreachable!()
};
let mut choices = widget::column([])
.spacing(6)
.push(self.label("Workspace style", 13.))
.push(self.note("A sample of active, occupied and empty workspaces."));
for style in ferese_config::panel::WorkspaceStyle::ALL {
let sample = ferese_theme::workspaces::sample(style, palette, self.font, 22.);
choices = choices.push(
widget::button::custom(
widget::row![self.label(style.label(), 12.).width(Length::Fill), sample,]
.spacing(10)
.align_y(Alignment::Center),
)
.id(format!("workspace-style:{}", style.key()).into())
.name(format!("Workspace style: {}", style.label()))
.padding([8, 10])
.width(Length::Fill)
.class(crate::visuals::panel_button(palette, style == selected))
.on_press_maybe(
(style != selected)
.then(|| Message::PanelEdit(Action::Set(item.id.clone(), "style".into(), style.key().into()))),
),
);
}
choices.into()
}
pub(super) fn group_inspector(&self, panel: &Panel, group: &Group, placement: Zone) -> Element<'static, Message> {
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let id = group.id.clone();
let mut rows = widget::column([])
.spacing(12)
.push(self.inspector_heading(
&crate::panel_controls::group_name(&group.id.0),
"Group arrangement & spacing",
))
.push(
widget::column([])
.spacing(8)
.push(self.label("Placement", 13.))
.push(crate::visuals::panel_select(
cosmic::iced::widget::pick_list(Zone::ALL.to_vec(), Some(placement), move |zone| {
Message::PanelEdit(Action::MoveGroup(id.clone(), zone))
})
.font(self.font)
.text_size(13)
.padding([8, 10])
.width(Length::Fill),
palette,
)),
);
let surface_choices = [
Choice("inherit", "Default"),
Choice("none", "None"),
Choice("inset", "Inset"),
Choice("island", "Island"),
];
let selected_surface = group.surface.map_or(surface_choices[0], |surface| {
*surface_choices.iter().find(|choice| choice.0 == surface.key()).unwrap()
});
let surface_id = group.id.clone();
rows = rows.push(self.label("Surface", 13.)).push(crate::visuals::panel_select(
cosmic::iced::widget::pick_list(surface_choices.to_vec(), Some(selected_surface), move |choice| {
Message::PanelEdit(if choice.0 == "inherit" {
Action::ClearGroupSurface(surface_id.clone())
} else {
Action::SetGroup(surface_id.clone(), "surface".into(), choice.0.into())
})
})
.font(self.font)
.text_size(13)
.padding([8, 10])
.width(Length::Fill),
palette,
));
rows = rows.push(self.panel_divider());
let mut spacing = widget::column([]).spacing(12);
for (name, label, value, maximum) in [
("spacing", "Item spacing", group.spacing, 64.),
("padding_vertical", "Vertical padding", f32::from(group.padding[0]), 32.),
(
"padding_horizontal",
"Horizontal padding",
f32::from(group.padding[1]),
32.,
),
("island_padding", "Island padding", group.island_padding, 32.),
] {
if panel.resolved_surface(group) != ferese_config::panel::GroupSurface::Island && name == "island_padding" {
continue;
}
let change = |value: f32| {
let value = if name.starts_with("padding_") {
serde_json::json!(value as u16)
} else {
serde_json::json!(value)
};
Message::PanelEdit(Action::SetGroup(group.id.clone(), name.into(), value))
};
spacing = spacing.push(
widget::row([])
.spacing(8)
.align_y(Alignment::Center)
.push(self.label(label, 13.).width(Length::Fill))
.push(
widget::row([])
.spacing(4)
.align_y(Alignment::Center)
.push(self.settings_icon_button(
&format!("Decrease {label}"),
"M5 12h14",
(value > 0.).then(|| change((value - 1.).max(0.))),
))
.push(
self.label(format!("{value} px"), 12.)
.width(48)
.align_x(Alignment::Center),
)
.push(self.settings_icon_button(
&format!("Increase {label}"),
"M12 5v14 M5 12h14",
(value < maximum).then(|| change((value + 1.).min(maximum))),
)),
),
);
}
rows = rows.push(spacing).push(self.panel_divider());
let index = placement
.definition(panel)
.groups
.iter()
.position(|candidate| candidate.id == group.id)
.unwrap();
rows = rows.push(
self.inspector_actions(
(index > 0).then(|| Message::PanelEdit(Action::EarlierGroup(group.id.clone()))),
(index + 1 < placement.definition(panel).groups.len())
.then(|| Message::PanelEdit(Action::LaterGroup(group.id.clone()))),
"Remove group",
group
.items
.is_empty()
.then(|| Message::PanelEdit(Action::RemoveGroup(group.id.clone()))),
),
);
if !group.items.is_empty() {
rows = rows.push(self.note("Move or remove this group’s items before removing the group."));
}
rows.into()
}
fn inspector_actions(
&self,
earlier: Option<Message>,
later: Option<Message>,
remove_label: &str,
remove: Option<Message>,
) -> Element<'static, Message> {
widget::row([])
.spacing(8)
.push(self.settings_icon_button("Up", "M6 15l6-6 6 6", earlier))
.push(self.settings_icon_button("Down", "M6 9l6 6 6-6", later))
.push(self.settings_icon_button(
remove_label,
"M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7",
remove,
))
.into()
}
fn panel_field(&self, item: &Item, field: Field) -> Element<'static, Message> {
let p = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let id = item.id.clone();
let name = field.path.rsplit('.').next().unwrap().to_owned();
let values = serde_json::to_value(item).expect("serializable panel item");
match field.kind {
Kind::Choice { default, choices } => {
let value = values.get(&name).and_then(serde_json::Value::as_str).unwrap_or(default);
if name == "overflow" {
let selected = choices
.iter()
.find(|(key, _)| *key == value)
.map(|&(key, label)| Choice(key, label));
return widget::column([])
.spacing(8)
.push(self.label(field.label, 13.))
.push(crate::visuals::panel_select(
cosmic::iced::widget::pick_list(
choices
.iter()
.map(|&(key, label)| Choice(key, label))
.collect::<Vec<_>>(),
selected,
move |choice| {
Message::PanelEdit(Action::Set(id.clone(), name.clone(), choice.0.into()))
},
)
.font(self.font)
.text_size(13)
.padding([8, 10])
.width(Length::Fill),
p,
))
.push(self.note(&field.description))
.into();
}
let options = widget::row(choices.iter().map(|&(key, label)| {
widget::button::custom(self.label(label, 12.))
.name(format!("{}: {label}", field.label))
.padding([8, 4])
.width(Length::Fill)
.class(crate::visuals::panel_button(p, value == key))
.on_press(Message::PanelEdit(Action::Set(id.clone(), name.clone(), key.into())))
.into()
}))
.spacing(2)
.width(Length::Fill);
let mut rows = widget::column([]).spacing(8).push(self.label(field.label, 13.)).push(
widget::container(options)
.padding(3)
.class(crate::visuals::surface(p.sidebar, 9.)),
);
if !field.description.is_empty() {
rows = rows.push(self.note(&field.description));
}
rows.into()
}
Kind::Toggle(default) => {
let enabled = values
.get(&name)
.and_then(serde_json::Value::as_bool)
.unwrap_or(default);
widget::row([])
.spacing(12)
.align_y(Alignment::Center)
.push(self.label(field.label.clone(), 13.).width(Length::Fill))
.push(
ferese_theme::controls::switch(enabled, p)
.name(format!("{}: {}", field.label, if enabled { "on" } else { "off" }))
.on_press(Message::PanelEdit(Action::Set(id, name, (!enabled).into()))),
)
.into()
}
_ => unreachable!("panel inspector fields are choices or toggles"),
}
}
}
+865
View File
@@ -0,0 +1,865 @@
use cosmic::iced::{Alignment, Length};
use cosmic::widget::{button, column, container, row};
use ferese_config::panel::layout::{Placement, Resolution};
use ferese_config::panel::{Availability, Group, Item, ItemId, ItemKind, Panel, Representation, Zone};
use ferese_theme::panel::{Sample, Spacing, frame};
use crate::{App, Element, Message, panel_controls, visuals};
const SAMPLE_AVAILABILITY: Availability = Availability {
media: true,
network: true,
audio: true,
notifications: true,
battery: true,
external_display: true,
};
fn available(item: &Item) -> bool {
item.available(SAMPLE_AVAILABILITY)
}
impl App {
pub(super) fn panel_preview(&self) -> Element<'_, Message> {
let panel = match self.preview_panel() {
Ok(panel) => panel,
Err(error) => return self.note(&format!("Panel preview unavailable: {error}")),
};
let mut rows = column([])
.spacing(12)
.push(
row([])
.align_y(Alignment::Center)
.push(self.label("Composition preview", 14.).width(Length::Fill))
.push(self.note("Sample content")),
)
.push(
container(self.panel_preview_frame(panel.clone()))
.padding(cosmic::iced::Padding {
top: if panel.edge == ferese_config::panel::Edge::Top {
10.
} else {
26.
},
bottom: if panel.edge == ferese_config::panel::Edge::Top {
26.
} else {
10.
},
left: 12.,
right: 12.,
})
.width(Length::Fill)
.class(visuals::surface(
visuals::Palette::from_resolved(&self.resolved.presented).sidebar,
10.,
)),
);
if self.panel_preview_overflow {
let mut items = column([]).spacing(2);
for id in &self.panel_preview_resolution.overflow {
if let Some(item) = panel.item(id).filter(|item| available(item)) {
items = items.push(self.settings_button(
&format!("{} · {}", item.kind.label(), item.id.0),
"M6 9l6 6 6-6",
Some(Message::PanelSelect(item.id.clone())),
self.panel_selection.as_ref() == Some(id),
));
}
}
if !self.panel_preview_resolution.overflow.is_empty() {
rows = rows.push(container(crate::scrollable(items).height(Length::Shrink)).max_height(160.));
}
}
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
container(rows)
.id("panel-composition-preview")
.padding(16)
.width(Length::Fill)
.class(visuals::surface(palette.card, 14.))
.into()
}
pub(super) fn preview_panel(&self) -> Result<Panel, String> {
let panels = match self.draft.item("panels") {
Some(panels) => panels.clone(),
None => {
let crate::store::Edit::Set(_, panels) = panel_controls::initialize(&self.draft)? else {
unreachable!()
};
panels
}
};
serde_json::from_value::<Vec<Panel>>(panels)
.map_err(|error| error.to_string())?
.into_iter()
.next()
.ok_or_else(|| "No panel configured.".into())
}
fn panel_preview_frame(&self, panel: Panel) -> Element<'_, Message> {
let mut samples = Vec::new();
for zone in [&panel.center, &panel.start, &panel.end] {
for item in zone
.groups
.iter()
.flat_map(|group| &group.items)
.filter(|item| available(item))
{
for representation in item.representations() {
samples.push(Sample {
id: item.id.clone(),
representation,
minimum: match item.kind {
ItemKind::Workspaces { .. } => Some(24.),
ItemKind::Media => Some(112.),
_ => None,
},
view: self.preview_control(item, representation, None),
});
}
}
}
let overflow = panel.overflow_group();
samples.push(Sample {
id: ItemId("_overflow".into()),
representation: Representation::Icon,
minimum: None,
view: self.preview_control(&overflow.items[0], Representation::Icon, None),
});
let overflow_width = 2. * f32::from(overflow.padding[1])
+ if panel.resolved_surface(&overflow) == ferese_config::panel::GroupSurface::Island {
2. * overflow.island_padding
} else {
0.
};
let continuous = panel.surface == ferese_config::panel::PanelSurface::Solid;
let corners = panel
.resolved_radius(self.resolved.presented.tokens.geometry.shell_radius as f32)
.at_edge(panel.edge, panel.geometry.edge_margin == 0)
.0;
let opacity = self.resolved.presented.panel_opacity(panel.background_opacity);
let measured_panel = panel.clone();
let content = frame(
std::borrow::Cow::Owned(measured_panel),
samples,
Spacing {
overflow_decoration: overflow_width,
zone_gap: 32.,
},
self.panel_preview_resolution.clone(),
move |resolution| {
let start = self.preview_zone(&panel, &panel.start, resolution);
let center = self.preview_zone(&panel, &panel.center, resolution);
let mut end = self.preview_zone(&panel, &panel.end, resolution);
if !resolution.overflow.is_empty() {
let mut trigger = Resolution::default();
trigger.items.insert(
ItemId("_overflow".into()),
Placement::Visible {
representation: Representation::Icon,
width: resolution.overflow_trigger_width,
},
);
let trigger = self.preview_zone(
&panel,
&Zone {
groups: vec![overflow.clone()],
spacing: 0.,
},
&trigger,
);
let has_end = panel.end.groups.iter().flat_map(|group| &group.items)
.any(|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.));
end = row([])
.spacing(if has_end { panel.end.spacing } else { 0. })
.align_y(Alignment::Center)
.push(trigger)
.push(end)
.into();
}
cosmic::iced::widget::stack![
row([])
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center)
.push(start)
.push(cosmic::widget::Space::new().width(Length::Fill))
.push(end),
container(center).center_x(Length::Fill).center_y(Length::Fill),
]
.into()
},
Message::PanelPreviewResolved,
);
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
container(content)
.width(Length::Fill)
.height(48)
.clip(true)
.class(cosmic::theme::Container::custom(move |_| {
let mut style = ferese_theme::controls::surface_appearance(
cosmic::iced::Color {
a: opacity,
..palette.card
},
0.,
);
style.border.radius = corners.into();
if !continuous {
style.background = None;
}
style
}))
.into()
}
fn preview_zone(&self, panel: &Panel, zone: &Zone, resolution: &Resolution) -> Element<'static, Message> {
let mut groups = row([]).spacing(zone.spacing).align_y(Alignment::Center);
for group in &zone.groups {
let mut controls = row([]).align_y(Alignment::Center);
let mut count = 0;
for item in &group.items {
let Some(Placement::Visible { representation, width }) = resolution.items.get(&item.id) else {
continue;
};
if *width <= 0. {
continue;
}
if count > 0 {
controls =
controls.push(cosmic::widget::Space::new().width(item.gap_before.unwrap_or(group.spacing)));
}
controls = controls.push(self.preview_control(item, *representation, Some(*width)));
count += 1;
}
if count > 0 {
groups = groups.push(self.preview_group(panel, group, controls.into()));
}
}
groups.into()
}
fn preview_group(
&self,
panel: &Panel,
group: &Group,
controls: Element<'static, Message>,
) -> Element<'static, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let surface = panel.resolved_surface(group);
let radius = panel
.resolved_radius(self.resolved.presented.tokens.geometry.shell_radius as f32)
.at_edge(
panel.edge,
surface == ferese_config::panel::GroupSurface::Island && panel.geometry.edge_margin == 0,
)
.0;
let opacity = self.resolved.presented.panel_opacity(panel.background_opacity);
let border = panel.border;
let content: Element<'static, Message> = container(controls)
.padding(group.padding)
.class(cosmic::theme::Container::custom(move |_| {
ferese_theme::panel::group_border(palette.muted.scale_alpha(0.4), radius, border)
}))
.into();
if surface == ferese_config::panel::GroupSurface::None {
return content;
}
let parent_solid = panel.surface == ferese_config::panel::PanelSurface::Solid;
container(content)
.padding(if surface == ferese_config::panel::GroupSurface::Island {
[2., group.island_padding]
} else {
[0., 0.]
})
.class(cosmic::theme::Container::custom(move |_| {
let mut style = ferese_theme::controls::surface_appearance(
cosmic::iced::Color {
a: if parent_solid { opacity * 0.08 } else { opacity },
..if parent_solid { palette.text } else { palette.card }
},
0.,
);
style.border.radius = radius.into();
style
}))
.into()
}
fn preview_control(
&self,
item: &Item,
representation: Representation,
width: Option<f32>,
) -> Element<'static, Message> {
use ferese_theme::icons;
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
if let ItemKind::Workspaces { style } = item.kind {
let sample = ferese_theme::workspaces::sample(style, palette, self.font, 20.);
let control = button::custom(container(sample).id(format!("preview-icon:{}", item.id.0)))
.name("Edit Workspaces")
.padding([4, 2])
.height(28)
.on_press(Message::PanelSelect(item.id.clone()))
.class(preview_button(palette, self.panel_selection.as_ref() == Some(&item.id)));
return container(control)
.id(format!("preview-item:{}", item.id.0))
.width(width.map_or(Length::Shrink, Length::Fixed))
.clip(true)
.into();
}
let source = match item.kind {
ItemKind::Overview => icons::FERESE,
ItemKind::Workspaces { .. } => icons::OVERVIEW,
ItemKind::FocusedWindow => icons::DISPLAY,
ItemKind::Media => icons::MEDIA,
ItemKind::QuickSettings => icons::CONTROL_CENTER,
ItemKind::Network => icons::WIFI_FULL,
ItemKind::Audio => icons::VOLUME_HIGH,
ItemKind::Recording => icons::RECORD,
ItemKind::Notifications => icons::NOTIFICATIONS,
ItemKind::Battery { .. } => icons::BATTERY_75,
ItemKind::Clock => icons::CALENDAR,
ItemKind::DisplayMode => icons::DISPLAY,
ItemKind::Overflow => icons::CHEVRON_DOWN,
};
let label = match (item.kind, representation) {
(ItemKind::Workspaces { .. }, _) => "1 2 3",
(ItemKind::FocusedWindow, _) => "Settings",
(ItemKind::Media, Representation::Wide) => "Song title · Artist ▷",
(ItemKind::Media, Representation::Compact) => "Song title",
(ItemKind::Battery { percentage: true }, Representation::Wide) => "75%",
(ItemKind::Clock, Representation::Wide) => "Wed, 7 Oct · 14:35",
(ItemKind::Clock, Representation::Compact) => "14:35",
_ => "",
};
let mut content = row([])
.height(Length::Fill)
.spacing(5)
.align_y(Alignment::Center)
.push(container(icons::tinted(source, 16, palette.text)).id(format!("preview-icon:{}", item.id.0)));
if !label.is_empty() {
content = content.push(
self.label(label, 12.)
.wrapping(cosmic::iced::widget::text::Wrapping::None),
);
}
let action = if item.kind == ItemKind::Overflow {
Message::PanelPreviewOverflow
} else {
Message::PanelSelect(item.id.clone())
};
let control: Element<'static, Message> = button::custom(content)
.name(format!("Edit {}", item.id.0))
.padding([4, 7])
.height(28)
.on_press(action)
.class(preview_button(
palette,
if item.kind == ItemKind::Overflow {
self.panel_preview_overflow
} else {
self.panel_selection.as_ref() == Some(&item.id)
},
))
.into();
container(control)
.id(format!("preview-item:{}", item.id.0))
.width(width.map_or(Length::Shrink, Length::Fixed))
.clip(true)
.into()
}
}
/// Preview controls sit on a shared surface. Only interaction paints a background;
/// keep their hit area and border geometry identical in every state.
fn preview_button(palette: visuals::Palette, selected: bool) -> cosmic::theme::Button {
let paint = move |hovered: bool, pressed: bool, focused: bool| {
let background = if selected {
Some(
palette
.accent
.scale_alpha(if hovered || pressed { 0.26 } else { 0.16 })
.into(),
)
} else if pressed {
Some(palette.text.scale_alpha(0.14).into())
} else if hovered || focused {
Some(palette.text.scale_alpha(0.08).into())
} else {
None
};
cosmic::widget::button::Style {
shape: Some(cosmic::iced::border::Shape::Continuous),
background,
text_color: Some(palette.text),
icon_color: Some(palette.text),
border_radius: 6.into(),
border_width: 1.,
border_color: if focused {
palette.accent
} else if selected {
palette.accent.scale_alpha(0.6)
} else {
cosmic::iced::Color::TRANSPARENT
},
outline: None,
outline_width: 0.,
..Default::default()
}
};
cosmic::theme::Button::Custom {
active: Box::new(move |focused, _| paint(false, false, focused)),
hovered: Box::new(move |focused, _| paint(true, false, focused)),
pressed: Box::new(move |focused, _| paint(true, true, focused)),
disabled: Box::new(move |_| paint(false, false, false)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::Application;
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, renderer::Headless, widget};
use cosmic::iced::{Event, Font, Pixels, Point, Rectangle, Size};
#[derive(Default)]
struct Bounds {
targets: Vec<(widget::Id, ItemId)>,
items: Vec<(ItemId, Rectangle)>,
}
impl widget::Operation for Bounds {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if let Some((_, item)) = self.targets.iter().find(|(target, _)| Some(target) == id) {
self.items.push((item.clone(), bounds));
}
}
}
#[test]
fn preview_measures_adapts_and_targets_exact_instances_without_writing_config() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let app = App::init(
crate::Core::default(),
(
"/unused/preview-test.kdl".into(),
crate::store::Snapshot::parse(String::new()),
None,
),
)
.0;
let panel = app.preview_panel().unwrap();
let source = app.draft.source.clone();
let mut tree = None;
for width in [2400., 400., 180., 2400.] {
let mut view = app.panel_preview_frame(panel.clone());
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
tree.diff(view.as_widget_mut());
let viewport = Rectangle::with_size(Size::new(width, 80.));
let node = view
.as_widget_mut()
.layout(tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
let mut messages = Vec::new();
for _ in 0..3 {
view.as_widget_mut().update(
tree,
&Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())),
Layout::new(&node),
mouse::Cursor::Unavailable,
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&viewport,
);
}
assert_eq!(messages.len(), 1, "unchanged allocation is published once");
let Message::PanelPreviewResolved(resolution) = &messages[0] else {
panic!("expected resolution")
};
assert!(resolution.fits(width));
let clock = ItemId("clock".into());
if width == 2400. {
assert!(matches!(
resolution.items.get(&clock),
Some(Placement::Visible {
representation: Representation::Wide,
..
})
));
assert!(resolution.overflow.is_empty());
} else {
assert!(!resolution.overflow.is_empty());
}
let mut bounds = Bounds {
targets: resolution
.items
.keys()
.cloned()
.chain(std::iter::once(ItemId("_overflow".into())))
.map(|id| (widget::Id::new(format!("preview-item:{}", id.0)), id))
.collect(),
..Default::default()
};
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut bounds);
for (id, bounds) in &bounds.items {
let expected = if id.0 == "_overflow" {
resolution.overflow_trigger_width
} else {
let Placement::Visible { width, .. } = resolution.items[id] else {
panic!("overflowed item was rendered")
};
width
};
assert!(
(bounds.width - expected).abs() < 0.1,
"{id:?}: {bounds:?}, expected {expected}"
);
assert!(
bounds.x >= -0.1 && bounds.x + bounds.width <= width + 0.1,
"{bounds:?} outside {width}"
);
}
if width == 2400. {
let target = bounds.items.iter().find(|(id, _)| id == &clock).unwrap().1.center();
for event in [
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
view.as_widget_mut().update(
tree,
&Event::Mouse(event),
Layout::new(&node),
mouse::Cursor::Available(Point::new(target.x, target.y)),
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&viewport,
);
}
assert_eq!(
messages
.iter()
.filter(|message| matches!(message, Message::PanelSelect(id) if id == &clock))
.count(),
1
);
}
}
assert_eq!(app.draft.source, source);
assert!(app.pending.is_empty());
}
#[test]
fn preview_title_stays_centered_and_separate_from_asymmetric_side_groups() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let app = App::init(
crate::Core::default(),
(
"/unused/preview-centering.kdl".into(),
crate::store::Snapshot::parse(String::new()),
None,
),
)
.0;
for background in [
ferese_config::PanelPreset::Continuous,
ferese_config::PanelPreset::Islands,
] {
let mut panel = Panel::from_preset(ferese_config::PanelPreset::Islands);
panel.apply_preset(background);
// Match a three-group layout with media in navigation and a larger status group.
let media = panel.end.groups[0].items.remove(0);
panel.start.groups[0].items.push(media);
let title = &panel.center.groups[0].items[0];
let mut sample = app.preview_control(title, Representation::Wide, None);
let mut sample_tree = widget::Tree::new(&sample);
let natural = sample
.as_widget_mut()
.layout(
&mut sample_tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(2400., 80.)),
)
.size()
.width;
for width in [560., 668., 800., 1200.] {
let mut view = app.panel_preview_frame(panel.clone());
let mut tree = widget::Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, 80.)),
);
let mut bounds = Bounds {
targets: [&panel.start, &panel.center, &panel.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.map(|item| (widget::Id::new(format!("preview-item:{}", item.id.0)), item.id.clone()))
.chain(std::iter::once((
widget::Id::new("preview-item:_overflow"),
ItemId("_overflow".into()),
)))
.collect(),
..Default::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut bounds);
let center = bounds
.items
.iter()
.find(|(id, _)| id == &title.id)
.expect("sample title remains visible")
.1;
assert!((center.center_x() - width / 2.).abs() < 0.1, "{center:?} at {width}");
assert!((center.width - natural).abs() < 0.1, "sample title must not be clipped");
for (id, side) in &bounds.items {
if id == &title.id {
continue;
}
let gap = if side.center_x() < center.center_x() {
center.x - side.x - side.width
} else {
side.x - center.x - center.width
};
assert!(
gap >= 31.9,
"{background:?} at {width}: {} crowds title ({gap}px)",
id.0
);
}
}
}
}
#[test]
fn preview_icons_are_centered_in_their_hit_areas_and_across_groups() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let mut app = App::init(
crate::Core::default(),
(
"/unused/alignment.kdl".into(),
crate::store::Snapshot::parse(String::new()),
None,
),
)
.0;
for background in [
ferese_config::PanelPreset::Continuous,
ferese_config::PanelPreset::Islands,
] {
let panel = Panel::from_preset(background);
let ids: Vec<_> = [&panel.start, &panel.center, &panel.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.map(|item| item.id.clone())
.chain(std::iter::once(ItemId("_overflow".into())))
.collect();
for width in [180., 400., 2400.] {
for selected in [None, Some(ItemId("battery".into()))] {
app.panel_selection = selected;
let mut view = app.panel_preview_frame(panel.clone());
let mut tree = widget::Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, 80.)),
);
let mut controls = Bounds {
targets: ids
.iter()
.map(|id| (widget::Id::new(format!("preview-item:{}", id.0)), id.clone()))
.collect(),
..Default::default()
};
let mut icons = Bounds {
targets: ids
.iter()
.map(|id| (widget::Id::new(format!("preview-icon:{}", id.0)), id.clone()))
.collect(),
..Default::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut controls);
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut icons);
assert!(!controls.items.is_empty());
assert_eq!(controls.items.len(), icons.items.len());
for (id, icon) in &icons.items {
let control = controls.items.iter().find(|(item, _)| item == id).unwrap().1;
assert!(
(icon.center_y() - control.center_y()).abs() < 0.01,
"{background:?} at {width}: {} icon {icon:?} is off-center in {control:?}",
id.0
);
assert!(
(icon.center_y() - node.size().height / 2.).abs() < 0.01,
"{} is not on the panel centerline",
id.0
);
}
}
}
}
}
#[test]
fn preview_hover_and_selection_stay_inside_the_control_and_idle_is_transparent() {
use cosmic::iced::Color;
use cosmic::iced::advanced::renderer::{self, Renderer as _};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let mut app = App::init(
crate::Core::default(),
(
"/unused/hover.kdl".into(),
crate::store::Snapshot::parse(String::new()),
None,
),
)
.0;
let item = Item::new("battery", ItemKind::Battery { percentage: false });
let viewport = Rectangle::with_size(Size::new(96., 48.));
let backdrop = Color::from_rgb8(35, 47, 61);
let theme = app.native_palette.native_theme();
let mut baseline: Option<Vec<u8>> = None;
let mut baseline_bounds = None;
for state in ["idle", "hovered", "pressed", "selected"] {
app.panel_selection = (state == "selected").then(|| item.id.clone());
let mut view: Element<'_, Message> = container(app.preview_control(&item, Representation::Icon, None))
.center_x(Length::Fill)
.center_y(Length::Fill)
.into();
let mut tree = widget::Tree::new(&view);
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
let mut bounds = Bounds {
targets: vec![(widget::Id::new("preview-item:battery"), item.id.clone())],
..Default::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut bounds);
let target = bounds.items[0].1;
if let Some(baseline) = baseline_bounds {
assert_eq!(target, baseline);
} else {
baseline_bounds = Some(target);
}
let cursor = if state == "hovered" || state == "pressed" {
mouse::Cursor::Available(target.center())
} else {
mouse::Cursor::Unavailable
};
if state == "pressed" {
view.as_widget_mut().update(
&mut tree,
&Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Layout::new(&node),
cursor,
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut Vec::new()),
&viewport,
);
}
renderer.reset(viewport);
view.as_widget().draw(
&tree,
&mut renderer,
&theme,
&renderer::Style {
text_color: app.native_palette.text,
icon_color: app.native_palette.text,
scale_factor: 1.,
},
Layout::new(&node),
cursor,
&viewport,
);
let pixels = Headless::screenshot(&mut renderer, Size::new(96, 48), 1., backdrop);
if let Some(baseline) = &baseline {
let mut changed = 0;
for (index, (before, after)) in baseline.chunks_exact(4).zip(pixels.chunks_exact(4)).enumerate() {
if before != after {
changed += 1;
assert!(
target.contains(Point::new((index % 96) as f32 + 0.5, (index / 96) as f32 + 0.5)),
"{state} paints outside the hit area"
);
}
}
assert!(changed > 40, "{state} has no visible feedback");
} else {
// Above the glyph but inside the button: untouched backdrop, not
// an opaque rectangle that hides the panel's chosen opacity.
let index = ((target.y as usize + 2) * 96 + target.center_x() as usize) * 4;
assert_eq!(&pixels[index..index + 4], &pixels[..4]);
baseline = Some(pixels);
}
}
}
#[test]
fn invalid_source_is_reported_before_preview_layout() {
let app = App::init(
crate::Core::default(),
(
"/unused/preview-test.kdl".into(),
crate::store::Snapshot::parse("panel main { group-surface unknown; }".into()),
None,
),
)
.0;
assert!(app.error.is_some());
assert!(app.preview_panel().is_ok());
let _ = app.panel_preview();
assert!(app.pending.is_empty());
}
}
+67 -48
View File
@@ -17,6 +17,53 @@ pub enum Page {
} }
impl Page { impl Page {
pub const NAVIGATION: [(&'static str, &'static [Self]); 3] = [
(
"Desktop",
&[Self::Appearance, Self::Wallpaper, Self::Desktop, Self::Bar],
),
("Interaction", &[Self::Windows, Self::Keyboard, Self::Accessibility]),
(
"System",
&[
Self::Displays,
Self::Connections,
Self::Notifications,
Self::LockScreen,
Self::Startup,
],
),
];
/// Subpages share one entry in navigation and search.
pub fn navigation_page(self) -> Self {
match self {
Self::Motion => Self::Windows,
Self::Shortcuts => Self::Keyboard,
_ => self,
}
}
pub fn subpages(self) -> &'static [(Self, &'static str)] {
match self.navigation_page() {
Self::Windows => &[(Self::Windows, "Windows"), (Self::Motion, "Motion")],
Self::Keyboard => &[(Self::Keyboard, "Keyboard & mouse"), (Self::Shortcuts, "Shortcuts")],
_ => &[],
}
}
pub fn search_destination(self, query: &str) -> Self {
if !self.matches_own(query) {
for &(page, _) in self.subpages() {
if page.matches_own(query) {
return page;
}
}
}
self
}
#[cfg(test)]
pub const ALL: [Self; 14] = [ pub const ALL: [Self; 14] = [
Self::Appearance, Self::Appearance,
Self::Accessibility, Self::Accessibility,
@@ -40,7 +87,7 @@ impl Page {
Self::Accessibility => "Accessibility", Self::Accessibility => "Accessibility",
Self::Wallpaper => "Wallpaper", Self::Wallpaper => "Wallpaper",
Self::Desktop => "Desktop widgets", Self::Desktop => "Desktop widgets",
Self::Bar => "Menu bar", Self::Bar => "Panel & Shell",
Self::Notifications => "Notifications", Self::Notifications => "Notifications",
Self::LockScreen => "Lock screen", Self::LockScreen => "Lock screen",
Self::Windows => "Windows", Self::Windows => "Windows",
@@ -58,14 +105,14 @@ impl Page {
Self::Appearance => "A desktop that feels like yours.", Self::Appearance => "A desktop that feels like yours.",
Self::Accessibility => "Make the desktop easier to see and use.", Self::Accessibility => "Make the desktop easier to see and use.",
Self::Wallpaper => "Set the scene for your workspace.", Self::Wallpaper => "Set the scene for your workspace.",
Self::Desktop => "A clock that feels at home on your wallpaper.", Self::Desktop => "Clock and sticky notes on your desktop.",
Self::Bar => "Everything you need, within reach.", Self::Bar => "Arrange panel items and groups.",
Self::Notifications => "Stay informed on your terms.", Self::Notifications => "Stay informed on your terms.",
Self::LockScreen => "Your desktop, safely put away.", Self::LockScreen => "Your desktop, safely put away.",
Self::Windows => "Make room for the way you work.", Self::Windows => "Window layouts, spacing and focus effects.",
Self::Motion => "Find your rhythm.", Self::Motion => "Animations and transition speed.",
Self::Keyboard => "Fine-tune the everyday details.", Self::Keyboard => "Keyboard layouts, pointer behavior and touchpad gestures.",
Self::Shortcuts => "Your most-used actions, a keystroke away.", Self::Shortcuts => "Keyboard shortcuts and swipe actions.",
Self::Startup => "Ready when you sign in.", Self::Startup => "Ready when you sign in.",
Self::Displays => "A place for every screen.", Self::Displays => "A place for every screen.",
Self::Connections => "Wi-Fi networks and Bluetooth devices.", Self::Connections => "Wi-Fi networks and Bluetooth devices.",
@@ -98,6 +145,10 @@ impl Page {
} }
pub fn matches(self, query: &str) -> bool { pub fn matches(self, query: &str) -> bool {
self.matches_own(query) || self.subpages().iter().any(|(page, _)| page.matches_own(query))
}
fn matches_own(self, query: &str) -> bool {
let query = query.to_lowercase(); let query = query.to_lowercase();
self.title().to_lowercase().contains(&query) self.title().to_lowercase().contains(&query)
|| self.subtitle().to_lowercase().contains(&query) || self.subtitle().to_lowercase().contains(&query)
@@ -853,34 +904,8 @@ pub fn fields(page: Page) -> Vec<Field> {
), ),
], ],
Page::Bar => vec![ Page::Bar => vec![
choice(
"status.bar_layout",
"Layout",
"Use one continuous background or separate islands around the controls.",
"continuous",
&[("continuous", "Continuous"), ("islands", "Islands")],
),
range( range(
"status.bar_island_padding", "panels.0.geometry.height",
"Island side padding",
"Space on each side between the island background and its bordered controls.",
RangeSpec {
default: f64::from(ferese_config::default_bar_island_padding()),
min: 0.0,
max: 32.0,
step: 1.0,
suffix: " px",
integer: false,
},
),
toggle(
"status.window_title",
"Focused window title",
"Show the focused window title in the center of the bar when space allows.",
true,
),
range(
"theme.geometry.top_bar_height",
"Height", "Height",
"Keeps icon and text sizes unchanged.", "Keeps icon and text sizes unchanged.",
RangeSpec { RangeSpec {
@@ -893,9 +918,9 @@ pub fn fields(page: Page) -> Vec<Field> {
}, },
), ),
range( range(
"theme.geometry.top_bar_margin_top", "panels.0.geometry.edge_margin",
"Top margin", "Edge margin",
"Set to zero for an edge-to-edge bar.", "Space from the selected screen edge. Set to zero for a flush panel.",
RangeSpec { RangeSpec {
default: 0.0, default: 0.0,
min: 0.0, min: 0.0,
@@ -906,22 +931,22 @@ pub fn fields(page: Page) -> Vec<Field> {
}, },
), ),
range( range(
"theme.geometry.top_bar_margin_horizontal", "panels.0.geometry.side_margins",
"Side margins", "Side margins",
"Space between the bar and the display edges.", "Inset from both edges; stops before extra controls overflow.",
RangeSpec { RangeSpec {
default: 0.0, default: 0.0,
min: 0.0, min: 0.0,
max: 32.0, max: 4096.0,
step: 1.0, step: 1.0,
suffix: " px", suffix: " px",
integer: true, integer: true,
}, },
), ),
range( range(
"theme.geometry.top_bar_window_gap", "panels.0.geometry.window_clearance",
"Window clearance", "Window clearance",
"Space below the menu bar.", "Space between the panel and application windows.",
RangeSpec { RangeSpec {
default: 0.0, default: 0.0,
min: 0.0, min: 0.0,
@@ -931,12 +956,6 @@ pub fn fields(page: Page) -> Vec<Field> {
integer: true, integer: true,
}, },
), ),
toggle(
"status.battery_percentage",
"Battery percentage",
"Show the remaining charge beside the battery icon.",
true,
),
], ],
Page::Windows => vec![ Page::Windows => vec![
toggle( toggle(
+748 -8
View File
@@ -33,9 +33,121 @@ impl Operation for ScrollPosition {
} }
} }
#[test]
fn panel_preview_stays_visible_while_the_items_editor_scrolls() {
#[derive(Default)]
struct Bounds {
preview: Option<Rectangle>,
viewport: Option<Rectangle>,
}
impl Operation for Bounds {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("panel-composition-preview")) {
self.preview = Some(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
_: Vector,
_: &mut dyn cosmic::iced::advanced::widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("settings-content")) {
self.viewport = Some(bounds);
}
}
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let mut app = App::init(
Core::default(),
("/unused/editor.kdl".into(), Snapshot::parse(String::new()), None),
)
.0;
app.page = Page::Bar;
app.panel_page = panel_controls::PanelPage::Arrange;
app.draft
.edit(&panel_controls::initialize(&app.draft).unwrap())
.unwrap();
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(740., 650.)),
);
let mut before = Bounds::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut before);
let mut scroll = cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::AbsoluteOffset { x: None, y: Some(200.) },
);
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut scroll);
let mut position = ScrollPosition::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut position);
assert!(position.offset > 0. && position.content_height > position.viewport_height);
let mut after = Bounds::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut after);
assert_eq!(before.preview, after.preview);
let preview = after.preview.unwrap();
let viewport = after.viewport.unwrap();
assert!(preview.y + preview.height <= viewport.y);
assert!(preview.height > 0. && viewport.height > 0.);
}
#[test] #[test]
#[ignore = "release scroll/render sample; requires headless renderer backends"] #[ignore = "release scroll/render sample; requires headless renderer backends"]
fn appearance_scroll_preserves_progress_and_settles_visibility() { fn appearance_scroll_preserves_progress_and_settles_visibility() {
#[derive(Default)]
struct Labels {
labels: Vec<Rectangle>,
viewport: Option<Rectangle>,
translation: Vector,
}
impl Operation for Labels {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
if matches!(
text,
"Shell corner radius" | "Shell opacity" | "Background blur" | "Interface font" | "Focus border"
) {
self.labels.push(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
translation: Vector,
_: &mut dyn cosmic::iced::advanced::widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("settings-content")) {
self.viewport = Some(bounds);
self.translation = translation;
}
}
}
let runtime = tokio::runtime::Builder::new_current_thread() let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build() .build()
@@ -66,6 +178,7 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
let mut previous = 0.; let mut previous = 0.;
let mut render_times = Vec::new(); let mut render_times = Vec::new();
let mut visibility_messages = 0; let mut visibility_messages = 0;
let mut checked_labels = 0;
for frame in 0..60 { for frame in 0..60 {
let mut messages = Vec::new(); let mut messages = Vec::new();
@@ -84,7 +197,7 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
Event::Mouse(mouse::Event::WheelScrolled { Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { delta: mouse::ScrollDelta::Pixels {
x: 0., x: 0.,
y: if frame < 28 { -12. } else { 12. }, y: if frame < 28 { -48. } else { 48. },
}, },
}) })
}; };
@@ -147,17 +260,39 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
cursor, cursor,
&bounds, &bounds,
); );
drop(view); let mut labels = Labels::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut labels);
let started = std::time::Instant::now(); let started = std::time::Instant::now();
std::hint::black_box(Headless::screenshot( let pixels = Headless::screenshot(&mut renderer, physical, scale, theme.cosmic().bg_color().into());
&mut renderer,
physical,
scale,
theme.cosmic().bg_color().into(),
));
if (4..52).contains(&frame) { if (4..52).contains(&frame) {
render_times.push(started.elapsed().as_micros()); render_times.push(started.elapsed().as_micros());
} }
if let Some(viewport) = labels.viewport {
for bounds in labels.labels {
let bounds = bounds - labels.translation;
if bounds.y < viewport.y + 2. || bounds.y + bounds.height > viewport.y + viewport.height - 2. {
continue;
}
let mut ink = 0;
for y in (bounds.y * scale).ceil() as u32..((bounds.y + bounds.height) * scale).floor() as u32 {
for x in
(bounds.x * scale).ceil() as u32..((bounds.x + bounds.width) * scale).floor() as u32
{
let index = ((y * physical.width + x) * 4) as usize;
if pixels[index..index + 3].iter().any(|channel| *channel > 140) {
ink += 1;
}
}
}
assert!(
ink > 12,
"missing field text: backend={backend} split={split} frame={frame} bounds={bounds:?}"
);
checked_labels += 1;
}
}
drop(view);
for message in messages { for message in messages {
if matches!(message, Message::RowVisibility(..)) { if matches!(message, Message::RowVisibility(..)) {
@@ -170,6 +305,7 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
} }
render_times.sort_unstable(); render_times.sort_unstable();
assert!(checked_labels > 20, "scroll check must include visible field labels");
eprintln!( eprintln!(
"backend={backend} split={split} render_median_us={} p95_us={} visibility_messages={visibility_messages}", "backend={backend} split={split} render_median_us={} p95_us={} visibility_messages={visibility_messages}",
render_times[render_times.len() / 2], render_times[render_times.len() / 2],
@@ -178,3 +314,607 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
} }
} }
} }
#[test]
fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
// Native startup preloads text fonts; headless renderers skip that step.
// Supply a bundled proportional font even on minimal CI images.
cosmic::iced::advanced::graphics::text::font_system()
.write()
.unwrap()
.load_font(std::borrow::Cow::Borrowed(include_bytes!(
"../../../assets/fonts/Comfortaa-Regular.otf"
)));
#[derive(Default)]
struct Labels {
labels: Vec<(String, Rectangle, Option<Rectangle>)>,
traversal_start: usize,
}
impl Operation for Labels {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
let start = self.labels.len();
children(self);
self.traversal_start = start;
}
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
if matches!(
text,
"Top panel"
| "Shown on all displays"
| "Position"
| "Background"
| "Default group surface"
| "Background opacity"
| "Group borders"
| "Corner radius"
| "Size & spacing"
| "Side margins"
| "Window clearance"
| "Arrangement"
| "Start"
| "Center"
| "End"
| "Add group"
| "Add item"
| "Overview"
| "Workspaces"
| "Battery"
| "Placement"
| "Show item"
| "Overflow"
| "Preferred size"
| "Battery percentage"
| "Make it your own"
| "Surface"
| "Item spacing"
| "Vertical padding"
| "Horizontal padding"
| "Undo"
| "Reload configuration"
) {
self.labels.push((text.into(), bounds, None));
}
}
fn scrollable(
&mut self,
_: Option<&widget::Id>,
viewport: Rectangle,
_: Rectangle,
translation: Vector,
_: &mut dyn cosmic::iced::advanced::widget::operation::Scrollable,
) {
// Scrollable reports its offset after traversing its content.
for (_, bounds, clip) in &mut self.labels[self.traversal_start..] {
*bounds = *bounds - translation;
*clip = Some(clip.map_or(viewport, |clip| {
(clip - translation).intersection(&viewport).unwrap_or_default()
}));
}
}
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("wgpu"),
))
.expect("GPU panel rendering requires a hardware or software WGPU adapter");
for width in [740., 1100.] {
for scale in [1., 1.75] {
for selection in ["panels", "items", "battery", "status"] {
let mut app = App::init(
Core::default(),
(
"/unused/panel-text.kdl".into(),
Snapshot::parse("theme { mode dark; family \"ferese-blue\"; }".into()),
Some(InitialPage::Bar),
),
)
.0;
app.font = Font::with_name("Comfortaa");
app.panel_page = if selection == "panels" {
panel_controls::PanelPage::Panels
} else {
panel_controls::PanelPage::Arrange
};
app.panel_selection = (selection == "battery").then(|| ferese_config::panel::ItemId("battery".into()));
app.panel_group_selection =
(selection == "status").then(|| ferese_config::panel::GroupId("status".into()));
let bounds = Rectangle::with_size(Size::new(width, 650.));
let physical = Size::new((width * scale) as u32, (650. * scale) as u32);
let theme = app.native_palette.native_theme();
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
let mut checked = 0;
for offset in [0., 180., 400.] {
for id in ["settings-content", "panel-inspector-scroll"] {
let mut scroll = cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
widget::Id::new(id),
cosmic::iced::widget::scrollable::AbsoluteOffset {
x: None,
y: Some(offset),
},
);
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut scroll);
}
renderer.reset(bounds);
view.as_widget().draw(
&tree,
&mut renderer,
&theme,
&renderer::Style {
text_color: app.native_palette.text,
icon_color: app.native_palette.text,
scale_factor: scale as f64,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
&bounds,
);
if let Some(mut overlay) =
view.as_widget_mut()
.overlay(&mut tree, Layout::new(&node), &renderer, &bounds, Vector::ZERO)
{
let overlay = overlay.as_overlay_mut();
let node = overlay.layout(&renderer, bounds.size());
renderer.with_layer(bounds, |renderer| {
overlay.draw(
renderer,
&theme,
&renderer::Style {
text_color: app.native_palette.text,
icon_color: app.native_palette.text,
scale_factor: scale as f64,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
);
});
}
let mut labels = Labels::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
let pixels = Headless::screenshot(&mut renderer, physical, scale, theme.cosmic().bg_color().into());
for (label, rect, clip) in labels.labels {
let clip = clip.unwrap_or(bounds);
if rect.width <= 0.
|| rect.height <= 0.
|| rect.x < clip.x
|| rect.y < clip.y + 2.
|| rect.x + rect.width > clip.x + clip.width
|| rect.y + rect.height > clip.y + clip.height - 2.
{
continue;
}
let mut ink = 0;
for y in (rect.y * scale).ceil() as u32..((rect.y + rect.height) * scale).floor() as u32 {
for x in (rect.x * scale).ceil() as u32..((rect.x + rect.width) * scale).floor() as u32 {
let index = ((y * physical.width + x) * 4) as usize;
if pixels[index..index + 3].iter().any(|channel| *channel > 140) {
ink += 1;
}
}
}
assert!(
ink > 12,
"missing panel text {label:?}: state={selection} width={width} scale={scale} offset={offset} bounds={rect:?}"
);
checked += 1;
}
}
assert!(checked >= 6, "state={selection} must exercise visible panel labels");
}
}
}
}
/// Reproducible sketches rendered from the actual widgets, with no live services.
#[test]
#[ignore = "writes Panel & Shell review images to /tmp/ferese-panel-review-{backend}"]
fn render_panel_review_states() {
let backend = std::env::var("FERESE_REVIEW_BACKEND").unwrap_or_else(|_| "tiny-skia".into());
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some(&backend),
))
.unwrap();
let directory = format!("/tmp/ferese-panel-review-{backend}");
std::fs::create_dir_all(&directory).unwrap();
let mut app = App::init(
Core::default(),
(
"/unused/review.kdl".into(),
Snapshot::parse(String::new()),
Some(InitialPage::Bar),
),
)
.0;
app.status = "Changes save automatically".into();
for appearance in ["light", "dark"] {
let theme_config = Snapshot::parse(format!("theme {{ mode \"{appearance}\"; }}")).unwrap();
let theme = ferese_config::theme::resolve(
&theme_config.doc,
std::path::Path::new("/tmp"),
"2026-10-07T12:00:00Z".parse().unwrap(),
|_| unreachable!(),
)
.unwrap()
.theme;
app.resolved.presented = theme.clone();
app.resolved.theme = theme;
app.font = fonts::interface_font(&app.resolved.presented.tokens.typography.font_family);
app.native_palette = visuals::Palette::from_resolved(&app.resolved.presented);
for (name, tab, item, group) in [
("panels", panel_controls::PanelPage::Panels, None, None),
("mixed-surfaces", panel_controls::PanelPage::Panels, None, None),
(
"surface-inspector",
panel_controls::PanelPage::Arrange,
None,
Some("title"),
),
("corners", panel_controls::PanelPage::Panels, None, None),
("preview-hover", panel_controls::PanelPage::Panels, None, None),
("navigation", panel_controls::PanelPage::Arrange, None, None),
("shortcuts", panel_controls::PanelPage::Panels, None, None),
("motion", panel_controls::PanelPage::Panels, None, None),
("items", panel_controls::PanelPage::Arrange, None, None),
(
"workspace-styles",
panel_controls::PanelPage::Arrange,
Some("workspaces"),
None,
),
(
"selected-item",
panel_controls::PanelPage::Arrange,
Some("battery"),
None,
),
(
"selected-group",
panel_controls::PanelPage::Arrange,
None,
Some("status"),
),
] {
app.page = match name {
"shortcuts" => Page::Shortcuts,
"motion" => Page::Motion,
_ => Page::Bar,
};
app.draft = Snapshot::parse(String::new()).unwrap();
if matches!(name, "mixed-surfaces" | "surface-inspector") {
let mut panel = ferese_config::panel::Panel::from_preset(ferese_config::PanelPreset::Islands);
panel.center.groups[0].surface = Some(ferese_config::panel::GroupSurface::None);
panel.end.groups[0].surface = Some(ferese_config::panel::GroupSurface::Inset);
panel.geometry.edge_margin = 6;
app.draft
.edit(&crate::store::set("panels", serde_json::to_value(vec![panel]).unwrap()))
.unwrap();
}
if name == "corners" {
for edit in crate::panel_edit::plan(
&app.draft,
crate::panel_edit::Action::SetRadius("4px 8px 12px 16px".into()),
)
.unwrap()
{
app.draft.edit(&edit).unwrap();
}
app.draft = Snapshot::parse(app.draft.source.clone()).unwrap();
}
app.panel_page = tab;
app.sidebar_open = name == "navigation";
app.panel_selection = item.map(|id| ferese_config::panel::ItemId(id.into()));
app.panel_group_selection = group.map(|id| ferese_config::panel::GroupId(id.into()));
for (width, height) in [(1100, 860), (740, 650)] {
let bounds = Rectangle::with_size(Size::new(width as f32, height as f32));
let theme = app.native_palette.native_theme();
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
#[derive(Default)]
struct HoverTarget(Option<Rectangle>);
impl Operation for HoverTarget {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("preview-item:battery")) {
self.0 = Some(bounds);
}
}
}
let cursor = if name == "preview-hover" {
let mut target = HoverTarget::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut target);
mouse::Cursor::Available(target.0.expect("battery is visible in the review preview").center())
} else {
mouse::Cursor::Unavailable
};
// Responsive content is laid out before drawing, as it is in the app.
renderer.reset(bounds);
view.as_widget().draw(
&tree,
&mut renderer,
&theme,
&renderer::Style {
text_color: Color::WHITE,
icon_color: Color::WHITE,
scale_factor: 1.,
},
Layout::new(&node),
cursor,
&bounds,
);
if let Some(mut overlay) =
view.as_widget_mut()
.overlay(&mut tree, Layout::new(&node), &renderer, &bounds, Vector::ZERO)
{
let overlay = overlay.as_overlay_mut();
let layout = overlay.layout(&renderer, bounds.size());
renderer.with_layer(bounds, |renderer| {
overlay.draw(
renderer,
&theme,
&renderer::Style {
text_color: app.native_palette.text,
icon_color: app.native_palette.text,
scale_factor: 1.,
},
Layout::new(&layout),
mouse::Cursor::Unavailable,
);
});
}
let pixels = Headless::screenshot(
&mut renderer,
Size::new(width, height),
1.,
theme.cosmic().bg_color().into(),
);
let path = format!("{directory}/{appearance}-{name}-{width}.png");
image::save_buffer(&path, &pixels, width, height, image::ColorType::Rgba8).unwrap();
eprintln!("{path}");
}
}
}
}
#[test]
fn selecting_panel_items_preserves_editor_scroll_and_pane_bounds() {
#[derive(Default, Debug, PartialEq)]
struct Panes {
preview: Option<Rectangle>,
editor: Option<Rectangle>,
inspector: Option<Rectangle>,
}
impl Operation for Panes {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("panel-composition-preview")) {
self.preview = Some(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
_: Vector,
_: &mut dyn cosmic::iced::advanced::widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("settings-content")) {
self.editor = Some(bounds);
}
if id == Some(&widget::Id::new("panel-inspector-scroll")) {
self.inspector = Some(bounds);
}
}
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
for width in [740., 1100.] {
let mut app = App::init(
Core::default(),
(
"/unused/layout.kdl".into(),
Snapshot::parse(String::new()),
Some(InitialPage::Bar),
),
)
.0;
app.panel_page = panel_controls::PanelPage::Arrange;
let mut tree = None;
let mut initial = None;
for selection in [
None,
Some(Message::PanelSelect(ferese_config::panel::ItemId("battery".into()))),
Some(Message::PanelGroupSelect(ferese_config::panel::GroupId(
"status".into(),
))),
Some(Message::PanelClearSelection),
] {
if let Some(selection) = selection {
let _ = app.update(selection);
}
let mut view = app.page_view();
let tree = tree.get_or_insert_with(|| Tree::new(&view));
tree.diff(view.as_widget_mut());
let node = view.as_widget_mut().layout(
tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, 650.)),
);
if initial.is_none() {
let mut scroll = cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::AbsoluteOffset { x: None, y: Some(180.) },
);
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut scroll);
}
let mut panes = Panes::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut panes);
let mut position = ScrollPosition::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut position);
assert_eq!(position.offset, 180., "selection moved the arrangement");
let editor = panes.editor.unwrap();
let inspector = panes.inspector.unwrap();
assert!(editor.x + editor.width < inspector.x, "panes overlap");
assert!(inspector.x + inspector.width <= width, "inspector leaves window");
assert!(editor.height > 100. && inspector.height > 100.);
if let Some(initial) = &initial {
assert_eq!(&panes, initial);
} else {
initial = Some(panes);
}
assert!(app.pending.is_empty() && !app.saving && app.draft.item("panels").is_none());
}
}
}
#[test]
fn sidebar_collapses_at_the_breakpoint_and_compact_navigation_dismisses() {
#[derive(Default)]
struct Bounds {
sidebar: Option<Rectangle>,
page: Option<Rectangle>,
toggle: Option<Rectangle>,
}
impl Operation for Bounds {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("settings-sidebar")) {
self.sidebar = Some(bounds);
}
if id == Some(&widget::Id::new("settings-page")) {
self.page = Some(bounds);
}
if id == Some(&widget::Id::new("settings-navigation-toggle")) {
self.toggle = Some(bounds);
}
}
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let mut app = App::init(
Core::default(),
(
"/unused/sidebar.kdl".into(),
Snapshot::parse(String::new()),
Some(InitialPage::Bar),
),
)
.0;
app.panel_page = panel_controls::PanelPage::Arrange;
let mut tree = None;
for width in [1100., 959., 740., 960., 1100.] {
let mut view = app.page_view();
let tree = tree.get_or_insert_with(|| Tree::new(&view));
tree.diff(view.as_widget_mut());
let node = view.as_widget_mut().layout(
tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, 650.)),
);
let mut bounds = Bounds::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut bounds);
let compact = width < 960.;
assert_eq!(bounds.sidebar.is_none(), compact);
assert_eq!(bounds.toggle.is_some(), compact);
assert_eq!(bounds.page.unwrap().x, if compact { 0. } else { 204. });
assert_eq!(bounds.page.unwrap().width, if compact { width } else { width - 204. });
}
let _ = app.update(Message::Sidebar(true));
assert!(app.sidebar_open);
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let size = Size::new(740., 650.);
let node = view
.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, size));
let mut messages = Vec::new();
{
let viewport = Rectangle::with_size(size);
let mut overlay = view
.as_widget_mut()
.overlay(&mut tree, Layout::new(&node), &renderer, &viewport, Vector::ZERO)
.expect("compact navigation overlay");
let overlay = overlay.as_overlay_mut();
let layout = overlay.layout(&renderer, size);
let mut bounds = Bounds::default();
overlay.operate(Layout::new(&layout), &renderer, &mut bounds);
assert_eq!(bounds.sidebar.unwrap().width, 244.);
assert!(
bounds.page.is_none(),
"background must be excluded from modal focus navigation"
);
overlay.update(
&Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Layout::new(&layout),
mouse::Cursor::Available(Point::new(500., 300.)),
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
);
assert!(
messages
.iter()
.any(|message| matches!(message, Message::Sidebar(false)))
);
}
drop(view);
for message in messages {
let _ = app.update(message);
}
assert!(!app.sidebar_open);
let _ = app.update(Message::Sidebar(true));
let _ = app.update(Message::Page(Page::Bar));
assert!(!app.sidebar_open, "reselecting the current page closes navigation too");
assert!(app.pending.is_empty() && !app.saving);
}
+5
View File
@@ -21,6 +21,11 @@ impl Snapshot {
return Err("Configuration exceeds the 60 KiB limit.".into()); return Err("Configuration exceeds the 60 KiB limit.".into());
} }
let doc = Document::parse(&source).map_err(|e| e.to_string())?; let doc = Document::parse(&source).map_err(|e| e.to_string())?;
if let Some(value) = doc.get("panels") {
let panels: Vec<ferese_config::panel::Panel> =
serde_json::from_value(value.clone()).map_err(|error| error.to_string())?;
ferese_config::panel::validate(&panels)?;
}
Ok(Self { source, doc }) Ok(Self { source, doc })
} }
+54 -3
View File
@@ -8,6 +8,57 @@ use crate::Message;
use crate::schema::Page; use crate::schema::Page;
use crate::store::{Edit, Snapshot, set}; use crate::store::{Edit, Snapshot, set};
/// Keep focus distinct from selection and hover for keyboard navigation.
pub fn panel_button(p: Palette, selected: bool) -> cosmic::theme::Button {
use cosmic::iced::border::Shape;
let paint = move |hover: bool, focused: bool, enabled: bool| {
let highlighted = hover || focused;
let background = if selected && enabled {
ferese_theme::mix(p.card, p.accent, if highlighted { 0.28 } else { 0.16 })
} else if highlighted && enabled {
ferese_theme::mix(p.card, p.text, 0.09)
} else {
p.card
};
cosmic::widget::button::Style {
shape: Some(Shape::Continuous),
background: Some(background.into()),
text_color: Some(if enabled {
ferese_theme::foreground(background, p.text)
} else {
p.muted
}),
icon_color: Some(if enabled { p.text } else { p.muted }),
border_radius: 8.into(),
border_width: if focused {
2.
} else if selected {
1.
} else {
0.
},
border_color: p.accent.scale_alpha(if focused { 1. } else { 0.45 }),
outline_width: 0.,
outline: None,
..Default::default()
}
};
cosmic::theme::Button::Custom {
active: Box::new(move |focused, _| paint(false, focused, true)),
hovered: Box::new(move |focused, _| paint(true, focused, true)),
pressed: Box::new(move |focused, _| paint(true, focused, true)),
// A selected segment can be inert without losing its selection indicator.
disabled: Box::new(move |_| paint(false, false, selected)),
}
}
pub fn panel_select<'a>(
content: impl Into<Element<'a, Message>>,
p: Palette,
) -> cosmic::widget::Container<'a, Message, cosmic::Theme> {
cosmic::widget::container(content).class(surface(ferese_theme::mix(p.card, p.text, 0.035), 7.))
}
pub fn color(value: &str, fallback: Color) -> Color { pub fn color(value: &str, fallback: Color) -> Color {
ferese_theme::parse_color(value).unwrap_or(fallback) ferese_theme::parse_color(value).unwrap_or(fallback)
} }
@@ -205,9 +256,9 @@ fn preview(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) ->
let muted = hex(p.muted); let muted = hex(p.muted);
let gap = snapshot.number("layout.inner_gap", 8.).clamp(0., 32.); let gap = snapshot.number("layout.inner_gap", 8.).clamp(0., 32.);
let radius = snapshot.number("theme.geometry.window_radius", 14.).clamp(0., 28.); let radius = snapshot.number("theme.geometry.window_radius", 14.).clamp(0., 28.);
let bar_y = snapshot.number("theme.geometry.top_bar_margin_top", 0.) * 0.5 + 12.; let bar_y = snapshot.number("panels.0.geometry.edge_margin", 0.) * 0.5 + 12.;
let bar_margin = snapshot.number("theme.geometry.top_bar_margin_horizontal", 0.) * 0.5 + 14.; let bar_margin = snapshot.number("panels.0.geometry.side_margins", 0.) * 0.5 + 14.;
let bar_height = snapshot.number("theme.geometry.top_bar_height", 30.) * 0.65; let bar_height = snapshot.number("panels.0.geometry.height", 28.) * 0.65;
let bar_radius = snapshot.number("theme.geometry.shell_radius", 14.) * 0.65; let bar_radius = snapshot.number("theme.geometry.shell_radius", 14.) * 0.65;
let opacity = if snapshot.string("theme.material.style", "solid") == "translucent" { let opacity = if snapshot.string("theme.material.style", "solid") == "translucent" {
snapshot snapshot
@@ -380,18 +380,10 @@ impl App {
} else { } else {
self.wallpaper_placeholder(preview.error.as_deref().unwrap_or("Loading preview…"), palette, 110.) self.wallpaper_placeholder(preview.error.as_deref().unwrap_or("Loading preview…"), palette, 110.)
}; };
let label_color = if preview.handle.is_none() { let label = widget::container(self.label(name, 12.).class(cosmic::theme::Text::Color(palette.text)))
palette.text .padding([7, 10])
} else if appearance == Appearance::Light { .width(Length::Fill);
cosmic::iced::Color::from_rgb8(24, 33, 44) let tile = column([]).push(image).push(label);
} else {
cosmic::iced::Color::WHITE
};
let label = widget::container(self.label(name, 12.).class(cosmic::theme::Text::Color(label_color)))
.padding(8)
.width(Length::Fill)
.height(Length::Fill);
let tile = cosmic::iced::widget::stack([image, label.into()]);
defaults = defaults.push( defaults = defaults.push(
button::custom(tile) button::custom(tile)
+2 -1
View File
@@ -17,7 +17,8 @@ libc.workspace = true
# Keep GPU rendering available as a fallback/override. The shell defaults to # Keep GPU rendering available as a fallback/override. The shell defaults to
# tiny-skia to avoid a second GPU initialization during desktop startup. # tiny-skia to avoid a second GPU initialization during desktop startup.
libcosmic = { workspace = true, features = ["wgpu", "a11y"] } libcosmic = { workspace = true, features = ["wgpu", "a11y"] }
iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.0" } freedesktop-desktop-entry = { version = "0.8.3", default-features = false }
iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.1" }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tempfile.workspace = true tempfile.workspace = true
File diff suppressed because it is too large. Load diff
+285 -29
View File
@@ -1,24 +1,33 @@
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget}; use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Event, Length, Rectangle, Size, Vector}; use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
use cosmic::{Element, Theme}; use cosmic::{Element, Theme};
use ferese_config::BarLayout; use ferese_config::panel::PanelSurface;
pub(crate) fn island_id() -> widget::Id { pub(crate) fn island_id() -> widget::Id {
static ID: std::sync::OnceLock<widget::Id> = std::sync::OnceLock::new(); static ID: std::sync::OnceLock<widget::Id> = std::sync::OnceLock::new();
ID.get_or_init(widget::Id::unique).clone() ID.get_or_init(widget::Id::unique).clone()
} }
pub(crate) fn input_id() -> widget::Id {
static ID: std::sync::OnceLock<widget::Id> = std::sync::OnceLock::new();
ID.get_or_init(widget::Id::unique).clone()
}
pub(super) fn frame<'a, M: 'a>( pub(super) fn frame<'a, M: 'a>(
content: Element<'a, M>, content: Element<'a, M>,
mode: BarLayout, mode: PanelSurface,
radius: f32, radius: f32,
present: impl Fn(&[[f32; 5]]) + 'a, materials: Vec<(widget::Id, f32)>,
hover: Option<HoverTarget<'a, M>>,
present: impl Fn(&[[f32; 5]], &[f32]) + 'a,
changed: impl Fn(Vec<[f32; 5]>) -> M + 'a, changed: impl Fn(Vec<[f32; 5]>) -> M + 'a,
) -> Element<'a, M> { ) -> Element<'a, M> {
Element::new(Presentation { Element::new(Presentation {
content, content,
mode, mode,
radius, radius,
materials,
hover,
present: Box::new(present), present: Box::new(present),
changed: Box::new(changed), changed: Box::new(changed),
}) })
@@ -26,18 +35,45 @@ pub(super) fn frame<'a, M: 'a>(
#[derive(Default)] #[derive(Default)]
struct State { struct State {
mode: Option<BarLayout>, mode: Option<PanelSurface>,
current: Vec<[f32; 5]>, current: Vec<[f32; 5]>,
scratch: Vec<[f32; 5]>, scratch: Vec<[f32; 5]>,
scroll_starts: Vec<usize>, input: Vec<[f32; 5]>,
input_scratch: Vec<[f32; 5]>,
scroll_starts: Vec<(usize, usize)>,
opacity_scratch: Vec<f32>,
} }
type Present<'a> = Box<dyn Fn(&[[f32; 5]]) + 'a>; pub(super) struct HoverTarget<'a, M> {
pub(super) id: widget::Id,
pub(super) exited: Box<dyn Fn() -> M + 'a>,
}
struct HoverBounds<'a> {
target: &'a widget::Id,
bounds: Option<Rectangle>,
}
impl widget::Operation for HoverBounds<'_> {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(self.target) {
self.bounds = Some(bounds);
}
}
}
type Present<'a> = Box<dyn Fn(&[[f32; 5]], &[f32]) + 'a>;
struct Presentation<'a, M> { struct Presentation<'a, M> {
content: Element<'a, M>, content: Element<'a, M>,
mode: BarLayout, mode: PanelSurface,
radius: f32, radius: f32,
materials: Vec<(widget::Id, f32)>,
hover: Option<HoverTarget<'a, M>>,
present: Present<'a>, present: Present<'a>,
changed: Box<dyn Fn(Vec<[f32; 5]>) -> M + 'a>, changed: Box<dyn Fn(Vec<[f32; 5]>) -> M + 'a>,
} }
@@ -94,6 +130,32 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
shell: &mut Shell<'_, M>, shell: &mut Shell<'_, M>,
viewport: &Rectangle, viewport: &Rectangle,
) { ) {
// The stack may stop dispatching after another control captures input.
// Reconcile the active tooltip before any child handles the pointer.
if matches!(
event,
Event::Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorLeft)
) && let Some(hover) = &self.hover
{
let mut target = HoverBounds {
target: &hover.id,
bounds: None,
};
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, &mut target);
if matches!(event, Event::Mouse(mouse::Event::CursorLeft))
|| !cursor.is_over(*viewport)
|| !target.bounds.is_some_and(|bounds| cursor.is_over(bounds))
{
shell.publish((hover.exited)());
}
}
let cursor = if matches!(event, Event::Mouse(mouse::Event::CursorLeft)) {
mouse::Cursor::Unavailable
} else {
cursor
};
self.content.as_widget_mut().update( self.content.as_widget_mut().update(
&mut tree.children[0], &mut tree.children[0],
event, event,
@@ -111,12 +173,17 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
let state = tree.state.downcast_mut::<State>(); let state = tree.state.downcast_mut::<State>();
let mut collector = Collect { let mut collector = Collect {
regions: std::mem::take(&mut state.scratch), regions: std::mem::take(&mut state.scratch),
input: std::mem::take(&mut state.input_scratch),
scroll_starts: std::mem::take(&mut state.scroll_starts), scroll_starts: std::mem::take(&mut state.scroll_starts),
radius: self.radius, radius: self.radius,
materials: &self.materials,
opacities: std::mem::take(&mut state.opacity_scratch),
}; };
collector.regions.clear(); collector.regions.clear();
collector.opacities.clear();
collector.input.clear();
collector.scroll_starts.clear(); collector.scroll_starts.clear();
if self.mode == BarLayout::Islands { if self.mode == PanelSurface::None {
self.content self.content
.as_widget_mut() .as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, &mut collector); .operate(&mut tree.children[0], layout, renderer, &mut collector);
@@ -125,18 +192,27 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
collector collector
.regions .regions
.push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]); .push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]);
collector.opacities.push(1.);
} }
collector.clip_from(0, layout.bounds(), Vector::ZERO); collector.clip_from(0, layout.bounds(), Vector::ZERO);
collector.regions.retain(|region| region[2] > 0.0 && region[3] > 0.0); collector.clip_input_from(0, layout.bounds(), Vector::ZERO);
(self.present)(&collector.regions); collector.discard_empty();
if state.mode != Some(self.mode) || state.current != collector.regions { (self.present)(&collector.regions, &collector.opacities);
if state.mode != Some(self.mode) || state.current != collector.regions || state.input != collector.input {
state.mode = Some(self.mode); state.mode = Some(self.mode);
state.current.clone_from(&collector.regions); state.current.clone_from(&collector.regions);
shell.publish((self.changed)(collector.regions.clone())); state.input.clone_from(&collector.input);
shell.publish((self.changed)(if self.mode == PanelSurface::Solid {
collector.regions.clone()
} else {
collector.input.clone()
}));
} }
state.opacity_scratch = collector.opacities;
state.scratch = collector.regions; state.scratch = collector.regions;
state.input_scratch = collector.input;
state.scroll_starts = collector.scroll_starts; state.scroll_starts = collector.scroll_starts;
} }
@@ -194,13 +270,34 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
} }
} }
struct Collect { #[derive(Default)]
struct Collect<'a> {
regions: Vec<[f32; 5]>, regions: Vec<[f32; 5]>,
scroll_starts: Vec<usize>, input: Vec<[f32; 5]>,
scroll_starts: Vec<(usize, usize)>,
radius: f32, radius: f32,
materials: &'a [(widget::Id, f32)],
opacities: Vec<f32>,
} }
impl Collect { impl Collect<'_> {
fn discard_empty(&mut self) {
let mut index = 0;
self.opacities.retain(|_| {
let keep = self.regions[index][2] > 0. && self.regions[index][3] > 0.;
index += 1;
keep
});
self.regions.retain(|region| region[2] > 0. && region[3] > 0.);
self.input.retain(|region| region[2] > 0. && region[3] > 0.);
}
fn clip_input_from(&mut self, start: usize, viewport: Rectangle, translation: Vector) {
std::mem::swap(&mut self.regions, &mut self.input);
self.clip_from(start, viewport, translation);
std::mem::swap(&mut self.regions, &mut self.input);
}
fn clip_from(&mut self, start: usize, viewport: Rectangle, translation: Vector) { fn clip_from(&mut self, start: usize, viewport: Rectangle, translation: Vector) {
for region in &mut self.regions[start..] { for region in &mut self.regions[start..] {
let bounds = Rectangle { let bounds = Rectangle {
@@ -215,22 +312,32 @@ impl Collect {
} }
} }
impl widget::Operation for Collect { impl widget::Operation for Collect<'_> {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) { fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
children(self); children(self);
} }
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&island_id()) { let opacity = self
.materials
.iter()
.find(|(target, _)| Some(target) == id)
.map(|(_, opacity)| *opacity);
if opacity.is_some() || id == Some(&island_id()) || id == Some(&input_id()) {
self.input
.push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]);
}
if opacity.is_some() || id == Some(&island_id()) {
self.regions self.regions
.push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]); .push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]);
self.opacities.push(opacity.unwrap_or(1.));
} }
} }
fn pre_operation(&mut self, _id: Option<&widget::Id>) { fn pre_operation(&mut self, _id: Option<&widget::Id>) {
// Iced visits scrollable content before its scrollable callback. Record // Iced visits scrollable content before its scrollable callback. Record
// the range so only that subtree receives its offset and viewport clip. // the range so only that subtree receives its offset and viewport clip.
self.scroll_starts.push(self.regions.len()); self.scroll_starts.push((self.regions.len(), self.input.len()));
} }
fn scrollable( fn scrollable(
@@ -241,8 +348,9 @@ impl widget::Operation for Collect {
translation: Vector, translation: Vector,
_state: &mut dyn widget::operation::Scrollable, _state: &mut dyn widget::operation::Scrollable,
) { ) {
if let Some(start) = self.scroll_starts.pop() { if let Some((start, input_start)) = self.scroll_starts.pop() {
self.clip_from(start, bounds, translation); self.clip_from(start, bounds, translation);
self.clip_input_from(input_start, bounds, translation);
} }
} }
} }
@@ -254,6 +362,82 @@ mod tests {
use cosmic::iced::{Font, Pixels, widget as widgets}; use cosmic::iced::{Font, Pixels, widget as widgets};
use cosmic::widget::{container, row}; use cosmic::widget::{container, row};
#[test]
fn clipping_preserves_group_opacity_and_transparent_groups_keep_input() {
use widget::Operation;
let ids = [widget::Id::unique(), widget::Id::unique(), widget::Id::unique()];
let materials = [(ids[0].clone(), 0.2), (ids[1].clone(), 0.), (ids[2].clone(), 0.7)];
let mut collector = Collect {
materials: &materials,
radius: 12.,
..Default::default()
};
for (id, x) in ids.iter().zip([-80., 20., 80.]) {
collector.container(Some(id), Rectangle::new((x, 0.).into(), (30., 28.).into()));
}
let viewport = Rectangle::new((0., 0.).into(), (100., 28.).into());
collector.clip_from(0, viewport, Vector::ZERO);
collector.clip_input_from(0, viewport, Vector::ZERO);
collector.discard_empty();
assert_eq!(collector.regions, [[20., 0., 30., 28., 12.], [80., 0., 20., 28., 12.]]);
assert_eq!(collector.opacities, [0., 0.7]);
assert_eq!(
collector.input, collector.regions,
"background opacity does not disable controls"
);
}
#[test]
fn opacity_only_changes_publish_material_without_rebuilding_input() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let viewport = Rectangle::new((0., 0.).into(), (100., 28.).into());
let id = widget::Id::unique();
let mut tree = None;
for (index, opacity) in [1., 0.5, 0.].into_iter().enumerate() {
let published = std::cell::RefCell::new(Vec::new());
let content = container(widgets::Space::new().width(60).height(28))
.id(id.clone())
.into();
let mut view = frame(
content,
PanelSurface::None,
12.,
vec![(id.clone(), opacity)],
None,
|regions, opacities| published.borrow_mut().push((regions.to_vec(), opacities.to_vec())),
|regions| regions,
);
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
tree.diff(&mut view);
let node = view
.as_widget_mut()
.layout(tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
let mut messages = Vec::new();
view.as_widget_mut().update(
tree,
&Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())),
Layout::new(&node),
mouse::Cursor::Unavailable,
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&viewport,
);
assert_eq!(*published.borrow(), [(vec![[0., 0., 60., 28., 12.]], vec![opacity])]);
assert_eq!(messages.len(), usize::from(index == 0));
}
}
#[test] #[test]
fn measured_islands_and_live_layout_switches_publish_only_changed_bounds() { fn measured_islands_and_live_layout_switches_publish_only_changed_bounds() {
let runtime = tokio::runtime::Builder::new_current_thread() let runtime = tokio::runtime::Builder::new_current_thread()
@@ -270,18 +454,16 @@ mod tests {
let viewport = Rectangle::new((0., 0.).into(), (600., 28.).into()); let viewport = Rectangle::new((0., 0.).into(), (600., 28.).into());
let mut tree = None; let mut tree = None;
let theme = crate::ShellTheme { let theme = crate::ShellTheme {
bar_height: 28.,
panel_padding: 12.,
bar_radius: 14., bar_radius: 14.,
..Default::default() ..Default::default()
}; };
for (mode, padding) in [ for (mode, padding) in [
(BarLayout::Islands, 12.), (PanelSurface::None, 12.),
(BarLayout::Islands, 4.), (PanelSurface::None, 4.),
(BarLayout::Islands, 0.), (PanelSurface::None, 0.),
(BarLayout::Continuous, 32.), (PanelSurface::Solid, 32.),
(BarLayout::Islands, 12.), (PanelSurface::None, 12.),
] { ] {
let section = |width| { let section = |width| {
let inner = container(widgets::Space::new().width(width).height(24)) let inner = container(widgets::Space::new().width(width).height(24))
@@ -289,7 +471,15 @@ mod tests {
crate::bar::bar_group_style(theme) crate::bar::bar_group_style(theme)
})) }))
.into(); .into();
crate::bar::island(inner, theme, padding, mode == BarLayout::Islands, true) crate::bar::island(
inner,
theme,
crate::panel::PanelGeometry::default(),
padding,
mode == PanelSurface::None,
true,
None,
)
}; };
let islands = row![section(100), section(200),].spacing(60); let islands = row![section(100), section(200),].spacing(60);
let content = container(islands) let content = container(islands)
@@ -302,7 +492,9 @@ mod tests {
content, content,
mode, mode,
14., 14.,
|_| {}, vec![],
None,
|_, _| {},
|regions| { |regions| {
cosmic::Action::App(crate::Message::BarRegionsChanged( cosmic::Action::App(crate::Message::BarRegionsChanged(
cosmic::iced::window::Id::unique(), cosmic::iced::window::Id::unique(),
@@ -331,7 +523,7 @@ mod tests {
} }
assert_eq!(messages.len(), 1, "unchanged frames must not rebuild input regions"); assert_eq!(messages.len(), 1, "unchanged frames must not rebuild input regions");
let expected = if mode == BarLayout::Islands { let expected = if mode == PanelSurface::None {
vec![ vec![
[12., 0., 100. + 2. * padding, 28., 14.], [12., 0., 100. + 2. * padding, 28., 14.],
[172. + 2. * padding, 0., 200. + 2. * padding, 28., 14.], [172. + 2. * padding, 0., 200. + 2. * padding, 28., 14.],
@@ -350,8 +542,10 @@ mod tests {
fn scrolling_clips_only_its_own_islands_and_preserves_fractional_positions() { fn scrolling_clips_only_its_own_islands_and_preserves_fractional_positions() {
let mut collector = Collect { let mut collector = Collect {
regions: vec![[400., 2., 80., 24., 14.], [10.25, 2., 180., 24., 14.]], regions: vec![[400., 2., 80., 24., 14.], [10.25, 2., 180., 24., 14.]],
input: Vec::new(),
scroll_starts: Vec::new(), scroll_starts: Vec::new(),
radius: 14., radius: 14.,
..Default::default()
}; };
collector.clip_from( collector.clip_from(
1, 1,
@@ -366,4 +560,66 @@ mod tests {
collector.regions.retain(|region| region[2] > 0. && region[3] > 0.); collector.regions.retain(|region| region[2] > 0. && region[3] > 0.);
assert_eq!(collector.regions, vec![[20., 2., 100., 24., 14.]]); assert_eq!(collector.regions, vec![[20., 2., 100., 24., 14.]]);
} }
#[test]
fn undecorated_controls_have_input_regions_without_creating_material_or_gap_regions() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let content = row![
container(widgets::Space::new().width(30).height(24)).id(input_id()),
container(widgets::Space::new().width(40).height(24)).id(input_id()),
]
.spacing(20)
.into();
let materials = std::cell::RefCell::new(Vec::new());
let mut view = frame(
content,
PanelSurface::None,
14.,
vec![],
None,
|regions, _| *materials.borrow_mut() = regions.to_vec(),
|regions| regions,
);
let mut tree = widget::Tree::new(&view);
let viewport = Rectangle::new((0., 0.).into(), (100., 28.).into());
let node = view
.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
let mut messages = Vec::new();
view.as_widget_mut().update(
&mut tree,
&Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())),
Layout::new(&node),
mouse::Cursor::Unavailable,
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&viewport,
);
assert!(materials.borrow().is_empty());
assert_eq!(messages, vec![vec![[0., 0., 30., 24., 14.], [50., 0., 40., 24., 14.]]]);
let mut collector = Collect {
regions: vec![[300., 0., 20., 24., 14.]],
input: messages.remove(0),
scroll_starts: Vec::new(),
radius: 14.,
..Default::default()
};
collector.clip_input_from(
1,
Rectangle::new((40., 0.).into(), (20., 28.).into()),
Vector::new(10., 0.),
);
assert_eq!(collector.input, vec![[0., 0., 30., 24., 14.], [40., 0., 20., 24., 14.]]);
assert_eq!(collector.regions, vec![[300., 0., 20., 24., 14.]]);
}
} }
+223 -148
View File
@@ -5,7 +5,7 @@ use serde::Deserialize;
const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20]; const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug)]
pub(crate) struct ShellConfig { pub(crate) struct ShellConfig {
pub(crate) notifications: ferese_config::notifications::NotificationConfig, pub(crate) notifications: ferese_config::notifications::NotificationConfig,
pub(crate) desktop_widgets: ferese_config::desktop::DesktopWidgets, pub(crate) desktop_widgets: ferese_config::desktop::DesktopWidgets,
@@ -15,17 +15,30 @@ pub(crate) struct ShellConfig {
pub(crate) theme: ShellTheme, pub(crate) theme: ShellTheme,
pub(crate) theme_mode: ferese_config::theme::Mode, pub(crate) theme_mode: ferese_config::theme::Mode,
pub(crate) status: StatusConfig, pub(crate) status: StatusConfig,
pub(crate) panels: Vec<crate::panel::Panel>,
}
impl Default for ShellConfig {
fn default() -> Self {
let status = StatusConfig::default();
Self {
notifications: Default::default(),
desktop_widgets: Default::default(),
animations: Default::default(),
font_family: None,
wallpaper: Default::default(),
theme: Default::default(),
theme_mode: Default::default(),
panels: vec![crate::panel::Panel::default()],
status,
}
}
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
#[serde(default)] #[serde(default, deny_unknown_fields)]
pub(crate) struct StatusConfig { pub(crate) struct StatusConfig {
pub(crate) bar_layout: ferese_config::BarLayout,
#[serde(deserialize_with = "ferese_config::deserialize_bar_island_padding")]
pub(crate) bar_island_padding: f32,
pub(crate) keybinding_guide: bool, pub(crate) keybinding_guide: bool,
pub(crate) battery_percentage: bool,
pub(crate) window_title: bool,
pub(crate) low_battery_threshold: u8, pub(crate) low_battery_threshold: u8,
pub(crate) settings_command: Option<Vec<String>>, pub(crate) settings_command: Option<Vec<String>>,
} }
@@ -33,11 +46,7 @@ pub(crate) struct StatusConfig {
impl Default for StatusConfig { impl Default for StatusConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
bar_layout: ferese_config::BarLayout::Continuous,
bar_island_padding: ferese_config::default_bar_island_padding(),
keybinding_guide: true, keybinding_guide: true,
battery_percentage: true,
window_title: true,
low_battery_threshold: 20, low_battery_threshold: 20,
settings_command: Some(vec!["ferese-settings".into()]), settings_command: Some(vec!["ferese-settings".into()]),
} }
@@ -48,6 +57,7 @@ impl Default for StatusConfig {
pub(crate) struct ShellTheme { pub(crate) struct ShellTheme {
pub(crate) appearance: ferese_config::theme::Appearance, pub(crate) appearance: ferese_config::theme::Appearance,
pub(crate) high_contrast: bool, pub(crate) high_contrast: bool,
pub(crate) reduce_transparency: bool,
pub(crate) bar_background: [u8; 4], pub(crate) bar_background: [u8; 4],
pub(crate) bar_text_primary: [u8; 4], pub(crate) bar_text_primary: [u8; 4],
pub(crate) bar_text_muted: [u8; 4], pub(crate) bar_text_muted: [u8; 4],
@@ -60,13 +70,8 @@ pub(crate) struct ShellTheme {
pub(crate) on_accent: [u8; 4], pub(crate) on_accent: [u8; 4],
pub(crate) border: [u8; 4], pub(crate) border: [u8; 4],
pub(crate) shadow: [u8; 4], pub(crate) shadow: [u8; 4],
pub(crate) bar_height: f32,
pub(crate) bar_margin_top: i32,
pub(crate) bar_window_gap: i32,
pub(crate) bar_margin_horizontal: i32,
pub(crate) bar_radius: f32, pub(crate) bar_radius: f32,
pub(crate) material_radius: f32, pub(crate) material_radius: f32,
pub(crate) panel_padding: f32,
pub(crate) control_gap: f32, pub(crate) control_gap: f32,
pub(crate) shadow_offset_y: f32, pub(crate) shadow_offset_y: f32,
pub(crate) shadow_blur: f32, pub(crate) shadow_blur: f32,
@@ -78,6 +83,7 @@ impl Default for ShellTheme {
Self { Self {
appearance: Default::default(), appearance: Default::default(),
high_contrast: false, high_contrast: false,
reduce_transparency: false,
bar_background: [28, 32, 46, 255], bar_background: [28, 32, 46, 255],
bar_text_primary: [240, 243, 250, 255], bar_text_primary: [240, 243, 250, 255],
bar_text_muted: [170, 180, 199, 255], bar_text_muted: [170, 180, 199, 255],
@@ -90,13 +96,8 @@ impl Default for ShellTheme {
on_accent: [244, 247, 251, 255], on_accent: [244, 247, 251, 255],
border: [255, 255, 255, 24], border: [255, 255, 255, 24],
shadow: [0, 0, 0, 85], shadow: [0, 0, 0, 85],
bar_height: 28.0,
bar_margin_top: 0,
bar_window_gap: 0,
bar_margin_horizontal: 0,
bar_radius: 14.0, bar_radius: 14.0,
material_radius: 14.0, material_radius: 14.0,
panel_padding: 12.0,
control_gap: 12.0, control_gap: 12.0,
shadow_offset_y: 4.0, shadow_offset_y: 4.0,
shadow_blur: 18.0, shadow_blur: 18.0,
@@ -106,6 +107,22 @@ impl Default for ShellTheme {
} }
impl ShellTheme { impl ShellTheme {
pub(crate) fn panel_opacity(self, custom: Option<f32>) -> f32 {
ferese_config::theme::effective_panel_opacity(
f32::from(self.bar_background[3]) / 255.,
custom,
self.reduce_transparency,
)
}
pub(crate) fn material_opacity(self) -> f32 {
if self.high_contrast {
1.0
} else {
f32::from(self.surface_popover[3]) / 255.0
}
}
pub(crate) fn palette(self) -> ferese_theme::Palette { pub(crate) fn palette(self) -> ferese_theme::Palette {
let color = |[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.); let color = |[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.);
let surface = color(self.surface_base); let surface = color(self.surface_base);
@@ -164,6 +181,8 @@ pub(crate) enum WallpaperMode {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct FereseConfig { struct FereseConfig {
#[serde(default)]
panels: Option<Vec<crate::panel::Panel>>,
#[serde(default)] #[serde(default)]
notifications: ferese_config::notifications::NotificationConfig, notifications: ferese_config::notifications::NotificationConfig,
#[serde(default)] #[serde(default)]
@@ -171,18 +190,11 @@ struct FereseConfig {
#[serde(default)] #[serde(default)]
animations: crate::motion::Settings, animations: crate::motion::Settings,
#[serde(default)] #[serde(default)]
appearance: AppearanceConfig,
#[serde(default)]
theme: ThemeConfig, theme: ThemeConfig,
#[serde(default)] #[serde(default)]
status: StatusConfig, status: StatusConfig,
} }
#[derive(Debug, Default, Deserialize)]
struct AppearanceConfig {
corner_radius: Option<f32>,
}
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct ThemeConfig { struct ThemeConfig {
#[serde(default)] #[serde(default)]
@@ -270,21 +282,16 @@ impl Default for ThemeColorsConfig {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ThemeGeometryConfig { struct ThemeGeometryConfig {
#[serde(default = "default_bar_height")] #[serde(default, rename = "border_width")]
top_bar_height: f32, _border_width: Option<f64>,
#[serde(default = "default_bar_margin_top")] #[serde(default, rename = "focus_ring_width")]
top_bar_margin_top: i32, _focus_ring_width: Option<f64>,
#[serde(default = "default_bar_window_gap")] #[serde(default, rename = "window_radius")]
top_bar_window_gap: i32, _window_radius: Option<f64>,
#[serde(default = "default_bar_margin_horizontal")]
top_bar_margin_horizontal: i32,
#[serde(default)] #[serde(default)]
shell_radius: Option<f32>, shell_radius: Option<f32>,
#[serde(default)]
top_bar_radius: Option<f32>,
#[serde(default = "default_panel_padding")]
panel_padding: f32,
#[serde(default = "default_control_gap")] #[serde(default = "default_control_gap")]
control_gap: f32, control_gap: f32,
} }
@@ -292,13 +299,10 @@ struct ThemeGeometryConfig {
impl Default for ThemeGeometryConfig { impl Default for ThemeGeometryConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
top_bar_height: default_bar_height(), _border_width: None,
top_bar_margin_top: default_bar_margin_top(), _focus_ring_width: None,
top_bar_window_gap: default_bar_window_gap(), _window_radius: None,
top_bar_margin_horizontal: default_bar_margin_horizontal(),
shell_radius: None, shell_radius: None,
top_bar_radius: None,
panel_padding: default_panel_padding(),
control_gap: default_control_gap(), control_gap: default_control_gap(),
} }
} }
@@ -330,31 +334,33 @@ impl Default for SoftShadowConfig {
} }
} }
pub(crate) fn load() -> ShellConfig { pub(crate) fn load() -> Result<ShellConfig, String> {
let Some(path) = config_path() else { let Some(path) = config_path() else {
return ShellConfig::default(); return Ok(ShellConfig::default());
};
let Ok(source) = fs::read_to_string(&path) else {
return ShellConfig::default();
}; };
load_path(&path)
}
match parse_source(&source) { fn load_path(path: &std::path::Path) -> Result<ShellConfig, String> {
Ok(config) => config, match fs::symlink_metadata(path) {
Err(error) => { Ok(_) => {}
eprintln!("ferese-shell: failed to read {}: {error}", path.display()); Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(ShellConfig::default()),
ShellConfig::default() Err(error) => return Err(format!("cannot read {}: {error}", path.display())),
}
} }
let source = fs::read_to_string(path).map_err(|error| format!("cannot read {}: {error}", path.display()))?;
parse_source(&source).map_err(|error| format!("invalid configuration in {}: {error}", path.display()))
} }
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::Error> { pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
let document = ferese_config::Document::parse(source)?; let document = ferese_config::Document::parse(source)?;
ferese_config::theme::validate_overrides(&document).map_err(ferese_config::Error::from)?;
let snapshot = ferese_theme_client::service::current(); let snapshot = ferese_theme_client::service::current();
let mut config = parse_document( let mut config = parse_document(
&document.with_theme(&snapshot.presented), &document.with_theme(&snapshot.presented),
snapshot.presented.appearance, snapshot.presented.appearance,
snapshot.mode, snapshot.mode,
)?; )?;
config.apply_theme(&snapshot.presented); config.apply_theme(&snapshot.presented);
Ok(config) Ok(config)
} }
@@ -368,21 +374,16 @@ fn parse_document(
.map_err(|error| ferese_config::Error::from(error.to_string())) .map_err(|error| ferese_config::Error::from(error.to_string()))
{ {
Ok(config) => { Ok(config) => {
if let Some(panels) = &config.panels {
ferese_config::panel::validate(panels).map_err(ferese_config::Error::from)?;
}
config.animations.validate().map_err(ferese_config::Error::from)?; config.animations.validate().map_err(ferese_config::Error::from)?;
config.notifications.validate().map_err(ferese_config::Error::from)?; config.notifications.validate().map_err(ferese_config::Error::from)?;
config.desktop_widgets.validate().map_err(ferese_config::Error::from)?; config.desktop_widgets.validate().map_err(ferese_config::Error::from)?;
let mut theme = shell_theme(&config.theme); let mut theme = shell_theme(&config.theme);
theme.appearance = appearance; theme.appearance = appearance;
theme.material_radius = nonnegative_or( theme.material_radius = nonnegative_or(config.theme.geometry.shell_radius.unwrap_or(14.0), 14.0);
config
.theme
.geometry
.shell_radius
.or(config.appearance.corner_radius)
.or(config.theme.geometry.top_bar_radius)
.unwrap_or(14.0),
14.0,
);
theme.bar_radius = theme.material_radius; theme.bar_radius = theme.material_radius;
Ok(ShellConfig { Ok(ShellConfig {
@@ -393,6 +394,7 @@ fn parse_document(
wallpaper: config.theme.background, wallpaper: config.theme.background,
theme, theme,
theme_mode: mode, theme_mode: mode,
panels: config.panels.unwrap_or_else(|| vec![crate::panel::Panel::default()]),
status: StatusConfig { status: StatusConfig {
low_battery_threshold: config.status.low_battery_threshold.min(100), low_battery_threshold: config.status.low_battery_threshold.min(100),
..config.status ..config.status
@@ -409,11 +411,13 @@ impl ShellConfig {
self.theme = shell_theme(&config); self.theme = shell_theme(&config);
self.theme.appearance = theme.appearance; self.theme.appearance = theme.appearance;
self.theme.high_contrast = theme.accessibility.increase_contrast; self.theme.high_contrast = theme.accessibility.increase_contrast;
self.theme.reduce_transparency = theme.accessibility.reduce_transparency;
self.theme.accent_gradient = ferese_theme::Palette::from_resolved(theme).accent_gradient; self.theme.accent_gradient = ferese_theme::Palette::from_resolved(theme).accent_gradient;
self.theme.material_radius = theme.tokens.geometry.shell_radius as f32; self.theme.material_radius = theme.tokens.geometry.shell_radius as f32;
self.theme.bar_radius = self.theme.material_radius; self.theme.bar_radius = self.theme.material_radius;
self.font_family = Some(theme.tokens.typography.font_family.clone()); self.font_family = Some(theme.tokens.typography.font_family.clone());
self.wallpaper = config.background; self.wallpaper = config.background;
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() { if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
self.wallpaper.path = None; self.wallpaper.path = None;
} }
@@ -439,6 +443,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
ShellTheme { ShellTheme {
appearance: defaults.appearance, appearance: defaults.appearance,
high_contrast: defaults.high_contrast, high_contrast: defaults.high_contrast,
reduce_transparency: defaults.reduce_transparency,
material_radius: defaults.material_radius, material_radius: defaults.material_radius,
bar_background: { bar_background: {
let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background); let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
@@ -460,19 +465,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
on_accent: parse_color(&theme.colors.on_accent).unwrap_or(defaults.on_accent), on_accent: parse_color(&theme.colors.on_accent).unwrap_or(defaults.on_accent),
border: parse_color(&theme.colors.border).unwrap_or(defaults.border), border: parse_color(&theme.colors.border).unwrap_or(defaults.border),
shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow), shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow),
bar_height: positive_or(theme.geometry.top_bar_height, defaults.bar_height), bar_radius: nonnegative_or(theme.geometry.shell_radius.unwrap_or(14.0), defaults.bar_radius),
bar_margin_top: theme.geometry.top_bar_margin_top.max(0),
bar_window_gap: theme.geometry.top_bar_window_gap.max(0),
bar_margin_horizontal: theme.geometry.top_bar_margin_horizontal.max(0),
bar_radius: nonnegative_or(
theme
.geometry
.shell_radius
.or(theme.geometry.top_bar_radius)
.unwrap_or(14.0),
defaults.bar_radius,
),
panel_padding: nonnegative_or(theme.geometry.panel_padding, defaults.panel_padding),
control_gap: nonnegative_or(theme.geometry.control_gap, defaults.control_gap), control_gap: nonnegative_or(theme.geometry.control_gap, defaults.control_gap),
shadow_offset_y: finite_or(theme.shadow.soft.offset_y, defaults.shadow_offset_y), shadow_offset_y: finite_or(theme.shadow.soft.offset_y, defaults.shadow_offset_y),
shadow_blur: nonnegative_or(theme.shadow.soft.blur, defaults.shadow_blur), shadow_blur: nonnegative_or(theme.shadow.soft.blur, defaults.shadow_blur),
@@ -498,14 +491,6 @@ pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> {
Some([red, green, blue, alpha]) Some([red, green, blue, alpha])
} }
fn positive_or(value: f32, fallback: f32) -> f32 {
if value.is_finite() && value > 0.0 {
value
} else {
fallback
}
}
fn nonnegative_or(value: f32, fallback: f32) -> f32 { fn nonnegative_or(value: f32, fallback: f32) -> f32 {
if value.is_finite() && value >= 0.0 { if value.is_finite() && value >= 0.0 {
value value
@@ -542,26 +527,6 @@ fn default_shadow() -> String {
"#00000055".to_owned() "#00000055".to_owned()
} }
const fn default_bar_height() -> f32 {
28.0
}
const fn default_bar_window_gap() -> i32 {
0
}
const fn default_bar_margin_top() -> i32 {
0
}
const fn default_bar_margin_horizontal() -> i32 {
0
}
const fn default_panel_padding() -> f32 {
12.0
}
const fn default_control_gap() -> f32 { const fn default_control_gap() -> f32 {
12.0 12.0
} }
@@ -588,6 +553,67 @@ pub(crate) fn config_path() -> Option<PathBuf> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn removed_geometry_is_rejected_before_a_runtime_snapshot_can_hide_it() {
for key in [
"top-bar-radius",
"top-bar-height",
"top-bar-margin-top",
"top-bar-margin-horizontal",
"top-bar-window-clearance",
"shell-raduis",
] {
let source = format!("theme {{ geometry {{ {key} 8; }}; }}");
assert!(
parse_test_source(&source)
.unwrap_err()
.to_string()
.contains("unknown field")
);
for section in ["", "light", "dark"] {
let layer = if section.is_empty() {
format!("geometry {{ {key} 8; }}")
} else {
format!("{section} {{ geometry {{ {key} 8; }}; }}")
};
assert!(
parse_source(&format!("theme {{ {layer}; }}"))
.unwrap_err()
.to_string()
.contains(&key.replace('-', "_"))
);
}
}
parse_test_source("theme { geometry { border-width 1; focus-ring-width 2; window-radius 12; shell-radius 14; control-gap 8; }; }").unwrap();
}
#[test]
fn reduce_transparency_overrides_panel_opacity_for_both_surfaces() {
for surface in ["solid", "none"] {
for opacity in [0., 0.4, 1.] {
for reduce in [false, true] {
let source = format!(
"theme {{ material {{ style translucent; opacity 0.7; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ surface {surface}; group-surface island; background-opacity {opacity}; }}"
);
let document = ferese_config::Document::parse(&source).unwrap();
let resolved = ferese_config::theme::resolve(
&document,
std::path::Path::new("/tmp"),
jiff::Timestamp::now(),
|_| unreachable!(),
)
.unwrap()
.theme;
let mut config = parse_test_source(&source).unwrap();
config.apply_theme(&resolved);
let effective = config.theme.panel_opacity(config.panels[0].background_opacity);
assert_eq!(effective, if reduce { 1. } else { opacity });
assert_eq!(effective, resolved.panel_opacity(config.panels[0].background_opacity));
}
}
}
}
#[test] #[test]
fn rejects_removed_physics_keys_before_publication() { fn rejects_removed_physics_keys_before_publication() {
for property in ["spring", "viewport-spring"] { for property in ["spring", "viewport-spring"] {
@@ -640,43 +666,66 @@ mod tests {
} }
#[test] #[test]
fn island_layout_is_opt_in_and_does_not_change_modal_opacity() { fn config_loading_uses_only_authored_panel_settings() {
use crate::panel::{GroupSurface, ItemKind, PanelSurface};
let defaults = parse_test_source("").unwrap();
assert_eq!(ShellConfig::default().panels, defaults.panels);
let source = "panel main { surface none; group-surface island; geometry { height 36; edge-margin 6; }; center { group title { surface none; item title kind=focused-window visible=#false; }; }; end { group status { island-padding 9.5; item battery kind=battery percentage=#false; }; }; }";
let current = parse_test_source(source).unwrap();
let panel = &current.panels[0];
assert_eq!(panel.surface, PanelSurface::None);
assert_eq!(panel.group_surface, GroupSurface::Island);
assert_eq!(panel.geometry.height, 36.);
assert_eq!(panel.geometry.edge_margin, 6);
assert_eq!(panel.end.groups[0].island_padding, 9.5);
assert!(!panel.center.groups[0].items[0].visible);
assert_eq!( assert_eq!(
parse_test_source("").unwrap().status.bar_layout, panel.end.groups[0].items[0].kind,
ferese_config::BarLayout::Continuous ItemKind::Battery { percentage: false }
); );
let normal = parse_test_source("theme { material { style \"translucent\"; opacity 0.7; }; }").unwrap(); assert_eq!(parse_test_source("").unwrap().panels, defaults.panels);
let islands = parse_test_source(
"status { bar-layout \"islands\"; }\ntheme { material { style \"translucent\"; opacity 0.7; }; }",
)
.unwrap();
assert_eq!(islands.status.bar_layout, ferese_config::BarLayout::Islands);
assert_eq!(islands.theme.surface_popover, normal.theme.surface_popover);
assert_eq!(islands.theme.surface_base, normal.theme.surface_base);
assert_eq!(islands.theme.bar_background, normal.theme.bar_background);
assert!(parse_test_source("status { bar-layout \"invalid\"; }").is_err());
} }
#[test] #[test]
fn island_padding_accepts_compact_and_fractional_values_without_changing_panel_padding() { fn authored_composition_rejects_invalid_reload_data() {
let defaults = parse_test_source("").unwrap(); let source = r#"panel "custom" { surface "none"; group-surface "island"; end { group "clocks" { item "one" kind="clock"; item "two" kind="clock"; }; }; }"#;
assert_eq!(defaults.status.bar_island_padding, 4.); let config = parse_test_source(source).unwrap();
assert_eq!( assert_eq!(config.panels[0].id.0, "custom");
parse_test_source("status { bar-layout \"islands\"; }") assert_eq!(config.panels[0].surface, crate::panel::PanelSurface::None);
.unwrap() assert_eq!(config.panels[0].end.groups[0].items.len(), 2);
.status assert!(parse_test_source(&source.replace("item \"two\"", "item \"one\"")).is_err());
.bar_island_padding, assert!(parse_test_source("panel one; panel two;").is_err());
4. assert!(parse_test_source("panel one { edge left; }").is_err());
);
for padding in [0., 4., 4.5, 32.] {
let source = format!("status {{ bar-island-padding {padding}; }}");
let config = parse_test_source(&source).unwrap();
assert_eq!(config.status.bar_island_padding, padding);
assert_eq!(config.theme.panel_padding, defaults.theme.panel_padding);
} }
#[test]
fn group_surfaces_do_not_change_modal_opacity() {
let normal = parse_test_source("theme { material { style translucent; opacity 0.7; }; }").unwrap();
let islands = parse_test_source("panel main { surface none; group-surface island; }\ntheme { material { style translucent; opacity 0.7; }; }").unwrap();
assert_eq!(islands.theme.surface_popover, normal.theme.surface_popover);
assert_eq!(islands.theme.surface_base, normal.theme.surface_base);
assert_eq!(islands.theme.bar_background, normal.theme.bar_background);
assert!(parse_test_source("panel main { surface islands; }").is_err());
assert!(parse_test_source("status { bar-layout islands; }").is_err());
}
#[test]
fn island_padding_accepts_fractional_values_independently_of_panel_geometry() {
for padding in [0., 4., 4.5, 32.] {
let source = format!(
"panel main {{ geometry {{ inner-padding 12; }}; end {{ group status {{ island-padding {padding}; }}; }}; }}"
);
let config = parse_test_source(&source).unwrap();
assert_eq!(config.panels[0].end.groups[0].island_padding, padding);
assert_eq!(config.panels[0].geometry.inner_padding, 12.);
}
for value in ["-1", "32.5", "\"small\""] { for value in ["-1", "32.5", "\"small\""] {
assert!(parse_test_source(&format!("status {{ bar-island-padding {value}; }}")).is_err()); assert!(
parse_test_source(&format!(
"panel main {{ end {{ group status {{ island-padding {value}; }}; }}; }}"
))
.is_err()
);
} }
} }
@@ -708,18 +757,44 @@ theme {
} }
#[test] #[test]
fn shell_radius_unifies_surfaces_and_preserves_legacy_fallbacks() { fn shell_radius_unifies_surfaces() {
for radius in [0.0, 18.0] { for radius in [0.0, 18.0] {
let config = parse_test_source(&format!("appearance {{\n corner-radius 9\n}}\ntheme {{\n geometry {{\n shell-radius {radius}\n top-bar-radius 5\n window-radius 23\n }}\n}}")).unwrap(); let config = parse_test_source(&format!(
"theme {{\n geometry {{\n shell-radius {radius}\n window-radius 23\n }}\n}}"
))
.unwrap();
assert_eq!(config.theme.material_radius, radius); assert_eq!(config.theme.material_radius, radius);
assert_eq!(config.theme.bar_radius, radius); assert_eq!(config.theme.bar_radius, radius);
} }
let legacy = parse_test_source("theme {\n geometry {\n top-bar-radius 7\n }\n}").unwrap(); }
assert_eq!(legacy.theme.material_radius, 7.0);
assert_eq!(legacy.theme.bar_radius, 7.0); #[test]
let legacy = parse_test_source("appearance {\n corner-radius 6\n}").unwrap(); fn startup_rejects_invalid_configuration_without_replacing_it() {
assert_eq!(legacy.theme.material_radius, 6.0); let directory = tempfile::tempdir().unwrap();
assert_eq!(legacy.theme.bar_radius, 6.0); let path = directory.path().join("config.kdl");
let source =
"notifications { do-not-disturb #true; }\nstatus { bar-layout islands; battery-percentage #false; }";
std::fs::write(&path, source).unwrap();
let error = super::load_path(&path).unwrap_err();
assert!(error.contains("bar_layout"), "{error}");
assert!(error.contains(path.to_str().unwrap()), "{error}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), source);
std::fs::write(&path, "panel main {").unwrap();
assert!(super::load_path(&path).is_err());
}
#[test]
fn startup_distinguishes_absent_configuration_from_read_errors() {
let directory = tempfile::tempdir().unwrap();
assert!(super::load_path(&directory.path().join("missing.kdl")).is_ok());
let error = super::load_path(directory.path()).unwrap_err();
assert!(error.contains("cannot read"), "{error}");
let path = directory.path().join("config.kdl");
std::os::unix::fs::symlink(directory.path().join("missing-target"), &path).unwrap();
assert!(
super::load_path(&path).is_err(),
"a broken config symlink is a read error"
);
} }
#[test] #[test]
@@ -733,9 +808,9 @@ theme {
); );
assert_eq!(config.theme.background.mode, WallpaperMode::Fill); assert_eq!(config.theme.background.mode, WallpaperMode::Fill);
let theme = shell_theme(&config.theme); let theme = shell_theme(&config.theme);
assert_eq!(theme.bar_margin_top, 0); assert_eq!(crate::panel::PanelGeometry::default().edge_margin, 0);
assert_eq!(theme.bar_height, 28.0); assert_eq!(crate::panel::PanelGeometry::default().height, 28.0);
assert_eq!(theme.bar_margin_horizontal, 0); assert_eq!(crate::panel::PanelGeometry::default().side_margins, 0);
assert_eq!(theme.bar_radius, 14.0); assert_eq!(theme.bar_radius, 14.0);
assert_eq!(theme.bar_background[3], 255); assert_eq!(theme.bar_background[3], 255);
assert_eq!(theme.for_bar().text_primary, theme.bar_text_primary); assert_eq!(theme.for_bar().text_primary, theme.bar_text_primary);
+3 -6
View File
@@ -385,11 +385,7 @@ impl FereseShell {
pub(super) fn display_mode_rows(&self) -> cosmic::widget::Column<'_, cosmic::Action<Message>, cosmic::Theme> { pub(super) fn display_mode_rows(&self) -> cosmic::widget::Column<'_, cosmic::Action<Message>, cosmic::Theme> {
let palette = self.config.theme.palette(); let palette = self.config.theme.palette();
let material_opacity = if self.config.theme.high_contrast { let material_opacity = self.config.theme.material_opacity();
1.0
} else {
f32::from(self.config.theme.surface_popover[3]) / 255.0
};
let model = &self.display_mode; let model = &self.display_mode;
let focus_visible = self.system_modal.as_ref().is_some_and(|modal| modal.focus_visible); let focus_visible = self.system_modal.as_ref().is_some_and(|modal| modal.focus_visible);
let pending = model.pending(); let pending = model.pending();
@@ -506,9 +502,10 @@ impl FereseShell {
palette, palette,
false false
) )
.class(ferese_theme::controls::button_style_with_focus( .class(ferese_theme::controls::material_button_style_with_focus(
palette, palette,
false, false,
material_opacity,
focus_visible focus_visible
)) ))
.id("ferese-modal-cancel".into()) .id("ferese-modal-cancel".into())
+375 -67
View File
@@ -17,11 +17,14 @@ mod motion;
mod note_store; mod note_store;
mod notification_ui; mod notification_ui;
mod notifications; mod notifications;
mod panel;
mod panel_layout;
mod recording; mod recording;
mod renderer; mod renderer;
mod status; mod status;
mod status_ui; mod status_ui;
mod system_modal; mod system_modal;
mod workspace_ui;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -89,9 +92,9 @@ struct BarMetrics {
group_item_height: f32, group_item_height: f32,
} }
impl From<ShellTheme> for BarMetrics { impl From<panel::PanelGeometry> for BarMetrics {
fn from(theme: ShellTheme) -> Self { fn from(geometry: panel::PanelGeometry) -> Self {
let height = theme.bar_height; let height = geometry.height;
let control_height = (height - 4.0).max(21.0).min(height); let control_height = (height - 4.0).max(21.0).min(height);
// Leave two logical pixels inside the group border on each side, // Leave two logical pixels inside the group border on each side,
// including compact bars with 20 px icons. // including compact bars with 20 px icons.
@@ -112,6 +115,10 @@ impl From<ShellTheme> for BarMetrics {
} }
fn main() -> cosmic::iced::Result { fn main() -> cosmic::iced::Result {
let mut config = config::load().unwrap_or_else(|error| {
eprintln!("ferese-shell: {error}; shell was not started");
std::process::exit(2);
});
renderer::configure_shell(); renderer::configure_shell();
cosmic::iced::advanced::graphics::text::font_system() cosmic::iced::advanced::graphics::text::font_system()
@@ -127,7 +134,6 @@ fn main() -> cosmic::iced::Result {
"../../../assets/fonts/Cantarell-ExtraBold.otf" "../../../assets/fonts/Cantarell-ExtraBold.otf"
))); )));
let mut config = config::load();
motion::configure(config.animations, config.theme.material_radius); motion::configure(config.animations, config.theme.material_radius);
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some(); let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
if compositor_wallpaper { if compositor_wallpaper {
@@ -164,11 +170,11 @@ type WallpaperLoad = cosmic::iced::futures::channel::oneshot::Receiver<Result<im
struct FereseShell { struct FereseShell {
core: Core, core: Core,
bar_surface_id: window::Id,
config: ShellConfig, config: ShellConfig,
wallpaper: Option<image::Handle>, wallpaper: Option<image::Handle>,
control: Option<ShellControl>, control: Option<ShellControl>,
snapshot: ShellSnapshot, snapshot: ShellSnapshot,
workspace_ui: workspace_ui::WorkspaceUi,
overview_active: bool, overview_active: bool,
clock: String, clock: String,
clock_service: clock::Service, clock_service: clock::Service,
@@ -226,12 +232,15 @@ struct OutputSurfaces {
size: Option<(i32, i32)>, size: Option<(i32, i32)>,
effects: Option<EffectsBinding>, effects: Option<EffectsBinding>,
bar_regions: Vec<[f32; 5]>, bar_regions: Vec<[f32; 5]>,
panel_resolution: Option<panel_layout::Resolution>,
bar_margin_horizontal: i32,
hidden: bool, hidden: bool,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Message { enum Message {
BarRegionsChanged(window::Id, Vec<[f32; 5]>), BarRegionsChanged(window::Id, Vec<[f32; 5]>),
PanelResolved(window::Id, panel_layout::Resolution),
ThemeChanged(Box<ferese_ipc::theme::Snapshot>), ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
ThemeMode(ferese_config::theme::Mode), ThemeMode(ferese_config::theme::Mode),
ThemeModeSet(Result<(), String>), ThemeModeSet(Result<(), String>),
@@ -258,6 +267,10 @@ enum Message {
MediaArt(media::Artwork), MediaArt(media::Artwork),
ControlReady, ControlReady,
ActivateWorkspace(u64), ActivateWorkspace(u64),
HoverWorkspace(window::Id, u64, cosmic::iced::advanced::widget::Id, bool),
WorkspaceTooltipDelay(u64),
WorkspaceTooltipBounds(u64, Option<cosmic::iced::Rectangle>),
WorkspaceIconsLoaded(Result<workspace_ui::IconIndex, String>),
ToggleOverview, ToggleOverview,
StatusUpdated(status::Update), StatusUpdated(status::Update),
StartRecording, StartRecording,
@@ -273,8 +286,9 @@ enum Message {
ToggleNotificationGroup(String), ToggleNotificationGroup(String),
RemoveNotificationGroup(String), RemoveNotificationGroup(String),
AnimateMenu, AnimateMenu,
PanelAction(Box<Message>),
OpenMenu(status_ui::Menu, cosmic::iced::Rectangle<i32>), OpenMenu(status_ui::Menu, cosmic::iced::Rectangle<i32>),
OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle<i32>), OpenPopover(status_ui::Menu, status_ui::PopoverAnchor),
Control(status::Action), Control(status::Action),
ShowGuide, ShowGuide,
GuideLoaded { GuideLoaded {
@@ -316,11 +330,9 @@ impl cosmic::Application for FereseShell {
} }
fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task<Self::Message>) { fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task<Self::Message>) {
let bar_surface_id = window::Id::unique();
let desktop_clock = config.desktop_widgets.clock.labels(&Zoned::now()).unwrap_or_default(); let desktop_clock = config.desktop_widgets.clock.labels(&Zoned::now()).unwrap_or_default();
let app = Self { let app = Self {
core, core,
bar_surface_id,
notifications: notifications::Center::new(config.notifications.clone()), notifications: notifications::Center::new(config.notifications.clone()),
notification_surface: None, notification_surface: None,
status_service: status::Service::start(config.status.settings_command.clone()), status_service: status::Service::start(config.status.settings_command.clone()),
@@ -353,6 +365,7 @@ impl cosmic::Application for FereseShell {
}) })
.ok(), .ok(),
snapshot: ShellSnapshot::default(), snapshot: ShellSnapshot::default(),
workspace_ui: Default::default(),
overview_active: false, overview_active: false,
clock: current_time(), clock: current_time(),
desktop_clock, desktop_clock,
@@ -389,7 +402,7 @@ impl cosmic::Application for FereseShell {
.load(Ordering::Relaxed) .load(Ordering::Relaxed)
.then_some(Message::Event(event, id)), .then_some(Message::Event(event, id)),
Event::Keyboard(_) Event::Keyboard(_)
| Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_)) | Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_) | cosmic::iced::mouse::Event::CursorLeft)
| Event::Window(window::Event::Opened { .. } | window::Event::Closed) | Event::Window(window::Event::Opened { .. } | window::Event::Closed)
| Event::PlatformSpecific(PlatformSpecific::Wayland( | Event::PlatformSpecific(PlatformSpecific::Wayland(
wayland::Event::Popup(..) | wayland::Event::Layer(..) | wayland::Event::Output(..), wayland::Event::Popup(..) | wayland::Event::Layer(..) | wayland::Event::Output(..),
@@ -429,6 +442,38 @@ impl cosmic::Application for FereseShell {
fn update(&mut self, message: Self::Message) -> Task<Self::Message> { fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
match message { match message {
Message::PanelResolved(id, resolution) => {
let allocation_changed = self
.outputs
.iter()
.find(|output| output.bar == id)
.is_some_and(|output| {
output
.panel_resolution
.as_ref()
.is_some_and(|previous| previous != &resolution)
});
let close_menu = self.menu.as_ref().is_some_and(|menu| {
menu.anchor.parent == id
&& menu.anchor.item.is_some()
&& ((menu.kind == status_ui::Menu::Overflow && resolution.overflow.is_empty())
|| allocation_changed)
});
if let Some(output) = self.outputs.iter_mut().find(|output| output.bar == id) {
output.panel_resolution = Some(resolution);
}
let margin = self.update_panel_margin(id, false);
let tooltip = if allocation_changed {
self.dismiss_workspace_tooltip_for_bar(id)
} else {
Task::none()
};
if close_menu {
Task::batch([margin, tooltip, self.destroy_menu()])
} else {
Task::batch([margin, tooltip])
}
}
Message::BarRegionsChanged(id, regions) => { Message::BarRegionsChanged(id, regions) => {
let Some(output) = self.outputs.iter_mut().find(|output| output.bar == id) else { let Some(output) = self.outputs.iter_mut().find(|output| output.bar == id) else {
return Task::none(); return Task::none();
@@ -437,7 +482,7 @@ impl cosmic::Application for FereseShell {
output.bar_regions = regions; output.bar_regions = regions;
set_input_zone( set_input_zone(
id, id,
bar::input_region(self.config.status.bar_layout, output.hidden, &output.bar_regions), bar::input_region(self.config.panels[0].surface, output.hidden, &output.bar_regions),
) )
} }
@@ -580,7 +625,7 @@ impl cosmic::Application for FereseShell {
} }
Message::NativeSurface(id, result) => { Message::NativeSurface(id, result) => {
match result { match result {
Ok((_connection, surface)) if self.outputs.iter().any(|entry| entry.bar == id) => { Ok((_connection, surface)) if self.outputs.iter().any(|output| output.bar == id) => {
self.attach_effects(id, &surface) self.attach_effects(id, &surface)
} }
Ok((_connection, surface)) => { Ok((_connection, surface)) => {
@@ -676,16 +721,15 @@ impl cosmic::Application for FereseShell {
self.sync_notification_surface() self.sync_notification_surface()
} }
Message::AnimateMenu => self.animate_menu(), Message::AnimateMenu => self.animate_menu(),
Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor), Message::PanelAction(message) => {
Message::OpenMenuOn(id, kind, anchor) => { let close = self.destroy_menu();
if self.bar_surface_id != id { let action = self.update(*message);
let destroy = self.destroy_menu(); close.chain(action)
self.bar_surface_id = id;
let open = self.open_menu(kind, anchor);
Task::batch([destroy, open])
} else {
self.open_menu(kind, anchor)
} }
Message::OpenMenu(..) => Task::none(), // Bar views attach an explicit item/surface anchor.
Message::OpenPopover(kind, anchor) => {
let tooltip = self.dismiss_workspace_tooltip();
Task::batch([tooltip, self.open_menu(kind, anchor)])
} }
Message::ShowGuide => { Message::ShowGuide => {
if self.guide_shown if self.guide_shown
@@ -831,15 +875,18 @@ impl cosmic::Application for FereseShell {
if let Some(active) = poll.overview_active { if let Some(active) = poll.overview_active {
self.overview_active = active; self.overview_active = active;
if active {
reload_task = self.dismiss_workspace_tooltip();
}
} }
if let Some(source) = poll.config { if let Some(source) = poll.config {
reload_task = self.reload_config(source); reload_task = Task::batch([reload_task, self.reload_config(source)]);
} }
if let Some(snapshot) = poll.snapshot { if let Some(snapshot) = poll.snapshot {
self.snapshot = snapshot; self.snapshot = snapshot;
let mut tasks = vec![reload_task]; let mut tasks = vec![reload_task, self.update_workspace_ui()];
let mut visibility_changed = false; let mut visibility_changed = false;
for entry in &mut self.outputs { for entry in &mut self.outputs {
@@ -864,13 +911,18 @@ impl cosmic::Application for FereseShell {
} }
tasks.push(set_input_zone( tasks.push(set_input_zone(
entry.bar, entry.bar,
bar::input_region(self.config.status.bar_layout, hidden, &entry.bar_regions), bar::input_region(self.config.panels[0].surface, hidden, &entry.bar_regions),
)); ));
} }
if self.bar_hidden(self.bar_surface_id) { if self
.menu
.as_ref()
.is_some_and(|menu| self.bar_hidden(menu.anchor.parent))
{
tasks.push(self.destroy_menu()); tasks.push(self.destroy_menu());
} }
if visibility_changed { if visibility_changed {
tasks.push(self.dismiss_workspace_tooltip());
self.refresh_media_art(false); self.refresh_media_art(false);
} }
@@ -895,14 +947,20 @@ impl cosmic::Application for FereseShell {
if let Some(control) = &self.control { if let Some(control) = &self.control {
control.activate_workspace(id); control.activate_workspace(id);
} }
Task::none() self.dismiss_workspace_tooltip()
} }
Message::HoverWorkspace(bar, workspace, target, entered) => {
self.hover_workspace(bar, workspace, target, entered)
}
Message::WorkspaceTooltipDelay(serial) => self.locate_workspace_tooltip(serial),
Message::WorkspaceTooltipBounds(serial, bounds) => self.show_workspace_tooltip(serial, bounds),
Message::WorkspaceIconsLoaded(result) => self.workspace_icons_loaded(result),
Message::ToggleOverview => { Message::ToggleOverview => {
self.overview_active = !self.overview_active; self.overview_active = !self.overview_active;
if let Some(control) = &self.control { if let Some(control) = &self.control {
control.set_overview_active(self.overview_active); control.set_overview_active(self.overview_active);
} }
Task::none() self.dismiss_workspace_tooltip()
} }
} }
} }
@@ -928,6 +986,8 @@ impl FereseShell {
Ok(config) => config, Ok(config) => config,
Err(error) => { Err(error) => {
eprintln!("ferese-shell: reload rejected; keeping current config: {error}"); eprintln!("ferese-shell: reload rejected; keeping current config: {error}");
self.notifications
.service_error("Configuration could not be loaded", &error.to_string());
return Task::none(); return Task::none();
} }
}; };
@@ -960,7 +1020,10 @@ impl FereseShell {
self.status_service self.status_service
.update_settings(config.status.settings_command.clone()); .update_settings(config.status.settings_command.clone());
let composition_changed = !self.config.panels[0].same_composition(&config.panels[0]);
let edge_changed = self.config.panels[0].edge != config.panels[0].edge;
let old = self.config.theme; let old = self.config.theme;
let old_geometry = self.config.panels[0].geometry;
let old_clock = &self.config.desktop_widgets.clock; let old_clock = &self.config.desktop_widgets.clock;
let old_notes = &self.config.desktop_widgets.notes; let old_notes = &self.config.desktop_widgets.notes;
let new_notes = &config.desktop_widgets.notes; let new_notes = &config.desktop_widgets.notes;
@@ -975,24 +1038,38 @@ impl FereseShell {
|| old_clock.margin_x != new_clock.margin_x || old_clock.margin_x != new_clock.margin_x
|| old_clock.margin_y != new_clock.margin_y; || old_clock.margin_y != new_clock.margin_y;
let theme = config.theme; let theme = config.theme;
let geometry_changed = old.bar_height != theme.bar_height let geometry = config.panels[0].geometry;
|| old.bar_margin_top != theme.bar_margin_top let geometry_changed = edge_changed || old_geometry != geometry;
|| old.bar_margin_horizontal != theme.bar_margin_horizontal
|| old.bar_window_gap != theme.bar_window_gap;
self.notifications.configure(config.notifications.clone()); self.notifications.configure(config.notifications.clone());
self.clock_service.configure(&config.desktop_widgets.clock); self.clock_service.configure(&config.desktop_widgets.clock);
self.config = config; self.config = config;
let workspace_task = self.update_workspace_ui();
self.desktop_clock = self self.desktop_clock = self
.config .config
.desktop_widgets .desktop_widgets
.clock .clock
.labels(&Zoned::now()) .labels(&Zoned::now())
.unwrap_or_default(); .unwrap_or_default();
let mut tasks = vec![if clock_changed { let mut tasks = vec![
workspace_task,
if clock_changed {
self.rebuild_clocks(true) self.rebuild_clocks(true)
} else { } else {
Task::none() Task::none()
}]; },
];
if composition_changed || geometry_changed {
tasks.push(self.destroy_menu());
for output in &mut self.outputs {
// Keep the measured allocation and margins until the next layout
// publishes its replacement through PanelResolved.
tasks.push(set_input_zone(
output.bar,
bar::input_region(self.config.panels[0].surface, output.hidden, &output.bar_regions),
));
}
}
if !self.guide_load.manual if !self.guide_load.manual
&& !self.config.status.keybinding_guide && !self.config.status.keybinding_guide
@@ -1025,22 +1102,51 @@ impl FereseShell {
} }
for entry in self.outputs.iter().filter(|_| geometry_changed) { for entry in self.outputs.iter().filter(|_| geometry_changed) {
tasks.push(set_size(entry.bar, None, Some(theme.bar_height.round() as u32))); let (anchor, _) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
tasks.push(set_margin( tasks.push(set_anchor(entry.bar, anchor | Anchor::LEFT | Anchor::RIGHT));
entry.bar, tasks.push(set_size(entry.bar, None, Some(geometry.height.round() as u32)));
theme.bar_margin_top,
theme.bar_margin_horizontal,
0,
theme.bar_margin_horizontal,
));
tasks.push(set_exclusive_zone( tasks.push(set_exclusive_zone(
entry.bar, entry.bar,
(theme.bar_height.round() as i32).saturating_add(theme.bar_window_gap), (geometry.height.round() as i32).saturating_add(geometry.window_clearance),
)); ));
} }
let bars: Vec<_> = self.outputs.iter().map(|entry| entry.bar).collect();
for bar in bars {
tasks.push(self.update_panel_margin(bar, edge_changed || old_geometry.edge_margin != geometry.edge_margin));
}
if geometry_changed {
tasks.push(self.dismiss_workspace_tooltip());
if let Some(surface) = self.notification_surface.take() {
tasks.push(destroy_layer_surface(surface.id));
}
tasks.push(self.sync_notification_surface());
}
Task::batch(tasks) Task::batch(tasks)
} }
fn update_panel_margin(&mut self, bar: window::Id, force: bool) -> Task<Message> {
let geometry = self.config.panels[0].geometry;
let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == bar) else {
return Task::none();
};
let margin = entry.size.zip(entry.panel_resolution.as_ref()).map_or(
entry.bar_margin_horizontal,
|((width, _), resolution)| {
resolution.side_margin(
geometry.side_margins,
width,
f32::from(geometry.inner_padding.round() as u16),
)
},
);
if entry.bar_margin_horizontal == margin && !force {
return Task::none();
}
entry.bar_margin_horizontal = margin;
let (_, margin) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, margin);
set_margin(bar, margin.top, margin.right, margin.bottom, margin.left)
}
} }
fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style { fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style {
@@ -1060,6 +1166,231 @@ fn color_with_opacity(mut value: [u8; 4], opacity: f32) -> Color {
mod tests { mod tests {
use super::*; use super::*;
use crate::control::{OutputSnapshot, WindowSnapshot}; use crate::control::{OutputSnapshot, WindowSnapshot};
pub(super) fn shell_with_measured_panel() -> FereseShell {
let config = ShellConfig::default();
// This output is never dispatched; tests use only its stored geometry.
let (socket, _peer) = std::os::unix::net::UnixStream::pair().unwrap();
let connection = Connection::from_socket(socket).unwrap();
let output = wl_output::WlOutput::inert(connection.backend().downgrade());
FereseShell {
core: Core::default(),
notifications: notifications::Center::new(config.notifications.clone()),
notification_surface: None,
status_service: status::Service::start(None),
status: Default::default(),
recorder: Default::default(),
media: Default::default(),
calendar_offset: 0,
status_error: None,
theme_error: None,
menu: None,
system_modal: None,
display_mode: Default::default(),
guide_shown: false,
guide_load: Default::default(),
guide_attempts: 0,
pending_power: None,
note_editor: None,
note_drag: None,
note_pointer: Default::default(),
note_pending: Vec::new(),
note_inflight: Vec::new(),
note_saving: false,
note_error: None,
clock_service: clock::Service::new(&config.desktop_widgets.clock),
config,
wallpaper: None,
control: None,
snapshot: Default::default(),
workspace_ui: Default::default(),
overview_active: false,
clock: String::new(),
desktop_clock: Default::default(),
outputs: vec![OutputSurfaces {
output,
name: Some("test".into()),
bar: window::Id::unique(),
wallpaper: None,
clock: None,
notes: Vec::new(),
size: Some((1200, 800)),
effects: None,
bar_regions: vec![[0., 0., 600., 36., 8.]],
panel_resolution: Some(panel_layout::Resolution {
minimum_width: 600.,
..Default::default()
}),
bar_margin_horizontal: 0,
hidden: false,
}],
}
}
#[test]
fn panel_placement_uses_only_the_selected_edge_for_anchor_and_margin() {
use ferese_config::panel::Edge;
for gap in [0, 12] {
let (anchor, margin) = surfaces::panel_placement(Edge::Top, gap, 32);
assert_eq!(anchor, Anchor::TOP);
assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (gap, 32, 0, 32));
let (anchor, margin) = surfaces::panel_placement(Edge::Bottom, gap, 32);
assert_eq!(anchor, Anchor::BOTTOM);
assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 32, gap, 32));
}
}
#[test]
fn cosmetic_reloads_preserve_measured_margins_and_the_open_popover() {
let mut shell = shell_with_measured_panel();
shell.config.panels[0].geometry.side_margins = 240;
drop(shell.update_panel_margin(shell.outputs[0].bar, false));
assert_eq!(shell.outputs[0].bar_margin_horizontal, 240);
let popup = window::Id::unique();
shell.menu = Some(status_ui::OpenMenu {
id: popup,
anchor: status_ui::PopoverAnchor {
parent: shell.outputs[0].bar,
panel: shell.config.panels[0].id.clone(),
item: Some(panel::ItemId("clock".into())),
rectangle: Default::default(),
},
kind: status_ui::Menu::Calendar,
motion: motion::PopupMotion::new(Default::default()),
effects: None,
regions: Default::default(),
});
let resolution = shell.outputs[0].panel_resolution.clone();
for (field, value) in [
("background_opacity", serde_json::json!(0.5)),
("corner_radius", serde_json::json!("4px 8px")),
("border", serde_json::json!(true)),
("group_surface", serde_json::json!("inset")),
] {
let edit = field;
let mut document = ferese_config::Document::parse("").unwrap();
document
.set("panels", serde_json::to_value(&shell.config.panels).unwrap())
.unwrap();
document.set(&format!("panels.0.{field}"), value).unwrap();
let source = document.to_string();
let mut config = config::parse_source(&source).unwrap();
config.theme = shell.config.theme;
drop(shell.apply_config(config));
assert_eq!(shell.outputs[0].panel_resolution, resolution, "{edit}");
assert_eq!(shell.outputs[0].bar_margin_horizontal, 240, "{edit}");
assert_eq!(shell.menu.as_ref().unwrap().id, popup, "{edit}");
}
// A real membership edit closes the popup, but retains the last allocation
// until the adaptive widget measures and publishes its replacement.
let mut config = shell.config.clone();
config.panels[0].end.groups.clear();
drop(shell.apply_config(config));
assert!(shell.menu.is_none());
assert_eq!(shell.outputs[0].panel_resolution, resolution);
assert_eq!(shell.outputs[0].bar_margin_horizontal, 240);
use cosmic::Application;
let replacement = panel_layout::Resolution {
minimum_width: 900.,
..Default::default()
};
drop(shell.update(Message::PanelResolved(shell.outputs[0].bar, replacement.clone())));
assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(&replacement));
assert!(shell.outputs[0].bar_margin_horizontal < 240);
let before = shell.outputs[0].bar_margin_horizontal;
drop(shell.reload_config("panel broken {".into()));
assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(&replacement));
assert_eq!(shell.outputs[0].bar_margin_horizontal, before);
}
#[test]
fn panel_reallocation_dismisses_popovers_in_both_overflow_directions() {
use cosmic::Application;
use panel_layout::{Placement, Resolution};
let item = panel::ItemId("network".into());
let visible = Resolution {
items: [(
item.clone(),
Placement::Visible {
representation: panel::Representation::Icon,
width: 28.,
},
)]
.into(),
zone_widths: [0., 0., 40.],
..Default::default()
};
let overflow = Resolution {
items: [(item.clone(), Placement::Overflow)].into(),
overflow: vec![item.clone()],
zone_widths: [0., 0., 32.],
overflow_trigger_width: 24.,
..Default::default()
};
let mut moved = visible.clone();
moved.zone_widths[2] = 160.;
for (before, after, kind) in [
(&overflow, &visible, status_ui::Menu::Network),
(&visible, &overflow, status_ui::Menu::Network),
(&visible, &moved, status_ui::Menu::Network),
(&overflow, &visible, status_ui::Menu::Overflow),
] {
let mut shell = shell_with_measured_panel();
let bar = shell.outputs[0].bar;
shell.outputs[0].panel_resolution = Some(before.clone());
shell.menu = Some(status_ui::OpenMenu {
id: window::Id::unique(),
anchor: status_ui::PopoverAnchor {
parent: bar,
panel: shell.config.panels[0].id.clone(),
item: Some(if kind == status_ui::Menu::Overflow {
panel::ItemId("_overflow".into())
} else {
item.clone()
}),
rectangle: cosmic::iced::Rectangle {
x: 1100,
y: 0,
width: 28,
height: 36,
},
},
kind,
motion: motion::PopupMotion::new(Default::default()),
effects: None,
regions: Default::default(),
});
let popup = shell.menu.as_ref().unwrap().id;
drop(shell.update(Message::PanelResolved(window::Id::unique(), after.clone())));
assert_eq!(
shell.menu.as_ref().unwrap().id,
popup,
"another output must not dismiss this popup"
);
drop(shell.update(Message::PanelResolved(bar, before.clone())));
assert_eq!(
shell.menu.as_ref().unwrap().id,
popup,
"unchanged allocation must preserve the popup"
);
drop(shell.update(Message::PanelResolved(bar, after.clone())));
assert!(shell.menu.is_none(), "stale {kind:?} popup must be destroyed");
assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(after));
}
}
#[test]
fn an_empty_measured_panel_is_not_an_unmeasured_panel() {
let mut shell = shell_with_measured_panel();
let bar = shell.outputs[0].bar;
shell.config.panels[0].geometry.side_margins = 240;
shell.outputs[0].panel_resolution = None;
drop(shell.update_panel_margin(bar, false));
assert_eq!(shell.outputs[0].bar_margin_horizontal, 0);
shell.outputs[0].panel_resolution = Some(Default::default());
drop(shell.update_panel_margin(bar, false));
assert_eq!(shell.outputs[0].bar_margin_horizontal, 240);
}
#[test] #[test]
fn widget_drag_stops_at_edges_without_tunnelling_and_slides() { fn widget_drag_stops_at_edges_without_tunnelling_and_slides() {
use cosmic::iced::{Point, Rectangle}; use cosmic::iced::{Point, Rectangle};
@@ -1150,29 +1481,6 @@ mod tests {
assert_eq!(before.border, after.border); assert_eq!(before.border, after.border);
assert_eq!(before.background, after.background); assert_eq!(before.background, after.background);
assert_eq!(before.border.color, color(theme.border)); assert_eq!(before.border.color, color(theme.border));
assert_eq!(
workspace_selector_style(false, false, true, theme).background,
workspace_selector_style(false, false, true, contrasted).background,
);
}
#[test]
fn workspace_selector_has_distinct_active_paint() {
let theme = ShellTheme::default();
let active = workspace_selector_style(true, false, false, theme);
let occupied = workspace_selector_style(false, false, true, theme);
let inactive = workspace_selector_style(false, false, false, theme);
let remote = workspace_selector_style(false, true, false, theme);
assert!(active.background.is_some());
assert_eq!(
active.background,
Some(Background::Color(color_with_opacity(theme.accent, 0.16)))
);
assert!(occupied.background.is_some());
assert!(inactive.background.is_none());
assert_eq!(inactive.border.width, 0.0);
assert_eq!(remote.border.width, 1.0);
assert!(remote.background.is_none());
} }
#[test] #[test]
@@ -1223,9 +1531,9 @@ mod tests {
#[test] #[test]
fn compact_bar_preserves_logical_text_and_icon_sizes() { fn compact_bar_preserves_logical_text_and_icon_sizes() {
for height in [24.0, 26.0, 32.0, 38.0, 44.0] { for height in [24.0, 26.0, 32.0, 38.0, 44.0] {
let metrics = BarMetrics::from(ShellTheme { let metrics = BarMetrics::from(panel::PanelGeometry {
bar_height: height, height,
..ShellTheme::default() ..Default::default()
}); });
assert_eq!(metrics.text_size, 14); assert_eq!(metrics.text_size, 14);
assert_eq!(metrics.icon_size, 20); assert_eq!(metrics.icon_size, 20);
+17 -1
View File
@@ -358,11 +358,26 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
} }
pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32, regions: Regions, radius: f32) -> Element<'a, M> { pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32, regions: Regions, radius: f32) -> Element<'a, M> {
animated_from_edge(content, progress, regions, radius, ferese_config::panel::Edge::Top)
}
pub fn animated_from_edge<'a, M: 'a>(
content: Element<'a, M>,
progress: f32,
regions: Regions,
radius: f32,
edge: ferese_config::panel::Edge,
) -> Element<'a, M> {
Element::new(Motion { Element::new(Motion {
content, content,
progress, progress,
regions, regions,
radius, radius,
direction: if edge == ferese_config::panel::Edge::Top {
-1.
} else {
1.
},
}) })
} }
@@ -371,11 +386,12 @@ struct Motion<'a, M> {
progress: f32, progress: f32,
regions: Regions, regions: Regions,
radius: f32, radius: f32,
direction: f32,
} }
impl<M> Motion<'_, M> { impl<M> Motion<'_, M> {
fn translation(&self) -> Vector { fn translation(&self) -> Vector {
Vector::new(0.0, -4.0 * (1.0 - self.progress)) Vector::new(0.0, self.direction * 4.0 * (1.0 - self.progress))
} }
fn cursor(&self, cursor: mouse::Cursor) -> mouse::Cursor { fn cursor(&self, cursor: mouse::Cursor) -> mouse::Cursor {
+111 -74
View File
@@ -26,6 +26,21 @@ fn blend(base: Color, foreground: Color, amount: f32) -> Color {
} }
} }
fn icon_well<'a>(
content: impl Into<Element<'a, cosmic::Action<Message>>>,
foreground: Color,
extent: u16,
) -> Element<'a, cosmic::Action<Message>> {
container(content)
.center_x(extent)
.center_y(extent)
.class(ferese_theme::controls::surface(
foreground.scale_alpha(0.07),
motion::radius(f32::from(extent) / 2.0),
))
.into()
}
fn age_label(age: Duration) -> String { fn age_label(age: Duration) -> String {
match age.as_secs() { match age.as_secs() {
0..60 => "now".into(), 0..60 => "now".into(),
@@ -39,7 +54,6 @@ fn card_button<'a>(
content: impl Into<Element<'a, cosmic::Action<Message>>>, content: impl Into<Element<'a, cosmic::Action<Message>>>,
message: Message, message: Message,
foreground: Color, foreground: Color,
hover: Color,
round: bool, round: bool,
accessible_name: String, accessible_name: String,
) -> Element<'a, cosmic::Action<Message>> { ) -> Element<'a, cosmic::Action<Message>> {
@@ -50,7 +64,7 @@ fn card_button<'a>(
.on_press(cosmic::Action::App(message)) .on_press(cosmic::Action::App(message))
.class(ferese_theme::controls::notification_button( .class(ferese_theme::controls::notification_button(
foreground, foreground,
hover, foreground.scale_alpha(0.08),
if round { motion::radius(11.) } else { 0. }, if round { motion::radius(11.) } else { 0. },
round, round,
)) ))
@@ -88,7 +102,6 @@ fn icon_control<'a>(
.center_y(extent), .center_y(extent),
message, message,
foreground, foreground,
background,
true, true,
label.into(), label.into(),
); );
@@ -122,11 +135,18 @@ impl FereseShell {
pub(super) fn notification_history_height_limit(&self) -> f32 { pub(super) fn notification_history_height_limit(&self) -> f32 {
self.outputs self.outputs
.iter() .iter()
.find(|output| output.bar == self.bar_surface_id) .find(|output| {
Some(output.bar)
== self
.menu
.as_ref()
.map(|menu| menu.anchor.parent)
.or_else(|| self.active_bar())
})
.and_then(|output| output.size) .and_then(|output| output.size)
.map_or(HISTORY_MAX_HEIGHT, |(_, height)| { .map_or(HISTORY_MAX_HEIGHT, |(_, height)| {
let theme = self.config.theme; let geometry = self.config.panels[0].geometry;
(height as f32 - theme.bar_margin_top as f32 - theme.bar_height - 8.0 - 12.0).max(1.0) (height as f32 - geometry.edge_margin as f32 - geometry.height - 8.0 - 12.0).max(1.0)
}) })
.min(HISTORY_MAX_HEIGHT) .min(HISTORY_MAX_HEIGHT)
} }
@@ -135,31 +155,29 @@ impl FereseShell {
if self.notifications.history_open { if self.notifications.history_open {
return self.close_menu(); return self.close_menu();
} }
// Cards can open history too. Anchor those requests to the active bar; // Toast requests use the focused output without changing popup parent selection.
// bell clicks pass their exact anchor through the normal menu path. let Some(parent) = self.active_bar() else {
if let Some(output) = self.outputs.iter().find(|entry| { return Task::none();
self.snapshot };
.outputs let panel = &self.config.panels[0];
.iter()
.any(|output| output.focused && Some(output.name.as_str()) == entry.name.as_deref())
}) {
self.bar_surface_id = output.bar;
}
let width = self let width = self
.outputs .outputs
.iter() .iter()
.find(|entry| entry.bar == self.bar_surface_id) .find(|entry| entry.bar == parent)
.and_then(|entry| entry.size) .and_then(|entry| entry.size)
.map_or(POPUP_WIDTH as i32, |size| size.0); .map_or(POPUP_WIDTH as i32, |size| size.0);
self.open_menu( let anchor = status_ui::PopoverAnchor {
status_ui::Menu::Notifications, parent,
cosmic::iced::Rectangle { panel: panel.id.clone(),
item: None,
rectangle: cosmic::iced::Rectangle {
x: (width - 48).max(0), x: (width - 48).max(0),
y: 0, y: 0,
width: 24, width: 24,
height: self.config.theme.bar_height.round() as i32, height: self.config.panels[0].geometry.height.round() as i32,
}, },
) };
self.open_menu(status_ui::Menu::Notifications, anchor)
} }
pub(super) fn close_notification_history(&mut self) -> Task<Message> { pub(super) fn close_notification_history(&mut self) -> Task<Message> {
@@ -218,7 +236,7 @@ impl FereseShell {
.find(|entry| entry.output == output) .find(|entry| entry.output == output)
.and_then(|entry| entry.size) .and_then(|entry| entry.size)
.map_or(1080, |size| size.1); .map_or(1080, |size| size.1);
let top = self.config.theme.bar_height + self.config.theme.bar_margin_top as f32 + 12.0; let top = self.config.panels[0].geometry.height + self.config.panels[0].geometry.edge_margin as f32 + 12.0;
let height = desired_height.min((output_height as f32 - top - 12.0).max(100.0) as u32); let height = desired_height.min((output_height as f32 - top - 12.0).max(100.0) as u32);
if let Some(surface) = &mut self.notification_surface { if let Some(surface) = &mut self.notification_surface {
if surface.height != height { if surface.height != height {
@@ -234,20 +252,18 @@ impl FereseShell {
effects: None, effects: None,
regions: Default::default(), regions: Default::default(),
}); });
let top = self.config.theme.bar_height + self.config.theme.bar_margin_top as f32 + 12.0; let (edge_anchor, mut margin) =
crate::surfaces::panel_placement(self.config.panels[0].edge, top as i32, 12);
margin.left = 0;
let action = cosmic::surface::action::app_layer_shell::<Self>( let action = cosmic::surface::action::app_layer_shell::<Self>(
|_| Default::default(), |_| Default::default(),
move |_| SctkLayerSurfaceSettings { move |_| SctkLayerSurfaceSettings {
id, id,
layer: Layer::Overlay, layer: Layer::Overlay,
keyboard_interactivity: KeyboardInteractivity::None, keyboard_interactivity: KeyboardInteractivity::None,
anchor: Anchor::TOP | Anchor::RIGHT, anchor: edge_anchor | Anchor::RIGHT,
output: IcedOutput::Output(output.clone()), output: IcedOutput::Output(output.clone()),
margin: IcedMargin { margin,
top: top as i32,
right: 12,
..Default::default()
},
exclusive_zone: -1, exclusive_zone: -1,
size: Some((Some(POPUP_WIDTH), Some(height))), size: Some((Some(POPUP_WIDTH), Some(height))),
size_limits: Limits::NONE, size_limits: Limits::NONE,
@@ -300,18 +316,7 @@ impl FereseShell {
icon::from_name(notice.icon.as_str()).size(18).icon() icon::from_name(notice.icon.as_str()).size(18).icon()
} }
.opacity(opacity); .opacity(opacity);
let icon_well = container(app_icon) let icon_well = icon_well(app_icon, foreground, 30);
.center_x(30)
.center_y(30)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(well)),
border: Border {
shape: BorderShape::Continuous,
radius: motion::radius(15.0).into(),
..Default::default()
},
..Default::default()
}));
let icon: Element<'_, cosmic::Action<Message>> = if count > 1 { let icon: Element<'_, cosmic::Action<Message>> = if count > 1 {
let badge_fill = ferese_theme::composite( let badge_fill = ferese_theme::composite(
color(palette.accent), color(palette.accent),
@@ -350,7 +355,7 @@ impl FereseShell {
]) ])
.into() .into()
} else { } else {
icon_well.into() icon_well
}; };
let app = if notice.app.is_empty() { let app = if notice.app.is_empty() {
"NOTIFICATION".to_owned() "NOTIFICATION".to_owned()
@@ -452,7 +457,6 @@ impl FereseShell {
.center_y(38), .center_y(38),
Message::InvokeNotification(id, key.clone()), Message::InvokeNotification(id, key.clone()),
accent, accent,
well,
false, false,
label.clone(), label.clone(),
)); ));
@@ -661,37 +665,17 @@ impl FereseShell {
self.notifications.dnd, self.notifications.dnd,
1.0, 1.0,
)); ));
let controls = container(dnd) let controls = ferese_theme::menus::section(dnd.into(), primary, opacity, palette.material_radius);
.padding(10)
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(base)),
border: Border {
shape: BorderShape::Continuous,
radius: palette.material_radius.into(),
..Default::default()
},
..Default::default()
}));
let mut content = column([]).spacing(10).push(header).push(controls); let mut content = column([]).spacing(10).push(header).push(controls);
if count == 0 { if count == 0 {
let empty = column([]) let empty = column([])
.spacing(10) .spacing(10)
.align_x(alignment::Horizontal::Center) .align_x(alignment::Horizontal::Center)
.push( .push(icon_well(
container(accented_icon(ferese_theme::icons::NOTIFICATIONS, 26, muted, accent)) accented_icon(ferese_theme::icons::NOTIFICATIONS, 26, muted, accent),
.center_x(56) primary,
.center_y(56) 56,
.class(theme::Container::custom(move |_| container::Style { ))
background: Some(Background::Color(well)),
border: Border {
shape: BorderShape::Continuous,
radius: motion::radius(28.0).into(),
..Default::default()
},
..Default::default()
})),
)
.push( .push(
text("You're all caught up") text("You're all caught up")
.size(15) .size(15)
@@ -797,11 +781,12 @@ impl FereseShell {
..Default::default() ..Default::default()
})) }))
.into(); .into();
motion::animated( motion::animated_from_edge(
panel, panel,
progress, progress,
surface.map(|surface| surface.regions.clone()).unwrap_or_default(), surface.map(|surface| surface.regions.clone()).unwrap_or_default(),
palette.material_radius, palette.material_radius,
self.config.panels[0].edge,
) )
} else { } else {
let mut cards = column([]).spacing(8); let mut cards = column([]).spacing(8);
@@ -834,6 +819,58 @@ mod tests {
use cosmic::iced::advanced::{Layout, layout, mouse, widget::Tree}; use cosmic::iced::advanced::{Layout, layout, mouse, widget::Tree};
use cosmic::iced::{Font, Pixels, Rectangle, Size}; use cosmic::iced::{Font, Pixels, Rectangle, Size};
#[test]
fn notification_insets_reveal_the_modal_material() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.0),
Some("tiny-skia"),
))
.unwrap();
for foreground in [Color::BLACK, Color::WHITE] {
for extent in [30, 56] {
let inset = icon_well(cosmic::widget::Space::new(), foreground, extent);
let controls = ferese_theme::menus::section(
cosmic::widget::Space::new().width(100).height(40).into(),
foreground,
1.0,
14.0,
);
for mut element in [inset, controls] {
let bounds = Rectangle::with_size(Size::new(120.0, 60.0));
let mut tree = Tree::new(element.as_widget());
let node = element.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, bounds.size()),
);
renderer.reset(bounds);
element.as_widget().draw(
&tree,
&mut renderer,
&cosmic::Theme::dark(),
&Default::default(),
Layout::new(&node),
mouse::Cursor::Unavailable,
&bounds,
);
let pixels = Headless::screenshot(&mut renderer, Size::new(120, 60), 1.0, Color::TRANSPARENT);
let center = node.bounds().center();
let alpha = pixels[((center.y as usize * 120 + center.x as usize) * 4) + 3];
assert!(
alpha > 0 && alpha < 32,
"an inset must be visible without hiding the material: {alpha}"
);
}
}
}
}
#[test] #[test]
fn notification_action_hover_stays_inside_card_corners() { fn notification_action_hover_stays_inside_card_corners() {
check_action_hover("tiny-skia"); check_action_hover("tiny-skia");
@@ -865,7 +902,6 @@ mod tests {
cosmic::widget::Space::new().height(38), cosmic::widget::Space::new().height(38),
Message::InvokeNotification(1, "action".into()), Message::InvokeNotification(1, "action".into()),
Color::WHITE, Color::WHITE,
Color::WHITE,
false, false,
"Action".into(), "Action".into(),
); );
@@ -906,9 +942,10 @@ mod tests {
0, 0,
"{backend}, scale={scale}, history={history}" "{backend}, scale={scale}, history={history}"
); );
let hover_alpha = alpha(size.width / 2, size.height - 4);
assert!( assert!(
alpha(size.width / 2, size.height - 4) > 200, hover_alpha > 0 && hover_alpha < 32,
"the action hover must still render" "the action hover must render without hiding the material: {hover_alpha}"
); );
} }
} }
+1
View File
@@ -0,0 +1 @@
pub(crate) use ferese_config::panel::*;
+1
View File
@@ -0,0 +1 @@
pub(crate) use ferese_config::panel::layout::*;
+1 -1
View File
@@ -30,7 +30,7 @@ fn apply_app_backend(command: &mut Command, backend: Option<&OsStr>) {
if let Some(backend) = backend { if let Some(backend) = backend {
command.env("ICED_BACKEND", backend); command.env("ICED_BACKEND", backend);
} else { } else {
// The shell's automatic software preference must not leak into Settings. // Applications choose their own default rendering backend.
command.env_remove("ICED_BACKEND"); command.env_remove("ICED_BACKEND");
} }
} }
+39 -35
View File
@@ -8,26 +8,35 @@ use crate::status::Snapshot;
use crate::{BarMetrics, FereseShell, Message, accented_icon, bar_content, color, motion, recording, text}; use crate::{BarMetrics, FereseShell, Message, accented_icon, bar_content, color, motion, recording, text};
impl FereseShell { impl FereseShell {
pub fn view_status_bar(&self) -> Element<'_, cosmic::Action<Message>> { pub(crate) fn view_media_item(
&self,
representation: crate::panel::Representation,
selected: bool,
width: Option<f32>,
) -> Element<'_, cosmic::Action<Message>> {
let theme = self.config.theme.for_bar(); let theme = self.config.theme.for_bar();
let islands = self.config.status.bar_layout == ferese_config::BarLayout::Islands; if representation == crate::panel::Representation::Icon {
let metrics = BarMetrics::from(theme); self.view_status_item(Menu::Media, false, selected)
let mut controls = row::with_capacity(7).spacing(1).align_y(Alignment::Center); } else {
if self.media.snapshot.selected.is_some() { super::media::bar(
controls = controls.push(super::media::bar(self, theme, metrics)); self,
theme,
BarMetrics::from(self.config.panels[0].geometry),
representation,
selected,
width,
)
}
} }
for kind in [ pub(crate) fn view_status_item(
Menu::System, &self,
Menu::Network, kind: Menu,
Menu::Audio, battery_percentage: bool,
Menu::Recording, selected: bool,
Menu::Notifications, ) -> Element<'_, cosmic::Action<Message>> {
Menu::Battery, let theme = self.config.theme.for_bar();
] { let metrics = BarMetrics::from(self.config.panels[0].geometry);
if !kind.available(&self.status) {
continue;
}
let (source, enabled) = if kind == Menu::Recording { let (source, enabled) = if kind == Menu::Recording {
let source: &'static [u8] = match self.recorder.state { let source: &'static [u8] = match self.recorder.state {
recording::State::Selecting => ferese_theme::icons::RECORD_CANCEL, recording::State::Selecting => ferese_theme::icons::RECORD_CANCEL,
@@ -39,8 +48,8 @@ impl FereseShell {
} else { } else {
status_icon(kind, &self.status) status_icon(kind, &self.status)
}; };
let selected = self.menu.as_ref().is_some_and(|m| m.kind == kind) let selected =
|| (kind == Menu::Recording && matches!(self.recorder.state, recording::State::Recording(_))); selected || (kind == Menu::Recording && matches!(self.recorder.state, recording::State::Recording(_)));
let foreground = color(if selected { let foreground = color(if selected {
theme.accent theme.accent
} else if enabled { } else if enabled {
@@ -68,7 +77,7 @@ impl FereseShell {
} }
if kind == Menu::Battery if kind == Menu::Battery
&& self.config.status.battery_percentage && battery_percentage
&& let Some(battery) = &self.status.battery && let Some(battery) = &self.status.battery
{ {
content = content.push( content = content.push(
@@ -84,8 +93,11 @@ impl FereseShell {
.as_ref() .as_ref()
.is_some_and(|n| n.count > 0 && !n.dnd) .is_some_and(|n| n.count > 0 && !n.dnd)
{ {
content = content.push(container(text("")).width(4).height(4).class(theme::Container::custom( content = content.push(
move |_| container::Style { container(text(""))
.width(4)
.height(4)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(foreground)), background: Some(Background::Color(foreground)),
border: Border { border: Border {
shape: BorderShape::Continuous, shape: BorderShape::Continuous,
@@ -93,8 +105,8 @@ impl FereseShell {
..Default::default() ..Default::default()
}, },
..Default::default() ..Default::default()
}, })),
))); );
} }
let content = bar_content(content, metrics.group_item_height); let content = bar_content(content, metrics.group_item_height);
let recording_busy = kind == Menu::Recording && self.recorder.busy(); let recording_busy = kind == Menu::Recording && self.recorder.busy();
@@ -126,17 +138,7 @@ impl FereseShell {
}, },
)) ))
}); });
controls = controls.push(motion::button(control, foreground, selected, 1.0)); motion::button(control, foreground, selected, 1.0)
}
if islands {
return controls.into();
}
container(controls)
.padding([2, 3])
.height(metrics.group_height)
.class(theme::Container::custom(move |_| crate::bar::bar_group_style(theme)))
.into()
} }
} }
@@ -160,6 +162,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
_ => "Notifications: unavailable".into(), _ => "Notifications: unavailable".into(),
}, },
Menu::Calendar => "Calendar".into(), Menu::Calendar => "Calendar".into(),
Menu::Overflow => "More panel items".into(),
Menu::Recording => "Screen recording".into(), Menu::Recording => "Screen recording".into(),
Menu::Media => "Now playing".into(), Menu::Media => "Now playing".into(),
Menu::Battery => s.battery.as_ref().map_or_else( Menu::Battery => s.battery.as_ref().map_or_else(
@@ -207,6 +210,7 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
(audio_icon(a.volume, a.muted), !a.muted && a.volume > 0) (audio_icon(a.volume, a.muted), !a.muted && a.volume > 0)
}), }),
Menu::Calendar => (ferese_theme::icons::CALENDAR, true), Menu::Calendar => (ferese_theme::icons::CALENDAR, true),
Menu::Overflow => (ferese_theme::icons::CHEVRON_DOWN, true),
Menu::Recording => (ferese_theme::icons::RECORD, true), Menu::Recording => (ferese_theme::icons::RECORD, true),
Menu::Media => (ferese_theme::icons::MEDIA, true), Menu::Media => (ferese_theme::icons::MEDIA, true),
Menu::Battery => { Menu::Battery => {
+156 -35
View File
@@ -1,14 +1,32 @@
use std::time::Instant; use std::time::Instant;
use cosmic::app::Task; use cosmic::app::Task;
use cosmic::iced::Rectangle;
use cosmic::iced::platform_specific::runtime::wayland::popup::{SctkPopupSettings, SctkPositioner}; use cosmic::iced::platform_specific::runtime::wayland::popup::{SctkPopupSettings, SctkPositioner};
use cosmic::iced::{Limits, Rectangle, window}; use cosmic::iced::{Limits, window};
use wayland_client::Proxy; use wayland_client::Proxy;
use super::{Menu, OpenMenu}; use super::{Menu, OpenMenu, PopoverAnchor};
use crate::status::Action; use crate::status::Action;
use crate::{FereseShell, Message}; use crate::{FereseShell, Message};
fn menu_positioner(rectangle: Rectangle<i32>, height_limit: f32, edge: ferese_config::panel::Edge) -> SctkPositioner {
// xdg-positioner: bottom/bottom-left for top bars, top/top-left for bottom bars.
let (anchor, gravity, gap) = match edge {
ferese_config::panel::Edge::Top => (2u32, 6u32, 8),
ferese_config::panel::Edge::Bottom => (1u32, 5u32, -8),
};
SctkPositioner {
anchor_rect: rectangle,
anchor: anchor.try_into().unwrap(),
gravity: gravity.try_into().unwrap(),
offset: (rectangle.width / 2, gap),
size_limits: Limits::NONE.max_width(368.0).max_height(720.0_f32.max(height_limit)),
constraint_adjustment: 3,
..Default::default()
}
}
impl OpenMenu { impl OpenMenu {
fn switch_panel(&mut self, kind: Menu, settings: crate::motion::Settings, now: Instant) { fn switch_panel(&mut self, kind: Menu, settings: crate::motion::Settings, now: Instant) {
self.kind = kind; self.kind = kind;
@@ -20,8 +38,26 @@ impl OpenMenu {
} }
impl FereseShell { impl FereseShell {
pub fn open_menu(&mut self, kind: Menu, anchor: Rectangle<i32>) -> Task<Message> { pub fn open_menu(&mut self, kind: Menu, anchor: PopoverAnchor) -> Task<Message> {
if self.menu.as_ref().is_some_and(|menu| menu.kind == kind) { if !anchor.belongs_to(&self.config.panels[0])
|| !self.outputs.iter().any(|output| output.bar == anchor.parent)
|| self.bar_hidden(anchor.parent)
|| !kind.available(&self.status)
{
return Task::none();
}
if self
.menu
.as_ref()
.is_some_and(|menu| menu.anchor.parent != anchor.parent)
{
let close = self.destroy_menu();
let open = self.open_menu(kind, anchor);
return close.chain(open);
}
if self.menu.as_ref().is_some_and(|menu| {
menu.kind == kind && menu.anchor.same_item(&anchor) && menu.anchor.rectangle == anchor.rectangle
}) {
if let Some(menu) = &mut self.menu if let Some(menu) = &mut self.menu
&& menu.motion.closing() && menu.motion.closing()
{ {
@@ -66,42 +102,16 @@ impl FereseShell {
let notifications = self.sync_notification_surface(); let notifications = self.sync_notification_surface();
crate::EFFECT_FRAME_PENDING.store(true, std::sync::atomic::Ordering::Relaxed); crate::EFFECT_FRAME_PENDING.store(true, std::sync::atomic::Ordering::Relaxed);
self.status_error = None; self.status_error = None;
let parent = self.bar_surface_id; let parent = anchor.parent;
let anchor = if kind == Menu::System { let rectangle = anchor.rectangle;
self.outputs
.iter()
.find(|output| output.bar == parent)
.and_then(|output| output.size)
.map(|(width, _)| {
let theme = self.config.theme;
Rectangle {
x: (width - theme.bar_margin_horizontal * 2 - theme.panel_padding.round() as i32 - 1).max(0),
y: 0,
width: 1,
height: theme.bar_height.round() as i32,
}
})
.unwrap_or(anchor)
} else {
anchor
};
let height_limit = if kind == Menu::Notifications { let height_limit = if kind == Menu::Notifications {
self.notification_history_height_limit() self.notification_history_height_limit()
} else { } else {
kind.height_limit() kind.height_limit()
}; };
let positioner = SctkPositioner { let positioner = menu_positioner(rectangle, height_limit, self.config.panels[0].edge);
anchor_rect: anchor,
anchor: 2u32.try_into().unwrap(),
gravity: 6u32.try_into().unwrap(),
offset: (anchor.width / 2, 8),
// The view's autosize widget supplies the active panel's bounds.
// Keep initial popup limits broad enough for later panel changes.
size_limits: Limits::NONE.max_width(368.0).max_height(720.0_f32.max(height_limit)),
constraint_adjustment: 3,
..Default::default()
};
if let Some(menu) = &mut self.menu { if let Some(menu) = &mut self.menu {
menu.anchor = anchor;
menu.switch_panel(kind, self.config.animations, Instant::now()); menu.switch_panel(kind, self.config.animations, Instant::now());
return Task::batch([ return Task::batch([
notifications, notifications,
@@ -111,6 +121,7 @@ impl FereseShell {
let id = window::Id::unique(); let id = window::Id::unique();
self.menu = Some(OpenMenu { self.menu = Some(OpenMenu {
id, id,
anchor,
kind, kind,
motion: crate::motion::PopupMotion::new(self.config.animations), motion: crate::motion::PopupMotion::new(self.config.animations),
effects: None, effects: None,
@@ -133,9 +144,10 @@ impl FereseShell {
} }
pub fn destroy_menu(&mut self) -> Task<Message> { pub fn destroy_menu(&mut self) -> Task<Message> {
let tooltip = self.dismiss_workspace_tooltip();
crate::EFFECT_FRAME_PENDING.store(false, std::sync::atomic::Ordering::Relaxed); crate::EFFECT_FRAME_PENDING.store(false, std::sync::atomic::Ordering::Relaxed);
let Some(menu) = self.menu.take() else { let Some(menu) = self.menu.take() else {
return Task::none(); return tooltip;
}; };
self.refresh_media_art(false); self.refresh_media_art(false);
if menu.kind == Menu::Notifications { if menu.kind == Menu::Notifications {
@@ -143,6 +155,7 @@ impl FereseShell {
self.notifications.hovered = None; self.notifications.hovered = None;
} }
Task::batch([ Task::batch([
tooltip,
cosmic::task::message(cosmic::Action::Surface(cosmic::surface::action::destroy_popup(menu.id))), cosmic::task::message(cosmic::Action::Surface(cosmic::surface::action::destroy_popup(menu.id))),
self.sync_notification_surface(), self.sync_notification_surface(),
]) ])
@@ -218,12 +231,88 @@ mod tests {
use crate::motion::{PopupMotion, Settings}; use crate::motion::{PopupMotion, Settings};
use std::time::Duration; use std::time::Duration;
fn test_anchor() -> PopoverAnchor {
PopoverAnchor {
parent: window::Id::unique(),
panel: crate::panel::PanelId("main".into()),
item: Some(crate::panel::ItemId("battery".into())),
rectangle: Rectangle::default(),
}
}
#[test]
fn clicking_the_same_control_at_new_bounds_repositions_instead_of_toggling() {
let mut shell = crate::tests::shell_with_measured_panel();
shell.status.network = Some(crate::status::Network {
enabled: true,
connection: Some("Test network".into()),
signal: 90,
});
let bar = shell.outputs[0].bar;
let mut anchor = PopoverAnchor {
parent: bar,
panel: shell.config.panels[0].id.clone(),
item: Some(crate::panel::ItemId("network".into())),
rectangle: Rectangle {
x: 1000,
y: 0,
width: 60,
height: 36,
},
};
drop(shell.open_menu(Menu::Network, anchor.clone()));
let popup = shell.menu.as_ref().unwrap().id;
anchor.rectangle.x = 800;
drop(shell.open_menu(Menu::Network, anchor.clone()));
let menu = shell.menu.as_ref().unwrap();
assert_eq!(menu.id, popup);
assert_eq!(menu.anchor, anchor);
assert!(!menu.motion.closing());
drop(shell.open_menu(Menu::Network, anchor));
assert!(shell.menu.is_none());
}
#[test]
fn menus_open_toward_the_desktop_from_either_panel_edge() {
use ferese_config::panel::Edge;
let rectangle = Rectangle {
x: 400,
y: 0,
width: 32,
height: 30,
};
for (edge, anchor, gravity, gap) in [(Edge::Top, 2u32, 6u32, 8), (Edge::Bottom, 1u32, 5u32, -8)] {
let positioner = menu_positioner(rectangle, 620., edge);
assert_eq!(positioner.anchor_rect, rectangle);
assert_eq!(positioner.anchor, anchor.try_into().unwrap());
assert_eq!(positioner.gravity, gravity.try_into().unwrap());
assert_eq!(positioner.offset, (16, gap));
}
}
#[test]
fn popup_identity_includes_parent_panel_and_instance_but_not_rectangle() {
let first = test_anchor();
let mut second = first.clone();
second.rectangle.x = 100;
assert!(first.same_item(&second));
second.item = Some(crate::panel::ItemId("other-battery".into()));
assert!(!first.same_item(&second));
second = first.clone();
second.parent = window::Id::unique();
assert!(!first.same_item(&second));
second = first.clone();
second.panel = crate::panel::PanelId("other".into());
assert!(!first.same_item(&second));
}
#[test] #[test]
fn switching_panels_replays_entrance_on_the_same_surface() { fn switching_panels_replays_entrance_on_the_same_surface() {
let now = Instant::now(); let now = Instant::now();
let settings = Settings::default(); let settings = Settings::default();
let mut menu = OpenMenu { let mut menu = OpenMenu {
id: window::Id::unique(), id: window::Id::unique(),
anchor: test_anchor(),
kind: Menu::Battery, kind: Menu::Battery,
motion: PopupMotion::new(settings), motion: PopupMotion::new(settings),
effects: None, effects: None,
@@ -268,6 +357,7 @@ mod tests {
let now = Instant::now(); let now = Instant::now();
let mut menu = OpenMenu { let mut menu = OpenMenu {
id: window::Id::unique(), id: window::Id::unique(),
anchor: test_anchor(),
kind: Menu::Battery, kind: Menu::Battery,
motion: PopupMotion::new(Settings::default()), motion: PopupMotion::new(Settings::default()),
effects: None, effects: None,
@@ -299,4 +389,35 @@ mod tests {
); );
assert_eq!(menu.motion.progress_at(now + Duration::from_millis(60)), normal); assert_eq!(menu.motion.progress_at(now + Duration::from_millis(60)), normal);
} }
#[test]
fn popup_anchors_reject_removed_hidden_items_and_other_panel_definitions() {
let mut panel = crate::panel::Panel::default();
let anchor = PopoverAnchor {
parent: window::Id::unique(),
panel: panel.id.clone(),
item: Some(crate::panel::ItemId("network".into())),
rectangle: Rectangle {
x: 0,
y: 0,
width: 20,
height: 20,
},
};
assert!(anchor.belongs_to(&panel));
panel.end.groups[0].items[2].visible = false;
assert!(!anchor.belongs_to(&panel));
panel.end.groups[0].items.remove(2);
assert!(!anchor.belongs_to(&panel));
panel.id = crate::panel::PanelId("other".into());
let fallback = PopoverAnchor {
item: None,
..anchor.clone()
};
assert!(!fallback.belongs_to(&panel));
let fallback = PopoverAnchor {
panel: panel.id.clone(),
..fallback
};
assert!(fallback.belongs_to(&panel));
}
} }
+21 -6
View File
@@ -73,11 +73,13 @@ pub(super) fn bar<'a>(
shell: &'a FereseShell, shell: &'a FereseShell,
palette: ShellTheme, palette: ShellTheme,
metrics: BarMetrics, metrics: BarMetrics,
representation: crate::panel::Representation,
selected: bool,
width: Option<f32>,
) -> Element<'a, cosmic::Action<Message>> { ) -> Element<'a, cosmic::Action<Message>> {
let Some(player) = &shell.media.snapshot.selected else { let Some(player) = &shell.media.snapshot.selected else {
return text("").into(); return text("").into();
}; };
let selected = shell.menu.as_ref().is_some_and(|menu| menu.kind == Menu::Media);
let foreground = color(if selected { palette.accent } else { palette.text_primary }); let foreground = color(if selected { palette.accent } else { palette.text_primary });
let height = (metrics.group_item_height - 4.0).max(0.0); let height = (metrics.group_item_height - 4.0).max(0.0);
let side = height.min(24.0); let side = height.min(24.0);
@@ -111,9 +113,21 @@ pub(super) fn bar<'a>(
.height(height) .height(height)
.align_y(cosmic::iced::alignment::Vertical::Center) .align_y(cosmic::iced::alignment::Vertical::Center)
.class(theme::Text::Color(foreground)); .class(theme::Text::Color(foreground));
let label = container(label).max_width(112); let compact = representation == crate::panel::Representation::Compact;
let preferred_label_width: f32 = if compact { 112. } else { 320. };
// Outer padding, the transport button, details padding, and cover spacing
// share the allocation with the label.
let chrome = 4. + 2. + f32::from(metrics.icon_size) + 12. + 8. + if compact { 0. } else { side + 8. };
let label_width = width.map_or(preferred_label_width, |width| {
(width - chrome).max(0.).min(preferred_label_width)
});
let label = container(label).max_width(label_width);
let details = button::custom(bar_content( let details = button::custom(bar_content(
row![cover, label].spacing(8).align_y(Alignment::Center), if representation == crate::panel::Representation::Compact {
row![label].align_y(Alignment::Center)
} else {
row![cover, label].spacing(8).align_y(Alignment::Center)
},
height, height,
)) ))
.name(format!("Now playing: {}", player.label())) .name(format!("Now playing: {}", player.label()))
@@ -149,9 +163,10 @@ pub(super) fn bar<'a>(
))); )));
} }
let mut bar_style = crate::bar::bar_group_style(palette); let bar_style = ferese_theme::controls::surface_appearance(
bar_style.border = bar_style.border.width(0.0); color_with_opacity(palette.border, 0.30),
bar_style.background = Some(super::Background::Color(color_with_opacity(palette.border, 0.30))); palette.material_radius.min(16.),
);
let controls = container( let controls = container(
row![ row![
+43 -4
View File
@@ -41,6 +41,7 @@ pub enum Menu {
Media, Media,
Notifications, Notifications,
System, System,
Overflow,
} }
impl Menu { impl Menu {
@@ -57,7 +58,7 @@ impl Menu {
relevant.then_some(error.message.as_str()) relevant.then_some(error.message.as_str())
} }
fn width(self) -> f32 { pub(crate) fn width(self) -> f32 {
match self { match self {
Self::System => 360.0, Self::System => 360.0,
Self::Battery => 328.0, Self::Battery => 328.0,
@@ -74,6 +75,7 @@ impl Menu {
fn title(self) -> &'static str { fn title(self) -> &'static str {
match self { match self {
Self::System => "Control Center", Self::System => "Control Center",
Self::Overflow => "Panel items",
Self::Network => "Wi-Fi", Self::Network => "Wi-Fi",
Self::Bluetooth => "Bluetooth", Self::Bluetooth => "Bluetooth",
Self::Audio => "Sound", Self::Audio => "Sound",
@@ -93,19 +95,43 @@ impl Menu {
Self::Battery => status.battery.is_some(), Self::Battery => status.battery.is_some(),
Self::Calendar | Self::Recording | Self::Media => true, Self::Calendar | Self::Recording | Self::Media => true,
Self::Notifications => status.notifications.is_some(), Self::Notifications => status.notifications.is_some(),
Self::System => true, Self::System | Self::Overflow => true,
} }
} }
} }
pub struct OpenMenu { pub struct OpenMenu {
pub id: window::Id, pub id: window::Id,
pub anchor: PopoverAnchor,
pub kind: Menu, pub kind: Menu,
pub motion: super::motion::PopupMotion, pub motion: super::motion::PopupMotion,
pub effects: Option<EffectsBinding>, pub effects: Option<EffectsBinding>,
pub regions: super::motion::Regions, pub regions: super::motion::Regions,
} }
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct PopoverAnchor {
pub parent: window::Id,
pub panel: crate::panel::PanelId,
// Notifications can be opened from a toast without a panel item.
pub item: Option<crate::panel::ItemId>,
pub rectangle: cosmic::iced::Rectangle<i32>,
}
impl PopoverAnchor {
pub(crate) fn belongs_to(&self, panel: &crate::panel::Panel) -> bool {
self.panel == panel.id
&& self
.item
.as_ref()
.is_none_or(|id| id.0 == "_overflow" || panel.item(id).is_some_and(|item| item.visible))
}
pub fn same_item(&self, other: &Self) -> bool {
self.parent == other.parent && self.panel == other.panel && self.item == other.item
}
}
impl OpenMenu { impl OpenMenu {
pub(super) fn prepare_surface(&mut self, surface: &wl_surface::WlSurface) { pub(super) fn prepare_surface(&mut self, surface: &wl_surface::WlSurface) {
if self.effects.is_none() { if self.effects.is_none() {
@@ -219,6 +245,7 @@ impl FereseShell {
Menu::Calendar => rows.push(calendar::view(self.calendar_offset, theme, p)), Menu::Calendar => rows.push(calendar::view(self.calendar_offset, theme, p)),
Menu::Notifications => notification_controls(self, rows, style, false), Menu::Notifications => notification_controls(self, rows, style, false),
Menu::Recording => rows, Menu::Recording => rows,
Menu::Overflow => self.view_panel_overflow(rows),
Menu::Media => media::view(self, rows, style), Menu::Media => media::view(self, rows, style),
}; };
if kind != Menu::Calendar && !kind.available(&self.status) { if kind != Menu::Calendar && !kind.available(&self.status) {
@@ -237,7 +264,12 @@ impl FereseShell {
} }
let compositor_material = menu.effects.is_some(); let compositor_material = menu.effects.is_some();
let panel = container(rows) let body: Element<'_, cosmic::Action<Message>> = if kind == Menu::Overflow {
cosmic::widget::scrollable(rows).height(Length::Shrink).into()
} else {
rows.into()
};
let panel = container(body)
.id("ferese-blur-card") .id("ferese-blur-card")
.width(kind.width()) .width(kind.width())
.padding(16) .padding(16)
@@ -258,11 +290,12 @@ impl FereseShell {
})); }));
cosmic::widget::autosize::autosize( cosmic::widget::autosize::autosize(
super::motion::animated( super::motion::animated_from_edge(
panel.into(), panel.into(),
menu.motion.progress_at(now), menu.motion.progress_at(now),
menu.regions.clone(), menu.regions.clone(),
theme.material_radius, theme.material_radius,
self.config.panels[0].edge,
), ),
cosmic::iced::advanced::widget::Id::new("ferese-status-menu"), cosmic::iced::advanced::widget::Id::new("ferese-status-menu"),
) )
@@ -378,6 +411,12 @@ mod tests {
motion.begin(past); motion.begin(past);
let menu = OpenMenu { let menu = OpenMenu {
id: window::Id::unique(), id: window::Id::unique(),
anchor: PopoverAnchor {
parent: window::Id::unique(),
panel: crate::panel::PanelId("main".into()),
item: None,
rectangle: Default::default(),
},
kind: Menu::Network, kind: Menu::Network,
motion, motion,
effects: None, effects: None,
+89 -21
View File
@@ -7,19 +7,38 @@ use super::{
registry_queue_init, set_margin, status_ui, text, theme, wayland, window, wl_output, wl_registry, wl_surface, registry_queue_init, set_margin, status_ui, text, theme, wayland, window, wl_output, wl_registry, wl_surface,
}; };
pub(super) fn panel_placement(edge: ferese_config::panel::Edge, margin: i32, side: i32) -> (Anchor, IcedMargin) {
let (anchor, top, bottom) = match edge {
ferese_config::panel::Edge::Top => (Anchor::TOP, margin, 0),
ferese_config::panel::Edge::Bottom => (Anchor::BOTTOM, 0, margin),
};
(
anchor,
IcedMargin {
top,
right: side,
bottom,
left: side,
},
)
}
impl FereseShell { impl FereseShell {
pub(super) fn output_event(&mut self, event: wayland::OutputEvent, output: wl_output::WlOutput) -> Task<Message> { pub(super) fn output_event(&mut self, event: wayland::OutputEvent, output: wl_output::WlOutput) -> Task<Message> {
if matches!(event, wayland::OutputEvent::Removed) { if matches!(event, wayland::OutputEvent::Removed) {
if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) { if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) {
let entry = self.outputs.remove(index); let entry = self.outputs.remove(index);
self.refresh_media_art(false); self.refresh_media_art(false);
let menu = if self.bar_surface_id == entry.bar { let menu = if self.menu.as_ref().is_some_and(|menu| menu.anchor.parent == entry.bar) {
self.destroy_menu() self.destroy_menu()
} else { } else {
Task::none() Task::none()
}; };
let mut tasks = vec![
let mut tasks = vec![destroy_layer_surface(entry.bar), menu, self.rebuild_system_modal()]; self.dismiss_workspace_tooltip(),
menu.chain(destroy_layer_surface(entry.bar)),
self.rebuild_system_modal(),
];
if self.note_drag.as_ref().is_some_and(|drag| { if self.note_drag.as_ref().is_some_and(|drag| {
entry.clock == Some(drag.source) || entry.notes.iter().any(|(_, id)| *id == drag.source) entry.clock == Some(drag.source) || entry.notes.iter().any(|(_, id)| *id == drag.source)
}) { }) {
@@ -57,6 +76,7 @@ impl FereseShell {
}; };
if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.output == output) { if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.output == output) {
let resized = size.is_some() && entry.size != size;
if size.is_some() { if size.is_some() {
entry.size = size; entry.size = size;
} }
@@ -66,18 +86,37 @@ impl FereseShell {
entry.name = name; entry.name = name;
} }
return if changed { let bar = entry.bar;
Task::batch([self.rebuild_clocks(true), self.rebuild_notes(true)]) let margin = self.update_panel_margin(bar, false);
let popup = if resized && self.menu.as_ref().is_some_and(|menu| menu.anchor.parent == bar) {
self.destroy_menu()
} else { } else {
Task::none() Task::none()
}; };
let tooltip = if resized {
self.dismiss_workspace_tooltip_for_bar(bar)
} else {
Task::none()
};
return if changed {
Task::batch([
margin,
popup,
tooltip,
self.rebuild_clocks(true),
self.rebuild_notes(true),
])
} else {
Task::batch([margin, popup, tooltip])
};
} }
let bar_surface_id = window::Id::unique(); let bar_surface_id = window::Id::unique();
let wallpaper_surface_id = window::Id::unique(); let wallpaper_surface_id = window::Id::unique();
let shell_theme = self.config.theme; let geometry = self.config.panels[0].geometry;
let bar_layout = self.config.status.bar_layout; let panel_surface = self.config.panels[0].surface;
let bar = BarMetrics::from(shell_theme); let (edge_anchor, margin) = panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
let bar = BarMetrics::from(geometry);
let wallpaper_output = output.clone(); let wallpaper_output = output.clone();
let bar_output = output.clone(); let bar_output = output.clone();
let hidden = output_bar_hidden( let hidden = output_bar_hidden(
@@ -97,6 +136,8 @@ impl FereseShell {
.then_some(wallpaper_surface_id), .then_some(wallpaper_surface_id),
effects: None, effects: None,
bar_regions: Vec::new(), bar_regions: Vec::new(),
panel_resolution: Default::default(),
bar_margin_horizontal: 0,
clock: None, clock: None,
notes: Vec::new(), notes: Vec::new(),
size, size,
@@ -126,23 +167,18 @@ impl FereseShell {
|_| Default::default(), |_| Default::default(),
move |_| SctkLayerSurfaceSettings { move |_| SctkLayerSurfaceSettings {
id: bar_surface_id, id: bar_surface_id,
input_zone: super::bar::input_region(bar_layout, hidden, &[]), input_zone: super::bar::input_region(panel_surface, hidden, &[]),
layer: Layer::Top, layer: Layer::Top,
keyboard_interactivity: KeyboardInteractivity::None, keyboard_interactivity: KeyboardInteractivity::None,
anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT, anchor: edge_anchor | Anchor::LEFT | Anchor::RIGHT,
output: IcedOutput::Output(bar_output.clone()), output: IcedOutput::Output(bar_output.clone()),
namespace: "ferese-shell-top-bar".to_owned(), namespace: "ferese-shell-top-bar".to_owned(),
margin: IcedMargin { // Measure at full width before applying the requested side inset.
top: shell_theme.bar_margin_top, margin,
right: shell_theme.bar_margin_horizontal,
bottom: 0,
left: shell_theme.bar_margin_horizontal,
},
size: Some((None, Some(bar.height.round() as u32))), size: Some((None, Some(bar.height.round() as u32))),
size_limits: Limits::NONE, size_limits: Limits::NONE,
// Reserve breathing room below the visible bar; layer-shell // Reserve space toward the desktop; layer-shell accounts for the edge margin.
// accounts for the top margin separately. exclusive_zone: (bar.height.round() as i32).saturating_add(geometry.window_clearance),
exclusive_zone: (bar.height.round() as i32).saturating_add(shell_theme.bar_window_gap),
}, },
Some(Box::new(move |app| app.view_layer(bar_surface_id))), Some(Box::new(move |app| app.view_layer(bar_surface_id))),
); );
@@ -168,6 +204,9 @@ impl FereseShell {
} }
pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> { pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> {
if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorLeft)) {
return self.dismiss_workspace_tooltip_for_bar(id);
}
if let Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_)) = &event if let Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_)) = &event
&& let Some(modal) = &mut self.system_modal && let Some(modal) = &mut self.system_modal
&& modal.contains(id) && modal.contains(id)
@@ -319,6 +358,10 @@ impl FereseShell {
.. ..
}) if self.menu.is_some() => self.close_menu(), }) if self.menu.is_some() => self.close_menu(),
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(event, surface, popup_id))) => { Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(event, surface, popup_id))) => {
if self.workspace_ui.tooltip == Some(popup_id) && matches!(event, wayland::PopupEvent::Done) {
self.workspace_ui.tooltip = None;
return self.dismiss_workspace_tooltip();
}
if let Some(menu) = &mut self.menu if let Some(menu) = &mut self.menu
&& menu.id == popup_id && menu.id == popup_id
{ {
@@ -345,6 +388,10 @@ impl FereseShell {
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
Task::none() Task::none()
} }
Event::Window(window::Event::Closed) if self.workspace_ui.tooltip == Some(id) => {
self.workspace_ui.tooltip = None;
self.dismiss_workspace_tooltip()
}
Event::Window(window::Event::Closed) Event::Window(window::Event::Closed)
if self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) => if self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) =>
{ {
@@ -398,7 +445,7 @@ impl FereseShell {
pub(super) fn attach_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) { pub(super) fn attach_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) {
let hidden = self.bar_hidden(id); let hidden = self.bar_hidden(id);
let islands = self.config.status.bar_layout == ferese_config::BarLayout::Islands; let islands = self.config.panels[0].surface == ferese_config::panel::PanelSurface::None;
let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id) else { let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id) else {
return; return;
}; };
@@ -490,6 +537,17 @@ fn encode_regions(regions: &[[f32; 5]]) -> Vec<u8> {
.collect() .collect()
} }
fn validate_region_counts(regions: usize, opacities: usize) -> Result<(), std::io::Error> {
let limit = ferese_protocols::effects::v1::MAX_REGIONS;
if regions > limit || opacities > limit {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("effects presentation exceeds the {limit}-region limit"),
));
}
Ok(())
}
type EffectPresentation = (Vec<[f32; 5]>, u32, Vec<u32>, ferese_surface_effects_v1::Role); type EffectPresentation = (Vec<[f32; 5]>, u32, Vec<u32>, ferese_surface_effects_v1::Role);
pub(super) struct EffectsBinding { pub(super) struct EffectsBinding {
@@ -556,9 +614,9 @@ impl EffectsBinding {
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32; let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
let values: Vec<u32> = region_opacities let values: Vec<u32> = region_opacities
.into_iter() .into_iter()
.take(32)
.map(|value| (value.clamp(0.0, 1.0) * 1000.0).round() as u32) .map(|value| (value.clamp(0.0, 1.0) * 1000.0).round() as u32)
.collect(); .collect();
validate_region_counts(regions.len(), values.len())?;
let next = (regions.to_vec(), opacity, values, role); let next = (regions.to_vec(), opacity, values, role);
if self.presentation.borrow().as_ref() != Some(&next) { if self.presentation.borrow().as_ref() != Some(&next) {
self.surface.set_presentation( self.surface.set_presentation(
@@ -582,6 +640,7 @@ impl EffectsBinding {
regions: &[[f32; 5]], regions: &[[f32; 5]],
role: ferese_surface_effects_v1::Role, role: ferese_surface_effects_v1::Role,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
validate_region_counts(regions.len(), 0)?;
if self.regions.borrow().as_deref() == Some(regions) { if self.regions.borrow().as_deref() == Some(regions) {
return Ok(()); return Ok(());
} }
@@ -638,6 +697,15 @@ delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1);
#[cfg(test)] #[cfg(test)]
mod region_encoding_tests { mod region_encoding_tests {
#[test]
fn oversized_presentations_are_rejected_without_truncation() {
let limit = ferese_protocols::effects::v1::MAX_REGIONS;
assert!(super::validate_region_counts(0, 0).is_ok());
assert!(super::validate_region_counts(limit, limit).is_ok());
assert!(super::validate_region_counts(limit + 1, limit).is_err());
assert!(super::validate_region_counts(limit, limit + 1).is_err());
}
#[test] #[test]
fn fractional_region_encoding_preserves_values() { fn fractional_region_encoding_preserves_values() {
let regions = [[0.25, -0.75, 100.5, 40.25, 14.0]]; let regions = [[0.25, -0.75, 100.5, 40.25, 14.0]];
+11 -2
View File
@@ -170,7 +170,15 @@ impl FereseShell {
.iter() .iter()
.position(|output| output.name.as_deref() == Some(name)) .position(|output| output.name.as_deref() == Some(name))
}) })
.or_else(|| self.outputs.iter().position(|output| output.bar == self.bar_surface_id)) .or_else(|| {
self.menu
.as_ref()
.and_then(|menu| self.outputs.iter().position(|output| output.bar == menu.anchor.parent))
})
.or_else(|| {
self.active_bar()
.and_then(|bar| self.outputs.iter().position(|output| output.bar == bar))
})
.unwrap_or(0); .unwrap_or(0);
let mut tasks = vec![ let mut tasks = vec![
self.destroy_system_modal(true), self.destroy_system_modal(true),
@@ -536,9 +544,10 @@ impl FereseShell {
row![ row![
Space::new().width(Length::Fill), Space::new().width(Length::Fill),
ferese_theme::controls::text_button("Cancel", shell_font(), palette, false) ferese_theme::controls::text_button("Cancel", shell_font(), palette, false)
.class(ferese_theme::controls::button_style_with_focus( .class(ferese_theme::controls::material_button_style_with_focus(
palette, palette,
false, false,
theme.material_opacity(),
modal.focus_visible, modal.focus_visible,
)) ))
.id("ferese-modal-cancel".into()) .id("ferese-modal-cancel".into())
+918
View File
@@ -0,0 +1,918 @@
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::{Element, Message, container, text};
use cosmic::app::Task;
use cosmic::iced::advanced::widget::{Id, Operation, operation};
use cosmic::iced::{Rectangle, window};
use cosmic::widget::icon;
use freedesktop_desktop_entry as fde;
use crate::{FereseShell, ShellSnapshot, motion};
impl FereseShell {
pub(super) fn update_workspace_ui(&mut self) -> Task<Message> {
let needs_icons = [
&self.config.panels[0].start,
&self.config.panels[0].center,
&self.config.panels[0].end,
]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.any(|item| {
item.visible
&& matches!(
item.kind,
crate::panel::ItemKind::Workspaces {
style: ferese_config::panel::WorkspaceStyle::AppIcons
}
)
});
self.workspace_ui.update(&self.snapshot, self.config.animations);
let icons = self.workspace_ui.load_icons(&self.snapshot, needs_icons);
let tooltip = if self.workspace_ui.hovered.as_ref().is_some_and(|hover| {
!self
.snapshot
.workspaces
.iter()
.any(|workspace| workspace.id == hover.workspace)
}) {
self.dismiss_workspace_tooltip()
} else {
Task::none()
};
Task::batch([icons, tooltip])
}
pub(super) fn workspace_icons_loaded(&mut self, result: Result<IconIndex, String>) -> Task<Message> {
self.workspace_ui.icons_loading = false;
match result {
Ok(index) => {
self.workspace_ui.entries = Some(index.entries);
self.workspace_ui.icons.extend(index.icons);
// Windows may have changed while discovery was in flight.
self.update_workspace_ui()
}
Err(error) => {
eprintln!("ferese-shell: could not load workspace icons: {error}");
Task::none()
}
}
}
}
#[derive(Clone, Debug)]
pub(super) struct IconIndex {
entries: Arc<Vec<fde::DesktopEntry>>,
icons: HashMap<String, icon::Handle>,
}
fn discover_icons(entries: Option<Arc<Vec<fde::DesktopEntry>>>, app_ids: Vec<String>) -> IconIndex {
let entries = entries.unwrap_or_else(|| {
let mut seen = HashSet::new();
Arc::new(
fde::Iter::new(fde::default_paths())
.entries::<&str>(None)
.filter(|entry| seen.insert(entry.appid.clone()))
.filter(|entry| !entry.hidden())
.collect(),
)
});
let icons = app_ids
.into_iter()
.map(|app_id| {
let entry = fde::find_app_by_id(&entries, fde::unicase::Ascii::new(app_id.as_str()));
let source = entry
.and_then(fde::DesktopEntry::icon)
.unwrap_or("application-x-executable");
let handle = if source.starts_with('/') {
icon::from_path(source.into())
} else {
icon::from_name(source)
.prefer_svg(true)
.size(32)
.fallback(Some(icon::IconFallback::Names(vec!["application-x-executable".into()])))
.handle()
};
(app_id, handle)
})
.collect();
IconIndex { entries, icons }
}
#[derive(Default)]
pub(super) struct WorkspaceUi {
entries: Option<Arc<Vec<fde::DesktopEntry>>>,
icons: HashMap<String, icon::Handle>,
icons_loading: bool,
transitions: HashMap<u64, Transition>,
hovered: Option<Hover>,
pub(super) tooltip: Option<window::Id>,
serial: u64,
}
#[derive(Clone)]
struct Hover {
bar: window::Id,
workspace: u64,
target: Id,
serial: u64,
}
impl WorkspaceUi {
pub(super) fn hover_target(&self, bar: window::Id) -> Option<(u64, Id)> {
self.hovered
.as_ref()
.filter(|hover| hover.bar == bar)
.map(|hover| (hover.workspace, hover.target.clone()))
}
pub(super) fn hovering(&self, bar: window::Id, target: &Id) -> bool {
self.hovered
.as_ref()
.is_some_and(|hover| hover.bar == bar && &hover.target == target)
}
}
struct FindBounds {
target: Id,
bar: window::Id,
window: Option<window::Id>,
found: Option<Rectangle>,
}
impl Operation<Option<Rectangle>> for FindBounds {
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<Option<Rectangle>>)) {
operate(self);
}
fn set_window_id(&mut self, id: window::Id) {
self.window = Some(id);
}
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
if id == Some(&self.target) && self.window == Some(self.bar) {
self.found = Some(bounds);
}
}
fn finish(&self) -> operation::Outcome<Option<Rectangle>> {
operation::Outcome::Some(self.found)
}
}
impl FereseShell {
pub(super) fn dismiss_workspace_tooltip_for_bar(&mut self, bar: window::Id) -> Task<Message> {
if self.workspace_ui.hovered.as_ref().is_some_and(|hover| hover.bar == bar) {
self.dismiss_workspace_tooltip()
} else {
Task::none()
}
}
pub(super) fn dismiss_workspace_tooltip(&mut self) -> Task<Message> {
self.workspace_ui.serial = self.workspace_ui.serial.wrapping_add(1);
self.workspace_ui.hovered = None;
self.workspace_ui.tooltip.take().map_or_else(Task::none, |id| {
cosmic::task::message(cosmic::Action::Surface(cosmic::surface::action::destroy_popup(id)))
})
}
pub(super) fn hover_workspace(
&mut self,
bar: window::Id,
workspace: u64,
target: Id,
entered: bool,
) -> Task<Message> {
if !entered {
return if self.workspace_ui.hovering(bar, &target) {
self.dismiss_workspace_tooltip()
} else {
Task::none()
};
}
if self.workspace_ui.hovering(bar, &target) {
return Task::none();
}
let close = self.dismiss_workspace_tooltip();
if self.menu.is_some() || self.system_modal.is_some() || self.overview_active {
return close;
}
let serial = self.workspace_ui.serial;
self.workspace_ui.hovered = Some(Hover {
bar,
workspace,
target,
serial,
});
close.chain(Task::perform(
async { tokio::time::sleep(Duration::from_millis(300)).await },
move |_| cosmic::Action::App(Message::WorkspaceTooltipDelay(serial)),
))
}
pub(super) fn locate_workspace_tooltip(&self, serial: u64) -> Task<Message> {
let Some(hover) = self
.workspace_ui
.hovered
.as_ref()
.filter(|hover| hover.serial == serial)
else {
return Task::none();
};
cosmic::iced::advanced::widget::operate(FindBounds {
target: hover.target.clone(),
bar: hover.bar,
window: None,
found: None,
})
.map(move |bounds| cosmic::Action::App(Message::WorkspaceTooltipBounds(serial, bounds)))
}
pub(super) fn show_workspace_tooltip(&mut self, serial: u64, bounds: Option<Rectangle>) -> Task<Message> {
use cosmic::iced::platform_specific::runtime::wayland::popup::{SctkPopupSettings, SctkPositioner};
let Some(hover) = self
.workspace_ui
.hovered
.as_ref()
.filter(|hover| hover.serial == serial)
else {
return Task::none();
};
let Some(bounds) = bounds.filter(|bounds| bounds.width > 0. && bounds.height > 0.) else {
return Task::none();
};
if self.workspace_ui.tooltip.is_some() {
return Task::none();
}
if self.bar_hidden(hover.bar) || self.menu.is_some() || self.system_modal.is_some() || self.overview_active {
return self.dismiss_workspace_tooltip();
}
if !self
.snapshot
.workspaces
.iter()
.any(|workspace| workspace.id == hover.workspace)
{
return self.dismiss_workspace_tooltip();
}
let id = window::Id::unique();
let parent = hover.bar;
let (anchor, gravity, gap) = match self.config.panels[0].edge {
ferese_config::panel::Edge::Top => (2u32, 2u32, 6),
ferese_config::panel::Edge::Bottom => (1u32, 1u32, -6),
};
self.workspace_ui.tooltip = Some(id);
let action = cosmic::surface::action::app_popup::<Self>(
|_| Default::default(),
move |_| SctkPopupSettings {
id,
parent,
parent_size: None,
grab: false,
close_with_children: false,
input_zone: Some(Vec::new()),
positioner: SctkPositioner {
anchor_rect: Rectangle {
x: bounds.x.round() as i32,
y: bounds.y.round() as i32,
width: bounds.width.round() as i32,
height: bounds.height.round() as i32,
},
anchor: anchor.try_into().unwrap(),
gravity: gravity.try_into().unwrap(),
offset: (0, gap),
constraint_adjustment: 3,
size_limits: cosmic::iced::Limits::NONE.max_width(320.).max_height(64.),
..Default::default()
},
},
Some(Box::new(Self::view_workspace_tooltip)),
);
cosmic::task::message(cosmic::Action::Surface(action))
}
fn view_workspace_tooltip(&self) -> Element<'_, cosmic::Action<Message>> {
let Some(workspace) = self.workspace_ui.hovered.as_ref().and_then(|hover| {
self.snapshot
.workspaces
.iter()
.find(|workspace| workspace.id == hover.workspace)
}) else {
return text("").into();
};
let active = self
.snapshot
.outputs
.iter()
.any(|output| output.active_workspace == workspace.id);
let label = ferese_theme::workspaces::tooltip_label(&ferese_theme::workspaces::Workspace {
name: (&workspace.name).into(),
index: workspace.index,
window_count: workspace.window_count,
active,
visible_elsewhere: false,
});
cosmic::widget::autosize::autosize(
container(
text(label)
.size(11)
.wrapping(cosmic::iced::widget::text::Wrapping::None)
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
))
.class(cosmic::theme::Text::Color(crate::color(self.config.theme.text_primary))),
)
.padding([8, 10])
.class(ferese_theme::controls::surface(
crate::color(self.config.theme.surface_popover),
self.config.theme.material_radius.min(10.),
)),
Id::new("ferese-workspace-tooltip"),
)
.limits(cosmic::iced::Limits::NONE.max_width(320.).max_height(64.))
.into()
}
}
#[derive(Clone, Copy)]
pub(super) struct Transition {
from: f32,
to: f32,
started: Instant,
duration: Duration,
}
impl Transition {
pub(super) fn progress(self, now: Instant) -> f32 {
if self.duration.is_zero() {
return self.to;
}
let t = (now.saturating_duration_since(self.started).as_secs_f32() / self.duration.as_secs_f32()).min(1.);
self.from + (self.to - self.from) * t * t * (3. - 2. * t)
}
pub(super) fn active(self, now: Instant) -> bool {
self.from != self.to && now.saturating_duration_since(self.started) < self.duration
}
pub(super) fn revision(self) -> Option<Instant> {
Some(self.started)
}
}
impl WorkspaceUi {
pub(super) fn update(&mut self, snapshot: &ShellSnapshot, settings: motion::Settings) {
self.update_at(snapshot, settings, Instant::now());
}
fn update_at(&mut self, snapshot: &ShellSnapshot, settings: motion::Settings, now: Instant) {
let duration = if settings.motion_enabled() {
Duration::from_secs_f64(0.16 / settings.speed)
} else {
Duration::ZERO
};
self.transitions
.retain(|id, _| snapshot.workspaces.iter().any(|workspace| workspace.id == *id));
for workspace in &snapshot.workspaces {
let active = snapshot
.outputs
.iter()
.any(|output| Some(output.id) == workspace.output && output.active_workspace == workspace.id);
let target = if active { 1. } else { 0. };
let transition = self.transitions.entry(workspace.id).or_insert(Transition {
from: target,
to: target,
started: now,
duration,
});
if transition.to != target || transition.duration != duration {
*transition = Transition {
from: transition.progress(now),
to: target,
started: now,
duration,
};
}
}
}
fn load_icons(&mut self, snapshot: &ShellSnapshot, needed: bool) -> Task<Message> {
if !needed || self.icons_loading {
return Task::none();
}
let app_ids: Vec<_> = snapshot
.windows
.iter()
.map(|window| &window.app_id)
.filter(|id| !id.is_empty() && !self.icons.contains_key(*id))
.cloned()
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
if app_ids.is_empty() {
return Task::none();
}
self.icons_loading = true;
let entries = self.entries.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || discover_icons(entries, app_ids))
.await
.map_err(|error| error.to_string())
},
|result| cosmic::Action::App(Message::WorkspaceIconsLoaded(result)),
)
}
pub(super) fn transition(&self, workspace: u64, active: bool) -> Transition {
self.transitions.get(&workspace).copied().unwrap_or(Transition {
from: if active { 1. } else { 0. },
to: if active { 1. } else { 0. },
started: Instant::now(),
duration: Duration::ZERO,
})
}
pub(super) fn apps(&self, workspace: u64, snapshot: &ShellSnapshot) -> Vec<icon::Handle> {
snapshot
.windows
.iter()
.filter(|window| window.workspace == workspace && !window.app_id.is_empty())
.map(|window| &window.app_id)
.collect::<BTreeSet<_>>()
.into_iter()
.take(2)
.map(|id| {
self.icons
.get(id)
.cloned()
.unwrap_or_else(|| icon::from_svg_bytes(ferese_theme::icons::APPLICATION))
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::control::{OutputSnapshot, WorkspaceSnapshot};
fn snapshot(active: u64) -> ShellSnapshot {
ShellSnapshot {
outputs: vec![OutputSnapshot {
id: 4,
name: "test".into(),
active_workspace: active,
focused: true,
}],
workspaces: [1, 2]
.into_iter()
.map(|id| WorkspaceSnapshot {
id,
name: id.to_string(),
output: Some(4),
index: id as u32,
window_count: 0,
visible: id == active,
focused: id == active,
})
.collect(),
windows: Vec::new(),
}
}
fn add_window(snapshot: &mut ShellSnapshot, app_id: &str) {
snapshot.windows.push(crate::control::WindowSnapshot {
id: snapshot.windows.len() as u64,
workspace: 1,
app_id: app_id.into(),
title: String::new(),
focused: false,
urgent: false,
fullscreen: false,
floating: false,
});
}
#[test]
fn discovery_is_deferred_and_only_one_request_runs_at_a_time() {
let mut ui = WorkspaceUi::default();
let mut snapshot = snapshot(1);
add_window(&mut snapshot, "new-app");
ui.update(&snapshot, motion::Settings::default());
assert!(ui.entries.is_none());
assert_eq!(ui.load_icons(&snapshot, false).units(), 0);
assert!(!ui.icons_loading);
assert_eq!(ui.load_icons(&snapshot, true).units(), 1);
assert!(ui.icons_loading);
assert!(ui.entries.is_none(), "scheduling must not perform filesystem discovery");
assert_eq!(ui.apps(1, &snapshot).len(), 1, "new apps have a bundled placeholder");
assert_eq!(ui.load_icons(&snapshot, true).units(), 0);
assert!(ui.entries.is_none());
assert!(ui.icons.is_empty());
}
#[test]
fn icon_publication_preserves_existing_icons_and_queues_new_windows() {
let mut shell = crate::tests::shell_with_measured_panel();
for group in &mut shell.config.panels[0].start.groups {
for item in &mut group.items {
if matches!(item.kind, crate::panel::ItemKind::Workspaces { .. }) {
item.kind = crate::panel::ItemKind::Workspaces {
style: ferese_config::panel::WorkspaceStyle::AppIcons,
};
}
}
}
shell.snapshot = snapshot(1);
add_window(&mut shell.snapshot, "existing");
add_window(&mut shell.snapshot, "discovered");
shell
.workspace_ui
.icons
.insert("existing".into(), icon::from_svg_bytes(ferese_theme::icons::SETTINGS));
use std::hash::{Hash, Hasher};
let identity = |handle: &icon::Handle| {
let mut hash = std::collections::hash_map::DefaultHasher::new();
handle.hash(&mut hash);
hash.finish()
};
let existing = identity(&shell.workspace_ui.icons["existing"]);
drop(shell.update_workspace_ui());
assert!(shell.workspace_ui.icons_loading);
assert_eq!(identity(&shell.workspace_ui.icons["existing"]), existing);
add_window(&mut shell.snapshot, "arrived-during-discovery");
drop(shell.workspace_icons_loaded(Ok(IconIndex {
entries: Arc::new(Vec::new()),
icons: HashMap::from([("discovered".into(), icon::from_svg_bytes(ferese_theme::icons::OVERVIEW))]),
})));
assert_eq!(identity(&shell.workspace_ui.icons["existing"]), existing);
assert!(shell.workspace_ui.icons.contains_key("discovered"));
assert!(
shell.workspace_ui.icons_loading,
"newly arrived windows need a follow-up request"
);
drop(shell.workspace_icons_loaded(Err("discovery failed".into())));
assert!(!shell.workspace_ui.icons_loading);
assert_eq!(identity(&shell.workspace_ui.icons["existing"]), existing);
}
#[test]
fn switching_during_a_transition_preserves_position_and_reduced_motion_snaps() {
let mut ui = WorkspaceUi::default();
let now = Instant::now();
let settings = motion::Settings::default();
ui.update_at(&snapshot(1), settings, now);
assert_eq!(ui.transition(1, true).progress(now), 1.);
ui.update_at(&snapshot(2), settings, now);
let midpoint = now + Duration::from_millis(80);
assert_eq!(ui.transition(1, false).progress(midpoint), 0.5);
ui.update_at(&snapshot(1), settings, midpoint);
assert_eq!(ui.transition(1, true).progress(midpoint), 0.5);
assert_eq!(ui.transition(2, false).progress(midpoint), 0.5);
assert_eq!(
ui.transition(1, true).progress(midpoint + Duration::from_millis(160)),
1.
);
ui.update_at(
&snapshot(2),
motion::Settings {
reduced_motion: true,
..settings
},
midpoint,
);
assert_eq!(ui.transition(1, false).progress(midpoint), 0.);
assert_eq!(ui.transition(2, true).progress(midpoint), 1.);
assert!(!ui.transition(2, true).active(midpoint));
assert!(ui.entries.is_none(), "other styles must not scan desktop applications");
ui.update_at(&ShellSnapshot::default(), settings, midpoint);
assert!(ui.transitions.is_empty());
}
#[test]
fn active_state_belongs_to_the_workspace_output() {
let mut ui = WorkspaceUi::default();
let mut snapshot = snapshot(1);
snapshot.workspaces[0].output = Some(5);
ui.update(&snapshot, motion::Settings::default());
assert_eq!(ui.transition(1, false).progress(Instant::now()), 0.);
}
#[test]
fn app_slots_are_distinct_and_do_not_jump_when_windows_reorder() {
use crate::control::WindowSnapshot;
use std::hash::{Hash, Hasher};
let mut ui = WorkspaceUi::default();
for (id, svg) in [
("a", ferese_theme::icons::SETTINGS),
("b", ferese_theme::icons::FERESE),
("c", ferese_theme::icons::OVERVIEW),
] {
ui.icons.insert(id.into(), icon::from_svg_bytes(svg));
}
let mut snapshot = snapshot(1);
snapshot.windows = [(1, "b"), (1, "a"), (1, "b"), (1, "c"), (2, "a"), (1, "")]
.into_iter()
.enumerate()
.map(|(id, (workspace, app_id))| WindowSnapshot {
id: id as u64,
workspace,
app_id: app_id.into(),
title: String::new(),
focused: false,
urgent: false,
fullscreen: false,
floating: false,
})
.collect();
let fingerprint = |handles: Vec<icon::Handle>| {
handles
.into_iter()
.map(|handle| {
let mut hash = std::collections::hash_map::DefaultHasher::new();
handle.hash(&mut hash);
hash.finish()
})
.collect::<Vec<_>>()
};
let original = fingerprint(ui.apps(1, &snapshot));
assert_eq!(original.len(), 2);
assert_ne!(original[0], original[1]);
snapshot.windows.reverse();
snapshot.windows[0].focused = true;
assert_eq!(fingerprint(ui.apps(1, &snapshot)), original);
assert_eq!(ui.apps(2, &snapshot).len(), 1);
assert!(ui.apps(3, &snapshot).is_empty());
}
#[test]
fn bounds_lookup_ignores_matching_widgets_on_other_outputs() {
let target = Id::new("workspace:test");
let bar = window::Id::unique();
let mut operation = FindBounds {
target: target.clone(),
bar,
window: None,
found: None,
};
let bounds = Rectangle::with_size(cosmic::iced::Size::new(24., 28.));
operation.set_window_id(window::Id::unique());
operation.container(Some(&target), bounds);
assert_eq!(operation.found, None);
operation.set_window_id(bar);
operation.container(Some(&target), bounds);
assert_eq!(operation.found, Some(bounds));
}
#[test]
fn workspace_widget_exit_cancels_pending_and_visible_tooltips() {
use cosmic::Application;
use cosmic::iced::advanced::renderer::Headless;
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, widget};
use cosmic::iced::{Event, Font, Pixels, Point, Size};
use ferese_config::panel::WorkspaceStyle;
#[derive(Clone, Copy, Debug)]
enum Pointer {
Inside,
Beside,
LeaveAvailable,
LeaveUnavailable,
}
fn send_pointer(
app: &mut FereseShell,
renderer: &cosmic::Renderer,
tree: &mut widget::Tree,
pointer: Pointer,
captured: bool,
) -> (Rectangle, usize) {
let bar = app.outputs[0].bar;
let target = Id::new(format!("workspace:{bar:?}:workspaces:1"));
let viewport = Rectangle::with_size(Size::new(1200., 36.));
let mut view = app.view_layer(bar);
tree.diff(view.as_widget_mut());
let node = view
.as_widget_mut()
.layout(tree, renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
let mut find = FindBounds {
target,
bar,
window: Some(bar),
found: None,
};
view.as_widget_mut()
.operate(tree, Layout::new(&node), renderer, &mut operation::black_box(&mut find));
let bounds = find.found.expect("the real panel must contain the workspace target");
let inside = Point::new(bounds.center_x(), bounds.center_y());
let (event, cursor) = match pointer {
Pointer::Inside | Pointer::Beside => {
let position = if matches!(pointer, Pointer::Inside) {
inside
} else {
Point::new(bounds.x - 2., bounds.center_y())
};
(
Event::Mouse(mouse::Event::CursorMoved { position }),
mouse::Cursor::Available(position),
)
}
Pointer::LeaveAvailable => (Event::Mouse(mouse::Event::CursorLeft), mouse::Cursor::Available(inside)),
Pointer::LeaveUnavailable => (Event::Mouse(mouse::Event::CursorLeft), mouse::Cursor::Unavailable),
};
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
if captured {
shell.capture_event();
}
view.as_widget_mut().update(
tree,
&event,
Layout::new(&node),
cursor,
renderer,
&mut clipboard::Null,
&mut shell,
&viewport,
);
drop(view);
let mut hover_messages = 0;
for message in messages {
if let cosmic::Action::App(message @ Message::HoverWorkspace(..)) = message {
hover_messages += 1;
drop(app.update(message));
}
}
(bounds, hover_messages)
}
let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
for style in [
WorkspaceStyle::Numbers,
WorkspaceStyle::Dots,
WorkspaceStyle::Tabs,
WorkspaceStyle::WindowStacks,
WorkspaceStyle::AppIcons,
] {
for visible in [false, true] {
for (exit, captured) in [
(Pointer::Beside, true),
(Pointer::Beside, false),
(Pointer::LeaveAvailable, false),
(Pointer::LeaveAvailable, true),
(Pointer::LeaveUnavailable, false),
(Pointer::LeaveUnavailable, true),
] {
let mut app = crate::tests::shell_with_measured_panel();
app.snapshot = snapshot(1);
for group in &mut app.config.panels[0].start.groups {
for item in &mut group.items {
if matches!(item.kind, crate::panel::ItemKind::Workspaces { .. }) {
item.kind = crate::panel::ItemKind::Workspaces { style };
}
}
}
let mut tree = widget::Tree::empty();
let (bounds, messages) = send_pointer(&mut app, &renderer, &mut tree, Pointer::Inside, false);
assert!(messages > 0, "{style:?} must enter its actual workspace widget");
assert!(app.workspace_ui.hovered.is_some());
let serial = app.workspace_ui.serial;
if visible {
drop(app.show_workspace_tooltip(serial, Some(bounds)));
assert!(app.workspace_ui.tooltip.is_some());
}
send_pointer(&mut app, &renderer, &mut tree, Pointer::Inside, false);
assert_eq!(
app.workspace_ui.serial, serial,
"movement inside {style:?} must not restart the delay"
);
send_pointer(&mut app, &renderer, &mut tree, exit, captured);
assert!(
app.workspace_ui.hovered.is_none() && app.workspace_ui.tooltip.is_none(),
"{style:?}: {exit:?}, captured={captured}, visible={visible} must cancel the hover"
);
drop(app.show_workspace_tooltip(serial, Some(bounds)));
assert!(
app.workspace_ui.tooltip.is_none(),
"an expired delay must stay cancelled"
);
send_pointer(&mut app, &renderer, &mut tree, Pointer::Inside, false);
assert!(
app.workspace_ui.hovered.is_some(),
"reentry must restore {style:?} hover"
);
}
}
}
}
#[test]
fn leaving_the_bar_cancels_pending_and_visible_tooltips_and_allows_reentry() {
let mut shell = crate::tests::shell_with_measured_panel();
shell.snapshot = snapshot(1);
let bar = shell.outputs[0].bar;
let target = Id::new("workspace:hover");
let bounds = Some(Rectangle::with_size(cosmic::iced::Size::new(24., 28.)));
for visible in [false, true] {
drop(shell.hover_workspace(bar, 1, target.clone(), true));
let serial = shell.workspace_ui.serial;
drop(shell.hover_workspace(bar, 1, target.clone(), true));
assert_eq!(
shell.workspace_ui.serial, serial,
"pointer movement inside a workspace must preserve its delay"
);
if visible {
drop(shell.show_workspace_tooltip(serial, bounds));
assert!(shell.workspace_ui.tooltip.is_some());
}
drop(shell.handle_event(
cosmic::iced::Event::Mouse(cosmic::iced::mouse::Event::CursorLeft),
window::Id::unique(),
));
assert!(
shell.workspace_ui.hovered.is_some(),
"leaving another surface must not cancel this hover"
);
drop(shell.handle_event(cosmic::iced::Event::Mouse(cosmic::iced::mouse::Event::CursorLeft), bar));
assert!(shell.workspace_ui.hovered.is_none());
assert!(shell.workspace_ui.tooltip.is_none());
drop(shell.show_workspace_tooltip(serial, bounds));
assert!(
shell.workspace_ui.tooltip.is_none(),
"a delayed response after pointer exit must stay cancelled"
);
drop(shell.hover_workspace(bar, 1, target.clone(), true));
drop(shell.show_workspace_tooltip(shell.workspace_ui.serial, bounds));
assert!(
shell.workspace_ui.tooltip.is_some(),
"the same workspace must show its tooltip on reentry"
);
drop(shell.dismiss_workspace_tooltip());
}
}
#[test]
fn panel_reallocation_cancels_workspace_tooltips_without_a_pointer_exit() {
use cosmic::Application;
let mut shell = crate::tests::shell_with_measured_panel();
shell.snapshot = snapshot(1);
let bar = shell.outputs[0].bar;
drop(shell.hover_workspace(bar, 1, Id::new("workspace:hover"), true));
let serial = shell.workspace_ui.serial;
let bounds = Some(Rectangle::with_size(cosmic::iced::Size::new(24., 28.)));
drop(shell.show_workspace_tooltip(serial, bounds));
let previous = shell.outputs[0].panel_resolution.clone().unwrap();
drop(shell.update(Message::PanelResolved(bar, previous.clone())));
assert!(shell.workspace_ui.tooltip.is_some());
let mut moved = previous;
moved.zone_widths[0] = 200.;
drop(shell.update(Message::PanelResolved(bar, moved)));
assert!(shell.workspace_ui.tooltip.is_none());
assert!(shell.workspace_ui.hovered.is_none());
drop(shell.show_workspace_tooltip(serial, bounds));
assert!(shell.workspace_ui.tooltip.is_none());
}
#[test]
fn stale_hover_messages_cannot_replace_or_reopen_tooltips() {
let mut shell = crate::tests::shell_with_measured_panel();
shell.snapshot = snapshot(1);
let bar = shell.outputs[0].bar;
let first = Id::new("workspace:first");
let second = Id::new("workspace:second");
let _ = shell.hover_workspace(bar, 1, first.clone(), true);
let old_serial = shell.workspace_ui.serial;
let _ = shell.hover_workspace(bar, 2, second.clone(), true);
let _ = shell.hover_workspace(bar, 1, first, false);
assert_eq!(shell.workspace_ui.hovered.as_ref().unwrap().target, second);
let bounds = Some(Rectangle::with_size(cosmic::iced::Size::new(24., 28.)));
let _ = shell.show_workspace_tooltip(old_serial, bounds);
assert!(shell.workspace_ui.tooltip.is_none());
let current = shell.workspace_ui.serial;
let _ = shell.show_workspace_tooltip(current, bounds);
assert!(shell.workspace_ui.tooltip.is_some());
let _ = shell.dismiss_workspace_tooltip();
let _ = shell.show_workspace_tooltip(current, bounds);
assert!(shell.workspace_ui.tooltip.is_none());
let _ = shell.hover_workspace(bar, 2, second, true);
let current = shell.workspace_ui.serial;
let _ = shell.show_workspace_tooltip(current, bounds);
shell.snapshot.workspaces.retain(|workspace| workspace.id != 2);
let _ = shell.update_workspace_ui();
assert!(shell.workspace_ui.tooltip.is_none());
assert!(shell.workspace_ui.hovered.is_none());
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ use.
## Default shortcuts ## Default shortcuts
Use `Super`, usually the Windows key, for the shortcuts below, and make them your own in Use `Super`, usually the Windows key, for the shortcuts below, and make them your own in
**Settings → Shortcuts**. **Settings → Keyboard & mouse → Shortcuts**.
| Control | Action | | Control | Action |
| --- | --- | | --- | --- |
@@ -32,7 +32,7 @@ Use `Super`, usually the Windows key, for the shortcuts below, and make them you
| Super + right-button drag | Resize a floating window | | Super + right-button drag | Resize a floating window |
| Three-finger swipe up / down | Next / previous workspace | | Three-finger swipe up / down | Next / previous workspace |
| Three-finger swipe left / right | Focus the window to the right / left | | Three-finger swipe left / right | Focus the window to the right / left |
| Login shortcut guide | Appears at login; disable in Settings → Shortcuts | | Login shortcut guide | Appears at login; disable in Settings → Keyboard & mouse → Shortcuts |
The [shortcuts and mouse actions guide](shortcuts.md) covers the rest, including how to The [shortcuts and mouse actions guide](shortcuts.md) covers the rest, including how to
group windows, resize them and move between workspaces. group windows, resize them and move between workspaces.