Add named keyboard binding modes (#32)

Add named keyboard shortcut maps with an Escape exit and a panel indicator. Keep press/release ownership across transitions, validate remapped Escape keys, and cancel stale repeats and focus previews.

Give the indicator ownership of the panel while a mode is active. Close popovers, collapse Morph, and keep Live Activities data current without resizing the hidden panel. Document configuration and transition behavior.

Validation: 1,519 workspace Rust tests passed; 70 Python tests passed. Workspace build, formatting, Clippy, and Settings configuration validation passed. Opt-in native desktop checks were not rerun because Weston is absent.

Related: #5.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-12 23:22:02 +01:00
1 parent 6b0d903ad0
commit 7d58eeb65d
28 files changed
+1258 -35

No files matched your search

+10 -8
View File
@@ -381,15 +381,17 @@ impl FereseShell {
}
// Compact ranking changes must not replay the expanded panel's
// crossfade: its live rows update in place, preserving focus/scroll.
if before != runtime.activities.selection.visible
&& matches!(
runtime.hub.mode(),
ferese_panel::morph::Mode::Idle | ferese_panel::morph::Mode::Peek
)
{
runtime.hub.replace_content();
if self.snapshot.binding_mode.is_none() {
if before != runtime.activities.selection.visible
&& matches!(
runtime.hub.mode(),
ferese_panel::morph::Mode::Idle | ferese_panel::morph::Mode::Peek
)
{
runtime.hub.replace_content();
}
runtime.refresh_activity_size();
}
runtime.refresh_activity_size();
}
}
pub fn activity_tick_subscription(&self) -> Subscription<()> {
+108
View File
@@ -14,7 +14,32 @@ pub(super) mod presentation;
use crate::panel::Representation;
use crate::panel_layout::Placement;
#[cfg(test)]
mod binding_mode_tests;
impl FereseShell {
pub(super) fn binding_mode_transition(&mut self, was_active: bool) -> cosmic::app::Task<Message> {
match (was_active, self.snapshot.binding_mode.is_some()) {
(false, true) => {
let mut tasks = vec![self.destroy_menu(), self.dismiss_workspace_tooltip()];
let hubs: Vec<_> = self
.outputs
.iter()
.filter(|entry| entry.runtime.hub.is_some())
.map(|entry| entry.bar)
.collect();
for id in hubs {
// Release keyboard ownership and return to the collapsed
// backing allocation before displaying the mode indicator.
tasks.push(self.suspend_hub(id));
}
cosmic::app::Task::batch(tasks)
}
(true, false) => self.sync_hub_activity(),
_ => cosmic::app::Task::none(),
}
}
pub(super) fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> {
let name = self.outputs.iter().find(|entry| entry.bar == id)?.name.as_deref()?;
self.snapshot.outputs.iter().find(|output| output.name == name)
@@ -45,6 +70,9 @@ impl FereseShell {
.into();
}
let panel = self.panel_for_bar(id);
if let Some(name) = &self.snapshot.binding_mode {
return self.view_binding_mode(id, name);
}
if panel.style == ferese_panel::PanelStyle::Morph {
return self.view_hub(id);
}
@@ -250,6 +278,72 @@ impl FereseShell {
self.with_workspace_hover(id, content)
}
fn view_binding_mode<'a>(&'a self, id: window::Id, name: &'a str) -> Element<'a, cosmic::Action<Message>> {
let panel = self.panel_for_bar(id);
let output = self.outputs.iter().find(|entry| entry.bar == id).expect("panel output");
let effects = output.effects.as_ref();
let width = output
.runtime
.hub
.as_ref()
.map(|hub| hub.borrow().allocation[0])
.unwrap_or_else(|| output.size.map_or(320, |size| size.0 as u32))
.min(320);
let badge_id = cosmic::iced::advanced::widget::Id::new("ferese-binding-mode");
let title = self
.render
.text(format!("Mode: {name}"))
.size(14)
.width(Length::Fill)
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
));
let badge = container(
row![title, self.render.text("Esc to exit").size(12)]
.spacing(12)
.align_y(cosmic::iced::Alignment::Center),
)
.id(badge_id.clone())
.padding([0, 12])
.width(width as f32)
.height(panel.resolved_geometry().height)
.align_y(alignment::Vertical::Center);
let content = container(badge).center_x(Length::Fill).center_y(Length::Fill);
let palette = self.config.theme.for_bar();
let mut background = color(palette.bar_background);
background.a = palette.panel_opacity(panel.background_opacity);
// Replace the native paint/input scene as well as the text. Retaining
// the previous bar or Morph scene can clip a newly displayed mode.
ferese_theme::panel_surface::frame(
content.into(),
ferese_theme::panel_surface::Options {
parameters: panel.shape_parameters(),
bounds: Some(badge_id.clone()),
groups: vec![(badge_id, ferese_panel::shape::ZonePosition::Center)],
scale: output.scale,
high_contrast: palette.high_contrast,
background,
border: if panel.border {
color(palette.border)
} else {
Color::TRANSPARENT
},
divider: Color::TRANSPARENT,
native: effects.is_some(),
},
move |scene| {
if let Some(effects) = effects
&& let Err(error) = effects.set_panel_presentation(scene)
{
eprintln!("ferese-shell: could not update binding mode material: {error}");
}
},
Some(Box::new(move |scene| {
cosmic::Action::App(Message::PanelSurfaceChanged(id, Box::new(scene.clone())))
})),
)
}
pub(super) fn with_workspace_hover<'a>(
&'a self,
id: window::Id,
@@ -706,6 +800,9 @@ pub(super) fn bar_hidden(snapshot: &ShellSnapshot) -> bool {
}
pub(super) fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option<u64>) -> bool {
if snapshot.binding_mode.is_some() {
return false;
}
let active_workspace = snapshot
.outputs
.iter()
@@ -832,6 +929,17 @@ pub(super) fn bar_group_style(theme: ShellTheme) -> container::Style {
#[cfg(test)]
mod island_tests {
use super::*;
#[test]
fn mode_indicator_stays_visible_on_fullscreen_outputs() {
let mut snapshot = super::super::tests::snapshot_with_fullscreen_window(7);
assert!(output_bar_hidden(&snapshot, Some(1)));
snapshot.binding_mode = Some("resize".into());
assert!(!output_bar_hidden(&snapshot, Some(1)));
snapshot.binding_mode = None;
assert!(output_bar_hidden(&snapshot, Some(1)));
}
use crate::panel::{ItemId, Panel};
use cosmic::iced::advanced::{Layout, layout, renderer::Headless, widget};
use cosmic::iced::{Font, Pixels, Rectangle, Size};
@@ -0,0 +1,209 @@
use super::*;
use cosmic::iced::advanced::{Layout, Renderer, Shell, clipboard, layout, mouse, renderer, widget};
use cosmic::iced::{Event, Rectangle, Size};
#[derive(Default)]
struct TextBounds(Vec<(String, Rectangle)>);
impl widget::Operation for TextBounds {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
self.0.push((text.to_owned(), bounds));
}
}
#[test]
fn a_binding_mode_owns_the_panel_instead_of_hidden_morph_hover_or_menus() {
let mut shell = crate::tests::shell_with_measured_panel();
shell.config.panels[0].style = ferese_panel::PanelStyle::Morph;
shell.outputs[0]
.runtime
.reset(&shell.config.panels[0], &shell.snapshot, Some("test"));
let id = shell.outputs[0].bar;
shell.snapshot.binding_mode = Some("resize".into());
let allocation = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().allocation;
drop(shell.hub_hover(id, true));
assert_eq!(shell.hub_mode(id), Some(ferese_panel::morph::Mode::Idle));
drop(shell.open_hub(id, status_ui::Menu::Calendar));
assert_eq!(shell.hub_mode(id), Some(ferese_panel::morph::Mode::Idle));
assert_eq!(
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().allocation,
allocation
);
}
#[test]
fn mode_entry_dismisses_existing_popovers_and_collapses_expanded_morph_content() {
for style in [ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Morph] {
let mut shell = crate::tests::shell_with_measured_panel();
shell.config.panels[0].style = style;
shell.outputs[0]
.runtime
.reset(&shell.config.panels[0], &shell.snapshot, Some("test"));
let id = shell.outputs[0].bar;
let collapsed = shell.outputs[0].runtime.hub.as_ref().map(|hub| hub.borrow().allocation);
if collapsed.is_some() {
drop(shell.open_hub(id, status_ui::Menu::Calendar));
assert!(shell.hub_mode(id).unwrap().expanded());
}
let anchor = status_ui::PopoverAnchor {
parent: id,
panel: shell.config.panels[0].id.clone(),
item: Some(crate::panel::ItemId("clock".into())),
rectangle: Default::default(),
};
shell.menu = Some(status_ui::OpenMenu {
id: window::Id::unique(),
anchor: anchor.clone(),
kind: status_ui::Menu::Calendar,
motion: crate::motion::PopupMotion::new(Default::default()),
effects: None,
regions: Default::default(),
});
shell.workspace_ui.tooltip = Some(window::Id::unique());
shell.snapshot.binding_mode = Some("resize".into());
drop(shell.binding_mode_transition(false));
assert!(shell.menu.is_none());
assert!(shell.workspace_ui.tooltip.is_none());
drop(shell.open_menu(status_ui::Menu::Calendar, anchor));
assert!(
shell.menu.is_none(),
"a queued click on a displaced control must not reopen its popup"
);
if let Some(collapsed) = collapsed {
assert_eq!(shell.hub_mode(id), Some(ferese_panel::morph::Mode::Idle));
let hub = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow();
assert_eq!(hub.allocation, collapsed);
assert!(!hub.hub.active());
}
// Another snapshot while the mode is active does not restart the handoff.
drop(shell.binding_mode_transition(true));
shell.snapshot.binding_mode = None;
drop(shell.binding_mode_transition(true));
if collapsed.is_some() {
drop(shell.open_hub(id, status_ui::Menu::Calendar));
assert!(shell.hub_mode(id).unwrap().expanded());
}
}
}
#[test]
fn mode_name_and_escape_hint_fit_the_published_shape_in_every_panel_style() {
use cosmic::iced::advanced::renderer::Headless;
let mut renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
cosmic::iced::Font::default(),
cosmic::iced::Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let previews = std::env::var_os("FERESE_TEST_MODE_PREVIEWS").map(std::path::PathBuf::from);
if let Some(path) = &previews {
std::fs::create_dir_all(path).unwrap();
}
for style in ferese_panel::PanelStyle::AVAILABLE {
for name in ["resize", "resize_mode_with_a_long_name_and_a_required_exit"] {
let mut shell = crate::tests::shell_with_measured_panel();
shell.config.panels[0].style = style;
let style = style.key();
shell.snapshot.binding_mode = Some(name.into());
shell.outputs[0]
.runtime
.reset(&shell.config.panels[0], &shell.snapshot, Some("test"));
let id = shell.outputs[0].bar;
let allocation = shell.outputs[0]
.runtime
.hub
.as_ref()
.map(|hub| hub.borrow().allocation)
.unwrap_or([1200, shell.config.panels[0].resolved_geometry().height as u32]);
let viewport = Rectangle::with_size(Size::new(allocation[0] as f32, allocation[1] as f32));
let mut view = shell.view_layer(id);
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 messages = Vec::new();
view.as_widget_mut().update(
&mut tree,
&Event::Window(window::Event::RedrawRequested(std::time::Instant::now())),
Layout::new(&node),
mouse::Cursor::Unavailable,
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&viewport,
);
let scene = messages
.iter()
.find_map(|message| match message {
cosmic::Action::App(Message::PanelSurfaceChanged(_, scene)) => Some(scene),
_ => None,
})
.unwrap_or_else(|| panic!("{style}: mode must replace the previous native clipping and input shape"));
let mut labels = TextBounds::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
assert!(
labels.0.iter().any(|(text, _)| text.contains("resize")),
"{style}: mode name missing"
);
assert!(
labels.0.iter().any(|(text, _)| text.contains("Esc to exit")),
"{style}: exit hint missing"
);
for (text, bounds) in &labels.0 {
assert!(
bounds.x >= 0.
&& bounds.y >= 0.
&& bounds.x + bounds.width <= viewport.width + 0.1
&& bounds.y + bounds.height <= viewport.height + 0.1,
"{style}: {text} exceeds its backing surface: {bounds:?}"
);
assert!(
scene.paint.iter().any(|paint| {
let rect = paint.bounds;
f64::from(bounds.x) >= rect.x - 0.1
&& f64::from(bounds.y) >= rect.y - 0.1
&& f64::from(bounds.x + bounds.width) <= rect.x + rect.width + 0.1
&& f64::from(bounds.y + bounds.height) <= rect.y + rect.height + 0.1
}),
"{style}: {text} is outside the native paint shape"
);
}
renderer.reset(viewport);
view.as_widget().draw(
&tree,
&mut renderer,
&shell.config.theme.palette().native_theme(),
&renderer::Style {
text_color: color(shell.config.theme.for_bar().text_primary),
icon_color: Color::WHITE,
scale_factor: 1.,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
&viewport,
);
let pixels = renderer.screenshot(Size::new(allocation[0], allocation[1]), 1., Color::TRANSPARENT);
assert!(
pixels.chunks_exact(4).any(|pixel| pixel[3] > 0),
"{style}: no visible pixels"
);
if let Some(path) = &previews {
image_codec::save_buffer(
path.join(format!(
"mode-{style}{}.png",
if name == "resize" { "" } else { "-long" }
)),
&pixels,
allocation[0],
allocation[1],
image_codec::ColorType::Rgba8,
)
.unwrap();
}
}
}
}
+49 -1
View File
@@ -14,6 +14,7 @@ use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, delegate_noop};
#[derive(Clone, Debug, Default)]
pub(crate) struct ShellSnapshot {
pub(crate) binding_mode: Option<String>,
pub(crate) outputs: Vec<OutputSnapshot>,
pub(crate) workspaces: Vec<WorkspaceSnapshot>,
pub(crate) windows: Vec<WindowSnapshot>,
@@ -136,7 +137,7 @@ impl ShellControl {
let connection = control_connection()?;
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
let qh = queue.handle();
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 7..=8, ())?;
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 7..=9, ())?;
let shell = manager.get_shell(&qh, ());
let (sender, updates) = mpsc::channel();
let wake = Wake(Arc::new(tokio::sync::Notify::new()));
@@ -330,6 +331,9 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
state.serial = Some(serial);
state.pending = ShellSnapshot::default();
}
ferese_shell_v1::Event::BindingMode { name } => {
state.pending.binding_mode = (!name.is_empty()).then_some(name);
}
ferese_shell_v1::Event::Output {
output_hi,
output_lo,
@@ -558,6 +562,50 @@ mod tests {
}
}
#[test]
fn binding_mode_is_published_atomically_and_cleared_by_the_next_snapshot() {
let (control, mut state, queue, _peer) = control_fixture();
dispatch_event(
&control,
&mut state,
&queue,
ferese_shell_v1::Event::SnapshotBegin { serial: 1 },
);
dispatch_event(
&control,
&mut state,
&queue,
ferese_shell_v1::Event::BindingMode { name: "resize".into() },
);
assert!(control.poll().snapshot.is_none());
dispatch_event(
&control,
&mut state,
&queue,
ferese_shell_v1::Event::SnapshotEnd { serial: 1 },
);
assert_eq!(control.poll().snapshot.unwrap().binding_mode.as_deref(), Some("resize"));
dispatch_event(
&control,
&mut state,
&queue,
ferese_shell_v1::Event::SnapshotBegin { serial: 2 },
);
dispatch_event(
&control,
&mut state,
&queue,
ferese_shell_v1::Event::BindingMode { name: String::new() },
);
dispatch_event(
&control,
&mut state,
&queue,
ferese_shell_v1::Event::SnapshotEnd { serial: 2 },
);
assert!(control.poll().snapshot.unwrap().binding_mode.is_none());
}
#[test]
fn snapshot_retains_identity_and_floating_state_for_identical_window_metadata() {
let (control, mut state, queue, _peer) = control_fixture();
+5 -1
View File
@@ -830,10 +830,13 @@ impl cosmic::Application for FereseShell {
}
if let Some(snapshot) = poll.snapshot {
let mode_was_active = self.snapshot.binding_mode.is_some();
self.snapshot = snapshot;
let mode = self.binding_mode_transition(mode_was_active);
let feedback = self.sync_hub_workspace_feedback(overview_was_active || self.overview_active);
let mut tasks = vec![
reload_task,
mode,
self.update_workspace_ui(),
feedback,
self.refresh_adaptive_panels(),
@@ -1506,8 +1509,9 @@ mod tests {
assert_eq!(focused_bar_title(&snapshot, None), "");
}
fn snapshot_with_fullscreen_window(workspace: u64) -> ShellSnapshot {
pub(super) fn snapshot_with_fullscreen_window(workspace: u64) -> ShellSnapshot {
ShellSnapshot {
binding_mode: None,
outputs: vec![OutputSnapshot {
id: 1,
name: "eDP-1".to_owned(),
+43 -2
View File
@@ -251,7 +251,7 @@ impl FereseShell {
}
pub(super) fn open_hub(&mut self, id: window::Id, kind: status_ui::Menu) -> Task<Message> {
if self.bar_hidden(id) || self.hub_mode(id).is_none() {
if self.snapshot.binding_mode.is_some() || self.bar_hidden(id) || self.hub_mode(id).is_none() {
return Task::none();
}
if kind == status_ui::Menu::Notifications {
@@ -296,7 +296,8 @@ impl FereseShell {
let Some(id) = self.active_bar() else {
return Task::none();
};
if self.bar_hidden(id)
if self.snapshot.binding_mode.is_some()
|| self.bar_hidden(id)
|| self.hub_priority().is_some()
|| !matches!(self.hub_mode(id), Some(Mode::Idle | Mode::Peek | Mode::Toast))
|| !self
@@ -361,6 +362,9 @@ impl FereseShell {
}
fn transition_hub(&mut self, id: window::Id, event: Event) -> Task<Message> {
if self.snapshot.binding_mode.is_some() && !matches!(event, Event::Close | Event::Resize) {
return Task::none();
}
let workspace_width = self.hub_workspace_width(id);
let panel = self.panel_for_bar(id);
let geometry = panel.resolved_geometry();
@@ -1883,6 +1887,43 @@ mod tests {
}
}
#[test]
fn activity_updates_keep_the_binding_indicator_as_the_only_geometry_owner() {
let mut app = shell();
let bar = app.outputs[0].bar;
app.snapshot.binding_mode = Some("resize".into());
drop(app.binding_mode_transition(false));
let (allocation, target) = {
let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow();
(runtime.allocation, runtime.hub.target_size())
};
seed_activities(&mut app, 3);
drop(app.sync_hub_activity());
{
let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow();
assert_eq!(
runtime.activities.selection.visible.len(),
2,
"activity data must still update"
);
assert_eq!(
runtime.hub.target_size(),
target,
"hidden activity content cannot retarget the hub"
);
assert_eq!(runtime.allocation, allocation);
assert!(!runtime.hub.active());
}
app.snapshot.binding_mode = None;
drop(app.binding_mode_transition(true));
assert_ne!(
app.outputs[0].runtime.hub.as_ref().unwrap().borrow().hub.target_size(),
target
);
drop(app.open_hub(bar, status_ui::Menu::Activities));
assert_eq!(app.hub_mode(bar), Some(Mode::Expanded(Content::Activities)));
}
#[test]
fn progress_updates_do_not_start_a_second_hub_animation() {
let mut app = shell();
@@ -80,6 +80,9 @@ impl FereseShell {
pub(crate) fn sync_hub_activity(&mut self) -> Task<Message> {
self.sync_activity_presentations();
if self.snapshot.binding_mode.is_some() {
return Task::none();
}
let priority = self.hub_priority();
let changes: Vec<_> = self
.outputs
@@ -39,7 +39,8 @@ impl OpenMenu {
impl FereseShell {
pub fn open_menu(&mut self, kind: Menu, anchor: PopoverAnchor) -> Task<Message> {
if !anchor.belongs_to(&self.config.panels[0])
if (self.snapshot.binding_mode.is_some() && anchor.item.is_some())
|| !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)
+1
View File
@@ -472,6 +472,7 @@ mod tests {
fn snapshot(active: u64) -> ShellSnapshot {
ShellSnapshot {
binding_mode: None,
outputs: vec![OutputSnapshot {
id: 4,
name: "test".into(),