Add island bar layout and Settings controls
Group workspaces with Overview and status controls with the clock. Keep themed backgrounds and borders around each island, with click-through gaps and configurable side padding defaulting to 4 px. Capture blur within each panel region and update material and input regions when island bounds change. Cover layout reloads and padding changes with widget and native shell tests.
This commit is contained in:
1 parent
90e5c3543d
commit
004a5cdefe
12 files changed
+813
-42
No files matched your search
@@ -80,6 +80,12 @@ pub(crate) struct DaemonConfig {
|
|||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
struct ShellStatusConfig {
|
struct ShellStatusConfig {
|
||||||
|
bar_layout: Option<ferese_config::BarLayout>,
|
||||||
|
#[serde(
|
||||||
|
default = "ferese_config::default_bar_island_padding",
|
||||||
|
deserialize_with = "ferese_config::deserialize_bar_island_padding"
|
||||||
|
)]
|
||||||
|
bar_island_padding: f32,
|
||||||
battery_percentage: Option<bool>,
|
battery_percentage: Option<bool>,
|
||||||
low_battery_threshold: Option<u8>,
|
low_battery_threshold: Option<u8>,
|
||||||
settings_command: Option<Vec<String>>,
|
settings_command: Option<Vec<String>>,
|
||||||
@@ -359,6 +365,29 @@ pub(crate) fn config_path() -> Option<PathBuf> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn bar_layout_is_validated_before_live_publication() {
|
||||||
|
for layout in ["continuous", "islands"] {
|
||||||
|
let source = format!("status {{ bar-layout \"{layout}\"; }}");
|
||||||
|
Config::parse_source(&source).unwrap().runtime_config().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(Config::parse_source("status { bar-layout \"invalid\"; }").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn island_padding_is_validated_before_live_publication() {
|
||||||
|
for padding in ["0", "4.5", "12", "32"] {
|
||||||
|
let source = format!("status {{ bar-island-padding {padding}; }}");
|
||||||
|
Config::parse_source(&source).unwrap().runtime_config().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
for padding in ["-1", "32.5", "\"small\""] {
|
||||||
|
let source = format!("status {{ bar-island-padding {padding}; }}");
|
||||||
|
assert!(Config::parse_source(&source).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[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"] {
|
||||||
|
|||||||
@@ -49,7 +49,13 @@ pub(super) fn append_material_surface(
|
|||||||
geometry: *rect,
|
geometry: *rect,
|
||||||
corners: *corners,
|
corners: *corners,
|
||||||
index,
|
index,
|
||||||
capture_geometry: geometry,
|
capture_geometry: if crate::effects::surface_role(surface)
|
||||||
|
.is_some_and(|(role, _)| role == crate::effects::SemanticRole::Panel)
|
||||||
|
{
|
||||||
|
*rect
|
||||||
|
} else {
|
||||||
|
geometry
|
||||||
|
},
|
||||||
alpha: *alpha,
|
alpha: *alpha,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,29 @@ use serde_json::{Map, Value};
|
|||||||
|
|
||||||
pub const DEFAULT_MATERIAL_OPACITY: f64 = 0.78;
|
pub const DEFAULT_MATERIAL_OPACITY: f64 = 0.78;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BarLayout {
|
||||||
|
#[default]
|
||||||
|
Continuous,
|
||||||
|
Islands,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn default_bar_island_padding() -> f32 {
|
||||||
|
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);
|
||||||
|
|
||||||
|
|||||||
@@ -781,12 +781,27 @@ intentionally turning off the last working one.
|
|||||||
|
|
||||||
| `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 |
|
||||||
|
| `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. |
|
| `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 |
|
| `window-title` | boolean | `true` | Focused window title in the bar center when space allows |
|
||||||
| `battery-percentage` | boolean | `true` | Show percentage beside icon |
|
| `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:
|
||||||
|
|
||||||
|
```kdl
|
||||||
|
status {
|
||||||
|
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.
|
||||||
|
|
||||||
## Login items and locking
|
## Login items and locking
|
||||||
|
|
||||||
| `autostart` key | Type | Default | Meaning |
|
| `autostart` key | Type | Default | Meaning |
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Exercise real shell frame-driven motion on a private nested desktop and bus.
|
"""Exercise real shell frame-driven motion on a private nested desktop and bus.
|
||||||
|
|
||||||
FERESE_TEST_SHELL_MOTION=1 python3 scripts/tests/test_shell_motion_isolated.py
|
FERESE_TEST_SHELL_MOTION=1 python3 scripts/tests/test_shell_motion_isolated.py
|
||||||
|
Set FERESE_TEST_BAR_LAYOUT=islands to also test island masks and live switching.
|
||||||
Uses release binaries by default. Requires a Wayland host, dbus-daemon and gdbus.
|
Uses release binaries by default. Requires a Wayland host, dbus-daemon and gdbus.
|
||||||
Protocol traces contain only this test's shell traffic, never the host desktop.
|
Protocol traces contain only this test's shell traffic, never the host desktop.
|
||||||
"""
|
"""
|
||||||
@@ -28,8 +29,12 @@ class ShellMotionTest(unittest.TestCase):
|
|||||||
runtime.mkdir(mode=0o700)
|
runtime.mkdir(mode=0o700)
|
||||||
config = root / "config/ferese"
|
config = root / "config/ferese"
|
||||||
config.mkdir(parents=True)
|
config.mkdir(parents=True)
|
||||||
(config / "config.kdl").write_text(
|
bar_layout = os.environ.get("FERESE_TEST_BAR_LAYOUT", "continuous")
|
||||||
'animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n')
|
self.assertIn(bar_layout, ("continuous", "islands"))
|
||||||
|
config_file = config / "config.kdl"
|
||||||
|
config_file.write_text(
|
||||||
|
'animations { speed 0.5; }\n'
|
||||||
|
f'status {{ keybinding-guide #false; bar-layout "{bar_layout}"; bar-island-padding 12; }}\n')
|
||||||
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
|
||||||
@@ -88,6 +93,37 @@ class ShellMotionTest(unittest.TestCase):
|
|||||||
identity, start = surface
|
identity, start = surface
|
||||||
return re.search(r'wl_surface[@#]' + identity + r'\.destroy\(', log_path.read_text()[start:]) is not None
|
return re.search(r'wl_surface[@#]' + identity + r'\.destroy\(', log_path.read_text()[start:]) is not None
|
||||||
|
|
||||||
|
def bar_region_count(surface):
|
||||||
|
identity, start = surface
|
||||||
|
trace = log_path.read_text()[start:]
|
||||||
|
effects = re.findall(
|
||||||
|
r'get_surface_effects\(new id ferese_surface_effects_v1[@#](\d+), wl_surface[@#]'
|
||||||
|
+ identity + r'\)', trace)
|
||||||
|
if not effects:
|
||||||
|
return None
|
||||||
|
|
||||||
|
updates = re.findall(
|
||||||
|
r'ferese_surface_effects_v1[@#]' + effects[-1] + r'\.set_regions\(array\[(\d+)\]\)', trace)
|
||||||
|
return int(updates[-1]) // 20 if updates else None
|
||||||
|
|
||||||
|
def bar_input_rectangles(surface):
|
||||||
|
identity, start = surface
|
||||||
|
trace = log_path.read_text()[start:]
|
||||||
|
updates = list(re.finditer(
|
||||||
|
r'wl_surface[@#]' + identity + r'\.set_input_region\(wl_region[@#](\d+)\)', trace))
|
||||||
|
if not updates:
|
||||||
|
return []
|
||||||
|
|
||||||
|
update = updates[-1]
|
||||||
|
region = update.group(1)
|
||||||
|
creations = list(re.finditer(r'create_region\(new id wl_region[@#]' + region + r'\)', trace[:update.start()]))
|
||||||
|
if not creations:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [tuple(map(int, values)) for values in re.findall(
|
||||||
|
r'wl_region[@#]' + region + r'\.add\((-?\d+), (-?\d+), (\d+), (\d+)\)',
|
||||||
|
trace[creations[-1].start():update.start()])]
|
||||||
|
|
||||||
with log_path.open("w") as log:
|
with log_path.open("w") as log:
|
||||||
try:
|
try:
|
||||||
bus = launch(["dbus-daemon", "--config-file=" + str(bus_config), "--nofork", "--print-address=1"],
|
bus = launch(["dbus-daemon", "--config-file=" + str(bus_config), "--nofork", "--print-address=1"],
|
||||||
@@ -98,7 +134,10 @@ class ShellMotionTest(unittest.TestCase):
|
|||||||
stdout=log, stderr=log)
|
stdout=log, stderr=log)
|
||||||
wait_for(lambda: (runtime / "ferese/control.sock").is_socket())
|
wait_for(lambda: (runtime / "ferese/control.sock").is_socket())
|
||||||
wait_for(lambda: dbus("GetServerInformation", check=False).returncode == 0)
|
wait_for(lambda: dbus("GetServerInformation", check=False).returncode == 0)
|
||||||
wait_for(lambda: layer_surface("ferese-shell-top-bar"))
|
bar = wait_for(lambda: layer_surface("ferese-shell-top-bar"))
|
||||||
|
if bar_layout == "islands":
|
||||||
|
wait_for(lambda: bar_region_count(bar) == 2)
|
||||||
|
original_input = wait_for(lambda: bar_input_rectangles(bar))
|
||||||
time.sleep(.5)
|
time.sleep(.5)
|
||||||
reply = dbus("Notify", "Ferese motion test", "0", "", "Frame cadence", "Synthetic test notification", "[]", "{}", "0").stdout
|
reply = dbus("Notify", "Ferese motion test", "0", "", "Frame cadence", "Synthetic test notification", "[]", "{}", "0").stdout
|
||||||
notice = re.search(r'uint32 (\d+)', reply).group(1)
|
notice = re.search(r'uint32 (\d+)', reply).group(1)
|
||||||
@@ -136,6 +175,29 @@ class ShellMotionTest(unittest.TestCase):
|
|||||||
self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:])
|
self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:])
|
||||||
self.assertNotIn("panicked at", log_path.read_text())
|
self.assertNotIn("panicked at", log_path.read_text())
|
||||||
print(f"Modal: {frames(modal)} callbacks; interrupted opening closed and destroyed", flush=True)
|
print(f"Modal: {frames(modal)} callbacks; interrupted opening closed and destroyed", flush=True)
|
||||||
|
|
||||||
|
if bar_layout == "islands":
|
||||||
|
for layout_name, expected_count in [("continuous", 1), ("islands", None)]:
|
||||||
|
source = config_file.read_text()
|
||||||
|
config_file.write_text(re.sub(
|
||||||
|
r'bar-layout "[^"]+"', f'bar-layout "{layout_name}"', source))
|
||||||
|
subprocess.run([str(ctl), "reload-config"], env=env, check=True, capture_output=True)
|
||||||
|
wait_for(lambda: bar_region_count(bar) == (expected_count or 2))
|
||||||
|
|
||||||
|
self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:])
|
||||||
|
self.assertNotIn("panicked at", log_path.read_text())
|
||||||
|
print("Bar: separate material regions; continuous/islands live switches passed", flush=True)
|
||||||
|
|
||||||
|
for padding in [4, 0]:
|
||||||
|
source = config_file.read_text()
|
||||||
|
config_file.write_text(re.sub(
|
||||||
|
r'bar-island-padding \d+', f'bar-island-padding {padding}', source))
|
||||||
|
subprocess.run([str(ctl), "reload-config"], env=env, check=True, capture_output=True)
|
||||||
|
expected_width = original_input[0][2] - 2 * (12 - padding)
|
||||||
|
wait_for(lambda: len(bar_input_rectangles(bar)) == 2
|
||||||
|
and bar_input_rectangles(bar)[0][2] == expected_width)
|
||||||
|
|
||||||
|
print("Bar: live padding reductions updated the input regions", flush=True)
|
||||||
finally:
|
finally:
|
||||||
for child in reversed(children):
|
for child in reversed(children):
|
||||||
if child.poll() is None:
|
if child.poll() is None:
|
||||||
|
|||||||
@@ -853,6 +853,26 @@ 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(
|
||||||
|
"status.bar_island_padding",
|
||||||
|
"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(
|
toggle(
|
||||||
"status.window_title",
|
"status.window_title",
|
||||||
"Focused window title",
|
"Focused window title",
|
||||||
|
|||||||
+199
-28
@@ -3,6 +3,9 @@ use super::{
|
|||||||
bar_icon, button, color, color_with_opacity, container, control, motion, row, status_ui, text, theme, window,
|
bar_icon, button, color, color_with_opacity, container, control, motion, row, status_ui, text, theme, window,
|
||||||
};
|
};
|
||||||
use cosmic::iced::border::Shape as BorderShape;
|
use cosmic::iced::border::Shape as BorderShape;
|
||||||
|
use ferese_config::BarLayout;
|
||||||
|
|
||||||
|
pub(super) mod presentation;
|
||||||
|
|
||||||
impl FereseShell {
|
impl FereseShell {
|
||||||
pub(super) fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> {
|
pub(super) fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> {
|
||||||
@@ -22,22 +25,38 @@ impl FereseShell {
|
|||||||
let focused_output = self.output_for_bar(id);
|
let focused_output = self.output_for_bar(id);
|
||||||
let shell_theme = self.config.theme.for_bar();
|
let shell_theme = self.config.theme.for_bar();
|
||||||
let bar = BarMetrics::from(shell_theme);
|
let bar = BarMetrics::from(shell_theme);
|
||||||
|
let mode = self.config.status.bar_layout;
|
||||||
|
let islands = mode == BarLayout::Islands;
|
||||||
|
let output = self.outputs.iter().find(|output| output.bar == id);
|
||||||
|
let effects = output.and_then(|output| output.effects.as_ref());
|
||||||
|
let compositor_material = effects.is_some();
|
||||||
|
let control_height = if islands {
|
||||||
|
bar.group_item_height
|
||||||
|
} else {
|
||||||
|
bar.control_height
|
||||||
|
};
|
||||||
|
let control_metrics = BarMetrics { control_height, ..bar };
|
||||||
let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1)
|
let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1)
|
||||||
.spacing(3)
|
.spacing(if islands { 8 } else { 3 })
|
||||||
.align_y(cosmic::iced::Alignment::Center);
|
.align_y(cosmic::iced::Alignment::Center);
|
||||||
|
|
||||||
workspace_row = workspace_row.push(motion::button(
|
let overview = motion::button(
|
||||||
button::custom(overview_control(bar, color(shell_theme.accent)))
|
button::custom(overview_control(control_metrics, color(shell_theme.accent)))
|
||||||
.height(bar.control_height)
|
.height(control_height)
|
||||||
.padding([0, 7])
|
.padding([0, 7])
|
||||||
.on_press(cosmic::Action::App(Message::ToggleOverview)),
|
.on_press(cosmic::Action::App(Message::ToggleOverview)),
|
||||||
color(shell_theme.text_primary),
|
color(shell_theme.text_primary),
|
||||||
self.overview_active,
|
self.overview_active,
|
||||||
1.0,
|
1.0,
|
||||||
));
|
);
|
||||||
let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len())
|
let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len() + usize::from(islands))
|
||||||
.spacing(1)
|
.spacing(1)
|
||||||
.align_y(cosmic::iced::Alignment::Center);
|
.align_y(cosmic::iced::Alignment::Center);
|
||||||
|
if islands {
|
||||||
|
workspace_buttons = workspace_buttons.push(overview);
|
||||||
|
} else {
|
||||||
|
workspace_row = workspace_row.push(overview);
|
||||||
|
}
|
||||||
|
|
||||||
for workspace in self
|
for workspace in self
|
||||||
.snapshot
|
.snapshot
|
||||||
@@ -88,12 +107,17 @@ impl FereseShell {
|
|||||||
// accessible button name, without an overlay stealing its target.
|
// accessible button name, without an overlay stealing its target.
|
||||||
workspace_buttons = workspace_buttons.push(selector);
|
workspace_buttons = workspace_buttons.push(selector);
|
||||||
}
|
}
|
||||||
workspace_row = workspace_row.push(
|
workspace_row = workspace_row.push(island(
|
||||||
container(workspace_buttons)
|
container(workspace_buttons)
|
||||||
.padding([2, 3])
|
.padding([2, 3])
|
||||||
.height(bar.group_height)
|
.height(bar.group_height)
|
||||||
.class(theme::Container::custom(move |_| bar_group_style(shell_theme))),
|
.class(theme::Container::custom(move |_| bar_group_style(shell_theme)))
|
||||||
);
|
.into(),
|
||||||
|
shell_theme,
|
||||||
|
self.config.status.bar_island_padding,
|
||||||
|
islands,
|
||||||
|
compositor_material,
|
||||||
|
));
|
||||||
|
|
||||||
let foreground = color(shell_theme.text_primary);
|
let foreground = color(shell_theme.text_primary);
|
||||||
let left = cosmic::iced::widget::scrollable(workspace_row)
|
let left = cosmic::iced::widget::scrollable(workspace_row)
|
||||||
@@ -103,9 +127,9 @@ impl FereseShell {
|
|||||||
.scroller_width(0),
|
.scroller_width(0),
|
||||||
))
|
))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(bar.group_height);
|
.height(if islands { bar.height } else { bar.group_height });
|
||||||
let (date, time) = self.clock.split_once(", ").unwrap_or(("", &self.clock));
|
let (date, time) = self.clock.split_once(", ").unwrap_or(("", &self.clock));
|
||||||
let clock = container(motion::button(
|
let clock = motion::button(
|
||||||
button::custom(
|
button::custom(
|
||||||
container(
|
container(
|
||||||
row![
|
row![
|
||||||
@@ -137,11 +161,17 @@ impl FereseShell {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|menu| menu.kind == status_ui::Menu::Calendar),
|
.is_some_and(|menu| menu.kind == status_ui::Menu::Calendar),
|
||||||
1.0,
|
1.0,
|
||||||
))
|
);
|
||||||
|
let clock: Element<'_, cosmic::Action<Message>> = if islands {
|
||||||
|
clock
|
||||||
|
} else {
|
||||||
|
container(clock)
|
||||||
.padding([2, 3])
|
.padding([2, 3])
|
||||||
.height(bar.group_height)
|
.height(bar.group_height)
|
||||||
.align_y(alignment::Vertical::Center)
|
.align_y(alignment::Vertical::Center)
|
||||||
.class(theme::Container::custom(move |_| bar_group_style(shell_theme)));
|
.class(theme::Container::custom(move |_| bar_group_style(shell_theme)))
|
||||||
|
.into()
|
||||||
|
};
|
||||||
let mut right = row![
|
let mut right = row![
|
||||||
self.view_status_bar().map(move |action| match action {
|
self.view_status_bar().map(move |action| match action {
|
||||||
cosmic::Action::App(Message::OpenMenu(kind, anchor)) =>
|
cosmic::Action::App(Message::OpenMenu(kind, anchor)) =>
|
||||||
@@ -153,13 +183,13 @@ impl FereseShell {
|
|||||||
.spacing(8)
|
.spacing(8)
|
||||||
.align_y(cosmic::iced::Alignment::Center);
|
.align_y(cosmic::iced::Alignment::Center);
|
||||||
if self.display_mode.external_connected() {
|
if self.display_mode.external_connected() {
|
||||||
right = right.push(motion::button(
|
let display = motion::button(
|
||||||
button::custom(bar_content(
|
button::custom(bar_content(
|
||||||
bar_icon(ferese_theme::icons::DISPLAY, bar.icon_size, foreground),
|
bar_icon(ferese_theme::icons::DISPLAY, bar.icon_size, foreground),
|
||||||
bar.control_height,
|
control_height,
|
||||||
))
|
))
|
||||||
.name("Display mode")
|
.name("Display mode")
|
||||||
.height(bar.control_height)
|
.height(control_height)
|
||||||
.padding([0, 7])
|
.padding([0, 7])
|
||||||
.on_press(cosmic::Action::App(Message::OpenDisplays(
|
.on_press(cosmic::Action::App(Message::OpenDisplays(
|
||||||
self.outputs
|
self.outputs
|
||||||
@@ -170,8 +200,24 @@ impl FereseShell {
|
|||||||
foreground,
|
foreground,
|
||||||
self.display_mode.open,
|
self.display_mode.open,
|
||||||
1.0,
|
1.0,
|
||||||
));
|
);
|
||||||
|
right = right.push(display);
|
||||||
}
|
}
|
||||||
|
let right: Element<'_, cosmic::Action<Message>> = if islands {
|
||||||
|
island(
|
||||||
|
container(right)
|
||||||
|
.padding([2, 3])
|
||||||
|
.height(bar.group_height)
|
||||||
|
.class(theme::Container::custom(move |_| bar_group_style(shell_theme)))
|
||||||
|
.into(),
|
||||||
|
shell_theme,
|
||||||
|
self.config.status.bar_island_padding,
|
||||||
|
true,
|
||||||
|
compositor_material,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
right.into()
|
||||||
|
};
|
||||||
let available = self
|
let available = self
|
||||||
.outputs
|
.outputs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -188,7 +234,7 @@ impl FereseShell {
|
|||||||
let center = text(title)
|
let center = text(title)
|
||||||
.size(bar.text_size)
|
.size(bar.text_size)
|
||||||
.width(title_width)
|
.width(title_width)
|
||||||
.height(bar.control_height)
|
.height(control_height)
|
||||||
.align_x(alignment::Horizontal::Center)
|
.align_x(alignment::Horizontal::Center)
|
||||||
.align_y(alignment::Vertical::Center)
|
.align_y(alignment::Vertical::Center)
|
||||||
.wrapping(cosmic::iced::widget::text::Wrapping::None)
|
.wrapping(cosmic::iced::widget::text::Wrapping::None)
|
||||||
@@ -196,6 +242,21 @@ impl FereseShell {
|
|||||||
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
|
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
|
||||||
))
|
))
|
||||||
.class(theme::Text::Color(foreground));
|
.class(theme::Text::Color(foreground));
|
||||||
|
let center: Element<'_, cosmic::Action<Message>> = if islands && !title.is_empty() {
|
||||||
|
island(
|
||||||
|
container(center)
|
||||||
|
.height(bar.group_height)
|
||||||
|
.align_y(alignment::Vertical::Center)
|
||||||
|
.class(theme::Container::custom(move |_| bar_group_style(shell_theme)))
|
||||||
|
.into(),
|
||||||
|
shell_theme,
|
||||||
|
self.config.status.bar_island_padding,
|
||||||
|
true,
|
||||||
|
compositor_material,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
center.into()
|
||||||
|
};
|
||||||
let right = cosmic::iced::widget::scrollable(container(right).width(Length::Shrink))
|
let right = cosmic::iced::widget::scrollable(container(right).width(Length::Shrink))
|
||||||
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
|
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
|
||||||
cosmic::iced::widget::scrollable::Scrollbar::default()
|
cosmic::iced::widget::scrollable::Scrollbar::default()
|
||||||
@@ -204,7 +265,7 @@ impl FereseShell {
|
|||||||
))
|
))
|
||||||
.anchor_right()
|
.anchor_right()
|
||||||
.width(Length::Shrink)
|
.width(Length::Shrink)
|
||||||
.height(bar.group_height);
|
.height(if islands { bar.height } else { bar.group_height });
|
||||||
let content = row![
|
let content = row![
|
||||||
container(left).width(Length::Fill),
|
container(left).width(Length::Fill),
|
||||||
center,
|
center,
|
||||||
@@ -216,18 +277,28 @@ impl FereseShell {
|
|||||||
.align_y(cosmic::iced::Alignment::Center)
|
.align_y(cosmic::iced::Alignment::Center)
|
||||||
.height(Length::Fill);
|
.height(Length::Fill);
|
||||||
|
|
||||||
let compositor_material = self
|
let content = container(content)
|
||||||
.outputs
|
|
||||||
.iter()
|
|
||||||
.any(|entry| entry.bar == id && entry.effects.is_some());
|
|
||||||
container(content)
|
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.padding([0, shell_theme.panel_padding.round() as u16])
|
.padding([0, shell_theme.panel_padding.round() as u16])
|
||||||
.class(theme::Container::custom(move |_| {
|
.class(theme::Container::custom(move |_| {
|
||||||
bar_style(shell_theme, compositor_material)
|
bar_style(shell_theme, compositor_material, islands)
|
||||||
}))
|
}))
|
||||||
.into()
|
.into();
|
||||||
|
presentation::frame(
|
||||||
|
content,
|
||||||
|
mode,
|
||||||
|
shell_theme.bar_radius,
|
||||||
|
move |regions| {
|
||||||
|
if let Some(effects) = effects
|
||||||
|
&& let Err(error) =
|
||||||
|
effects.set_material_regions(regions, super::ferese_surface_effects_v1::Role::Panel)
|
||||||
|
{
|
||||||
|
eprintln!("ferese-shell: could not update bar material: {error}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move |regions| cosmic::Action::App(Message::BarRegionsChanged(id, regions)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,8 +427,12 @@ pub(super) fn workspace_selector_style(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn bar_style(theme: ShellTheme, compositor_material: bool) -> container::Style {
|
pub(super) fn bar_style(theme: ShellTheme, compositor_material: bool, islands: bool) -> container::Style {
|
||||||
let mut style = ferese_theme::controls::surface_appearance(color(theme.bar_background), theme.bar_radius);
|
let mut style = if islands {
|
||||||
|
container::Style::default()
|
||||||
|
} else {
|
||||||
|
ferese_theme::controls::surface_appearance(color(theme.bar_background), theme.bar_radius)
|
||||||
|
};
|
||||||
if compositor_material {
|
if compositor_material {
|
||||||
style.background = None;
|
style.background = None;
|
||||||
}
|
}
|
||||||
@@ -367,6 +442,60 @@ pub(super) fn bar_style(theme: ShellTheme, compositor_material: bool) -> contain
|
|||||||
style
|
style
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn island<'a>(
|
||||||
|
content: Element<'a, cosmic::Action<Message>>,
|
||||||
|
theme: ShellTheme,
|
||||||
|
horizontal_padding: f32,
|
||||||
|
islands: bool,
|
||||||
|
compositor_material: bool,
|
||||||
|
) -> Element<'a, cosmic::Action<Message>> {
|
||||||
|
if !islands {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bar = BarMetrics::from(theme);
|
||||||
|
container(content)
|
||||||
|
.id(presentation::island_id())
|
||||||
|
.padding(cosmic::iced::Padding {
|
||||||
|
top: (bar.height - bar.group_height) * 0.5,
|
||||||
|
bottom: (bar.height - bar.group_height) * 0.5,
|
||||||
|
left: horizontal_padding,
|
||||||
|
right: horizontal_padding,
|
||||||
|
})
|
||||||
|
.height(bar.height)
|
||||||
|
.align_y(alignment::Vertical::Center)
|
||||||
|
.class(cosmic::theme::Container::custom(move |_| {
|
||||||
|
bar_style(theme, compositor_material, false)
|
||||||
|
}))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn input_region(
|
||||||
|
mode: BarLayout,
|
||||||
|
hidden: bool,
|
||||||
|
regions: &[[f32; 5]],
|
||||||
|
) -> Option<Vec<cosmic::iced::Rectangle>> {
|
||||||
|
if hidden {
|
||||||
|
return Some(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == BarLayout::Continuous {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(
|
||||||
|
regions
|
||||||
|
.iter()
|
||||||
|
.map(|r| cosmic::iced::Rectangle {
|
||||||
|
x: r[0].floor(),
|
||||||
|
y: r[1].floor(),
|
||||||
|
width: (r[0] + r[2]).ceil() - r[0].floor(),
|
||||||
|
height: (r[1] + r[3]).ceil() - r[1].floor(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn bar_group_style(theme: ShellTheme) -> container::Style {
|
pub(super) fn bar_group_style(theme: ShellTheme) -> container::Style {
|
||||||
container::Style {
|
container::Style {
|
||||||
background: Some(Background::Color(color_with_opacity(theme.border, 0.25))),
|
background: Some(Background::Color(color_with_opacity(theme.border, 0.25))),
|
||||||
@@ -380,3 +509,45 @@ pub(super) fn bar_group_style(theme: ShellTheme) -> container::Style {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod island_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn island_gaps_are_click_through_and_fullscreen_disables_every_input_region() {
|
||||||
|
let regions = [[12.25, 2.5, 80.5, 24., 14.], [400., 2., 160., 24., 14.]];
|
||||||
|
let input = input_region(BarLayout::Islands, false, ®ions).unwrap();
|
||||||
|
assert_eq!(input.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
input[0],
|
||||||
|
cosmic::iced::Rectangle::new((12., 2.).into(), (81., 25.).into())
|
||||||
|
);
|
||||||
|
assert!(!input.iter().any(|region| region.contains((240., 14.).into())));
|
||||||
|
assert_eq!(input_region(BarLayout::Islands, true, ®ions), Some(Vec::new()));
|
||||||
|
assert_eq!(input_region(BarLayout::Continuous, false, ®ions), None);
|
||||||
|
assert_eq!(input_region(BarLayout::Continuous, true, ®ions), Some(Vec::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn islands_reuse_the_bar_background_without_replacing_the_inner_section_style() {
|
||||||
|
let theme = ShellTheme {
|
||||||
|
bar_background: [12, 24, 36, 153],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let frame = bar_style(theme, false, true);
|
||||||
|
assert!(frame.background.is_none());
|
||||||
|
assert_eq!(frame.border.width, 0.);
|
||||||
|
assert_eq!(frame.shadow.color.a, 0.);
|
||||||
|
let outer = bar_style(theme, false, false);
|
||||||
|
assert_eq!(outer.background, Some(Background::Color(color(theme.bar_background))));
|
||||||
|
assert_eq!(outer.border.radius, theme.bar_radius.into());
|
||||||
|
assert!(bar_style(theme, true, false).background.is_none());
|
||||||
|
let inner = bar_group_style(theme);
|
||||||
|
assert_eq!(
|
||||||
|
inner.background,
|
||||||
|
Some(Background::Color(color_with_opacity(theme.border, 0.25)))
|
||||||
|
);
|
||||||
|
assert_eq!(inner.border.width, 1.);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
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::BarLayout;
|
||||||
|
|
||||||
|
pub(crate) fn island_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>(
|
||||||
|
content: Element<'a, M>,
|
||||||
|
mode: BarLayout,
|
||||||
|
radius: f32,
|
||||||
|
present: impl Fn(&[[f32; 5]]) + 'a,
|
||||||
|
changed: impl Fn(Vec<[f32; 5]>) -> M + 'a,
|
||||||
|
) -> Element<'a, M> {
|
||||||
|
Element::new(Presentation {
|
||||||
|
content,
|
||||||
|
mode,
|
||||||
|
radius,
|
||||||
|
present: Box::new(present),
|
||||||
|
changed: Box::new(changed),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct State {
|
||||||
|
mode: Option<BarLayout>,
|
||||||
|
current: Vec<[f32; 5]>,
|
||||||
|
scratch: Vec<[f32; 5]>,
|
||||||
|
scroll_starts: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Present<'a> = Box<dyn Fn(&[[f32; 5]]) + 'a>;
|
||||||
|
|
||||||
|
struct Presentation<'a, M> {
|
||||||
|
content: Element<'a, M>,
|
||||||
|
mode: BarLayout,
|
||||||
|
radius: f32,
|
||||||
|
present: Present<'a>,
|
||||||
|
changed: Box<dyn Fn(Vec<[f32; 5]>) -> M + 'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, 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> {
|
||||||
|
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: &cosmic::Renderer,
|
||||||
|
limits: &layout::Limits,
|
||||||
|
) -> layout::Node {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
if !matches!(event, Event::Window(cosmic::iced::window::Event::RedrawRequested(_))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = tree.state.downcast_mut::<State>();
|
||||||
|
let mut collector = Collect {
|
||||||
|
regions: std::mem::take(&mut state.scratch),
|
||||||
|
scroll_starts: std::mem::take(&mut state.scroll_starts),
|
||||||
|
radius: self.radius,
|
||||||
|
};
|
||||||
|
collector.regions.clear();
|
||||||
|
collector.scroll_starts.clear();
|
||||||
|
if self.mode == BarLayout::Islands {
|
||||||
|
self.content
|
||||||
|
.as_widget_mut()
|
||||||
|
.operate(&mut tree.children[0], layout, renderer, &mut collector);
|
||||||
|
} else {
|
||||||
|
let bounds = layout.bounds();
|
||||||
|
collector
|
||||||
|
.regions
|
||||||
|
.push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]);
|
||||||
|
}
|
||||||
|
|
||||||
|
collector.clip_from(0, layout.bounds(), Vector::ZERO);
|
||||||
|
collector.regions.retain(|region| region[2] > 0.0 && region[3] > 0.0);
|
||||||
|
(self.present)(&collector.regions);
|
||||||
|
if state.mode != Some(self.mode) || state.current != collector.regions {
|
||||||
|
state.mode = Some(self.mode);
|
||||||
|
state.current.clone_from(&collector.regions);
|
||||||
|
shell.publish((self.changed)(collector.regions.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
state.scratch = collector.regions;
|
||||||
|
state.scroll_starts = collector.scroll_starts;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Collect {
|
||||||
|
regions: Vec<[f32; 5]>,
|
||||||
|
scroll_starts: Vec<usize>,
|
||||||
|
radius: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Collect {
|
||||||
|
fn clip_from(&mut self, start: usize, viewport: Rectangle, translation: Vector) {
|
||||||
|
for region in &mut self.regions[start..] {
|
||||||
|
let bounds = Rectangle {
|
||||||
|
x: region[0] - translation.x,
|
||||||
|
y: region[1] - translation.y,
|
||||||
|
width: region[2],
|
||||||
|
height: region[3],
|
||||||
|
};
|
||||||
|
let bounds = bounds.intersection(&viewport).unwrap_or_default();
|
||||||
|
region[..4].copy_from_slice(&[bounds.x, bounds.y, bounds.width, bounds.height]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl widget::Operation for Collect {
|
||||||
|
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(&island_id()) {
|
||||||
|
self.regions
|
||||||
|
.push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pre_operation(&mut self, _id: Option<&widget::Id>) {
|
||||||
|
// Iced visits scrollable content before its scrollable callback. Record
|
||||||
|
// the range so only that subtree receives its offset and viewport clip.
|
||||||
|
self.scroll_starts.push(self.regions.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scrollable(
|
||||||
|
&mut self,
|
||||||
|
_id: Option<&widget::Id>,
|
||||||
|
bounds: Rectangle,
|
||||||
|
_content: Rectangle,
|
||||||
|
translation: Vector,
|
||||||
|
_state: &mut dyn widget::operation::Scrollable,
|
||||||
|
) {
|
||||||
|
if let Some(start) = self.scroll_starts.pop() {
|
||||||
|
self.clip_from(start, bounds, translation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use cosmic::iced::advanced::{clipboard, renderer::Headless};
|
||||||
|
use cosmic::iced::{Font, Pixels, widget as widgets};
|
||||||
|
use cosmic::widget::{container, row};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn measured_islands_and_live_layout_switches_publish_only_changed_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 viewport = Rectangle::new((0., 0.).into(), (600., 28.).into());
|
||||||
|
let mut tree = None;
|
||||||
|
let theme = crate::ShellTheme {
|
||||||
|
bar_height: 28.,
|
||||||
|
panel_padding: 12.,
|
||||||
|
bar_radius: 14.,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
for (mode, padding) in [
|
||||||
|
(BarLayout::Islands, 12.),
|
||||||
|
(BarLayout::Islands, 4.),
|
||||||
|
(BarLayout::Islands, 0.),
|
||||||
|
(BarLayout::Continuous, 32.),
|
||||||
|
(BarLayout::Islands, 12.),
|
||||||
|
] {
|
||||||
|
let section = |width| {
|
||||||
|
let inner = container(widgets::Space::new().width(width).height(24))
|
||||||
|
.class(cosmic::theme::Container::custom(move |_| {
|
||||||
|
crate::bar::bar_group_style(theme)
|
||||||
|
}))
|
||||||
|
.into();
|
||||||
|
crate::bar::island(inner, theme, padding, mode == BarLayout::Islands, true)
|
||||||
|
};
|
||||||
|
let islands = row![section(100), section(200),].spacing(60);
|
||||||
|
let content = container(islands)
|
||||||
|
.padding([0, 12])
|
||||||
|
.width(600)
|
||||||
|
.height(28)
|
||||||
|
.align_y(cosmic::iced::alignment::Vertical::Center)
|
||||||
|
.into();
|
||||||
|
let mut view = frame(
|
||||||
|
content,
|
||||||
|
mode,
|
||||||
|
14.,
|
||||||
|
|_| {},
|
||||||
|
|regions| {
|
||||||
|
cosmic::Action::App(crate::Message::BarRegionsChanged(
|
||||||
|
cosmic::iced::window::Id::unique(),
|
||||||
|
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();
|
||||||
|
|
||||||
|
for _ in 0..5 {
|
||||||
|
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 frames must not rebuild input regions");
|
||||||
|
let expected = if mode == BarLayout::Islands {
|
||||||
|
vec![
|
||||||
|
[12., 0., 100. + 2. * padding, 28., 14.],
|
||||||
|
[172. + 2. * padding, 0., 200. + 2. * padding, 28., 14.],
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![[0., 0., 600., 28., 14.]]
|
||||||
|
};
|
||||||
|
let cosmic::Action::App(crate::Message::BarRegionsChanged(_, regions)) = &messages[0] else {
|
||||||
|
panic!("expected measured bar regions");
|
||||||
|
};
|
||||||
|
assert_eq!(*regions, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrolling_clips_only_its_own_islands_and_preserves_fractional_positions() {
|
||||||
|
let mut collector = Collect {
|
||||||
|
regions: vec![[400., 2., 80., 24., 14.], [10.25, 2., 180., 24., 14.]],
|
||||||
|
scroll_starts: Vec::new(),
|
||||||
|
radius: 14.,
|
||||||
|
};
|
||||||
|
collector.clip_from(
|
||||||
|
1,
|
||||||
|
Rectangle::new((20., 0.).into(), (100., 28.).into()),
|
||||||
|
Vector::new(40.5, 0.),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
collector.regions,
|
||||||
|
vec![[400., 2., 80., 24., 14.], [20., 2., 100., 24., 14.]]
|
||||||
|
);
|
||||||
|
collector.clip_from(0, Rectangle::new((0., 0.).into(), (200., 28.).into()), Vector::ZERO);
|
||||||
|
collector.regions.retain(|region| region[2] > 0. && region[3] > 0.);
|
||||||
|
assert_eq!(collector.regions, vec![[20., 2., 100., 24., 14.]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ pub(crate) struct ShellConfig {
|
|||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
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) battery_percentage: bool,
|
||||||
pub(crate) window_title: bool,
|
pub(crate) window_title: bool,
|
||||||
@@ -30,6 +33,8 @@ 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,
|
battery_percentage: true,
|
||||||
window_title: true,
|
window_title: true,
|
||||||
@@ -634,6 +639,47 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn island_layout_is_opt_in_and_does_not_change_modal_opacity() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_test_source("").unwrap().status.bar_layout,
|
||||||
|
ferese_config::BarLayout::Continuous
|
||||||
|
);
|
||||||
|
let normal = parse_test_source("theme { material { style \"translucent\"; opacity 0.7; }; }").unwrap();
|
||||||
|
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]
|
||||||
|
fn island_padding_accepts_compact_and_fractional_values_without_changing_panel_padding() {
|
||||||
|
let defaults = parse_test_source("").unwrap();
|
||||||
|
assert_eq!(defaults.status.bar_island_padding, 4.);
|
||||||
|
assert_eq!(
|
||||||
|
parse_test_source("status { bar-layout \"islands\"; }")
|
||||||
|
.unwrap()
|
||||||
|
.status
|
||||||
|
.bar_island_padding,
|
||||||
|
4.
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
for value in ["-1", "32.5", "\"small\""] {
|
||||||
|
assert!(parse_test_source(&format!("status {{ bar-island-padding {value}; }}")).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_theme_without_rejecting_compositor_sections() {
|
fn parses_theme_without_rejecting_compositor_sections() {
|
||||||
let config: FereseConfig = ferese_config::from_str(
|
let config: FereseConfig = ferese_config::from_str(
|
||||||
|
|||||||
@@ -223,11 +223,13 @@ struct OutputSurfaces {
|
|||||||
notes: Vec<(String, window::Id)>,
|
notes: Vec<(String, window::Id)>,
|
||||||
size: Option<(i32, i32)>,
|
size: Option<(i32, i32)>,
|
||||||
effects: Option<EffectsBinding>,
|
effects: Option<EffectsBinding>,
|
||||||
|
bar_regions: Vec<[f32; 5]>,
|
||||||
hidden: bool,
|
hidden: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
enum Message {
|
enum Message {
|
||||||
|
BarRegionsChanged(window::Id, Vec<[f32; 5]>),
|
||||||
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>),
|
||||||
@@ -413,6 +415,18 @@ 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::BarRegionsChanged(id, regions) => {
|
||||||
|
let Some(output) = self.outputs.iter_mut().find(|output| output.bar == id) else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
|
||||||
|
output.bar_regions = regions;
|
||||||
|
set_input_zone(
|
||||||
|
id,
|
||||||
|
bar::input_region(self.config.status.bar_layout, output.hidden, &output.bar_regions),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Message::ThemeChanged(snapshot) => {
|
Message::ThemeChanged(snapshot) => {
|
||||||
let mut config = self.config.clone();
|
let mut config = self.config.clone();
|
||||||
config.apply_theme(&snapshot.presented);
|
config.apply_theme(&snapshot.presented);
|
||||||
@@ -793,7 +807,10 @@ impl cosmic::Application for FereseShell {
|
|||||||
{
|
{
|
||||||
eprintln!("ferese-shell: could not update panel material: {error}");
|
eprintln!("ferese-shell: could not update panel material: {error}");
|
||||||
}
|
}
|
||||||
tasks.push(set_input_zone(entry.bar, if hidden { Some(Vec::new()) } else { None }));
|
tasks.push(set_input_zone(
|
||||||
|
entry.bar,
|
||||||
|
bar::input_region(self.config.status.bar_layout, hidden, &entry.bar_regions),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if self.bar_hidden(self.bar_surface_id) {
|
if self.bar_hidden(self.bar_surface_id) {
|
||||||
tasks.push(self.destroy_menu());
|
tasks.push(self.destroy_menu());
|
||||||
@@ -1116,7 +1133,7 @@ mod tests {
|
|||||||
fn startup_surface_clear_is_transparent_and_bar_fallback_is_dark() {
|
fn startup_surface_clear_is_transparent_and_bar_fallback_is_dark() {
|
||||||
let theme = ShellTheme::default();
|
let theme = ShellTheme::default();
|
||||||
assert_eq!(shell_surface_style(theme).background_color, Color::TRANSPARENT);
|
assert_eq!(shell_surface_style(theme).background_color, Color::TRANSPARENT);
|
||||||
let Some(Background::Color(background)) = bar_style(theme.for_bar(), false).background else {
|
let Some(Background::Color(background)) = bar_style(theme.for_bar(), false, false).background else {
|
||||||
panic!("first frame needs a fallback fill before material attachment");
|
panic!("first frame needs a fallback fill before material attachment");
|
||||||
};
|
};
|
||||||
assert!(background.r < 0.15 && background.g < 0.15 && background.b < 0.20);
|
assert!(background.r < 0.15 && background.g < 0.15 && background.b < 0.20);
|
||||||
@@ -1127,7 +1144,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn bar_fallback_uses_the_matching_dark_palette_and_geometry() {
|
fn bar_fallback_uses_the_matching_dark_palette_and_geometry() {
|
||||||
let theme = ShellTheme::default().for_bar();
|
let theme = ShellTheme::default().for_bar();
|
||||||
let style = bar_style(theme, false);
|
let style = bar_style(theme, false, false);
|
||||||
assert_eq!(style.background, Some(Background::Color(color(theme.bar_background))));
|
assert_eq!(style.background, Some(Background::Color(color(theme.bar_background))));
|
||||||
assert_eq!(style.text_color, Some(color(theme.bar_text_primary)));
|
assert_eq!(style.text_color, Some(color(theme.bar_text_primary)));
|
||||||
assert_eq!(style.border.radius, theme.bar_radius.into());
|
assert_eq!(style.border.radius, theme.bar_radius.into());
|
||||||
@@ -1137,7 +1154,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn bar_does_not_cover_the_selected_compositor_material() {
|
fn bar_does_not_cover_the_selected_compositor_material() {
|
||||||
let theme = ShellTheme::default().for_bar();
|
let theme = ShellTheme::default().for_bar();
|
||||||
let style = bar_style(theme, true);
|
let style = bar_style(theme, true, false);
|
||||||
assert_eq!(style.background, None);
|
assert_eq!(style.background, None);
|
||||||
assert_eq!(style.text_color, Some(color(theme.bar_text_primary)));
|
assert_eq!(style.text_color, Some(color(theme.bar_text_primary)));
|
||||||
assert_eq!(style.border.radius, theme.bar_radius.into());
|
assert_eq!(style.border.radius, theme.bar_radius.into());
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ use cosmic::{Element, theme};
|
|||||||
|
|
||||||
use super::Menu;
|
use super::Menu;
|
||||||
use crate::status::Snapshot;
|
use crate::status::Snapshot;
|
||||||
use crate::{
|
use crate::{BarMetrics, FereseShell, Message, accented_icon, bar_content, color, motion, recording, text};
|
||||||
BarMetrics, FereseShell, Message, accented_icon, bar_content, bar_group_style, color, motion, recording, text,
|
|
||||||
};
|
|
||||||
|
|
||||||
impl FereseShell {
|
impl FereseShell {
|
||||||
pub fn view_status_bar(&self) -> Element<'_, cosmic::Action<Message>> {
|
pub fn view_status_bar(&self) -> 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;
|
||||||
let metrics = BarMetrics::from(theme);
|
let metrics = BarMetrics::from(theme);
|
||||||
let mut controls = row::with_capacity(6).spacing(1).align_y(Alignment::Center);
|
let mut controls = row::with_capacity(6).spacing(1).align_y(Alignment::Center);
|
||||||
for kind in [
|
for kind in [
|
||||||
@@ -125,10 +124,14 @@ impl FereseShell {
|
|||||||
});
|
});
|
||||||
controls = controls.push(motion::button(control, foreground, selected, 1.0));
|
controls = controls.push(motion::button(control, foreground, selected, 1.0));
|
||||||
}
|
}
|
||||||
|
if islands {
|
||||||
|
return controls.into();
|
||||||
|
}
|
||||||
|
|
||||||
container(controls)
|
container(controls)
|
||||||
.padding([2, 3])
|
.padding([2, 3])
|
||||||
.height(metrics.group_height)
|
.height(metrics.group_height)
|
||||||
.class(theme::Container::custom(move |_| bar_group_style(theme)))
|
.class(theme::Container::custom(move |_| crate::bar::bar_group_style(theme)))
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ impl FereseShell {
|
|||||||
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 shell_theme = self.config.theme;
|
||||||
|
let bar_layout = self.config.status.bar_layout;
|
||||||
let bar = BarMetrics::from(shell_theme);
|
let bar = BarMetrics::from(shell_theme);
|
||||||
let wallpaper_output = output.clone();
|
let wallpaper_output = output.clone();
|
||||||
let bar_output = output.clone();
|
let bar_output = output.clone();
|
||||||
@@ -94,6 +95,7 @@ impl FereseShell {
|
|||||||
.is_none()
|
.is_none()
|
||||||
.then_some(wallpaper_surface_id),
|
.then_some(wallpaper_surface_id),
|
||||||
effects: None,
|
effects: None,
|
||||||
|
bar_regions: Vec::new(),
|
||||||
clock: None,
|
clock: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
size,
|
size,
|
||||||
@@ -122,7 +124,7 @@ impl FereseShell {
|
|||||||
|_| Default::default(),
|
|_| Default::default(),
|
||||||
move |_| SctkLayerSurfaceSettings {
|
move |_| SctkLayerSurfaceSettings {
|
||||||
id: bar_surface_id,
|
id: bar_surface_id,
|
||||||
input_zone: hidden.then(Vec::new),
|
input_zone: super::bar::input_region(bar_layout, hidden, &[]),
|
||||||
layer: Layer::Top,
|
layer: Layer::Top,
|
||||||
keyboard_interactivity: KeyboardInteractivity::None,
|
keyboard_interactivity: KeyboardInteractivity::None,
|
||||||
anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT,
|
anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT,
|
||||||
@@ -394,6 +396,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 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;
|
||||||
};
|
};
|
||||||
@@ -404,7 +407,14 @@ impl FereseShell {
|
|||||||
|
|
||||||
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
||||||
|
|
||||||
match EffectsBinding::attach(surface, !hidden) {
|
let binding = if islands {
|
||||||
|
// Wait for measured island bounds rather than briefly painting the full bar.
|
||||||
|
EffectsBinding::attach_role(surface, None, 1.0)
|
||||||
|
} else {
|
||||||
|
EffectsBinding::attach(surface, !hidden)
|
||||||
|
};
|
||||||
|
|
||||||
|
match binding {
|
||||||
Ok(binding) => entry.effects = Some(binding),
|
Ok(binding) => entry.effects = Some(binding),
|
||||||
Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"),
|
Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user