From 3b200aeb8170a747cbc8ea66a66a4c6d79b419cf Mon Sep 17 00:00:00 2001 From: Javier Graus Date: Sat, 11 Jul 2026 17:19:45 +0200 Subject: [PATCH 1/2] feat: render the workspace sidebar as a grid in Grid layout When the compositor's workspace_layout is Grid, arrange the workspace thumbnails in a columns x rows grid (chunked by workspace_grid_columns) instead of a single strip. The overview otherwise behaves like the Vertical layout (sidebar beside the toplevels). Requires the cosmic-comp Grid layout change (WorkspaceLayout::Grid in cosmic-comp-config). Implements the overview half of #51. AI-assisted commit: implemented with Claude (Anthropic's AI coding assistant); the author directed the requirements, reviewed the generated diff, and tested the resulting behavior. --- src/view/mod.rs | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/src/view/mod.rs b/src/view/mod.rs index ae2b727..f0965f6 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -92,12 +92,14 @@ pub(crate) fn layer_surface<'a>( .flat_map(|t| &t.info.workspace) .collect::>(); let layout = app.conf.workspace_config.workspace_layout; + let grid_columns = app.conf.workspace_config.workspace_grid_columns.max(1) as usize; // track this rectangle let sidebar = workspaces_sidebar( app.workspaces.for_output(&surface.output), &workspaces_with_toplevels, &surface.output, layout, + grid_columns, app.drop_target.as_ref(), drag_workspace, window_id, @@ -136,7 +138,8 @@ pub(crate) fn layer_surface<'a>( cosmic::Element::from(toplevels) }; let container = match layout { - WorkspaceLayout::Vertical => widget::layer_container( + // Grid places the workspace sidebar on the left like Vertical. + WorkspaceLayout::Vertical | WorkspaceLayout::Grid => widget::layer_container( row![sidebar, toplevels] .spacing(12) .height(Length::Fill) @@ -428,6 +431,7 @@ fn workspaces_sidebar<'a>( workspaces_with_toplevels: &HashSet<&backend::ExtWorkspaceHandleV1>, output: &'a wl_output::WlOutput, layout: WorkspaceLayout, + grid_columns: usize, drop_target: Option<&DropTarget>, drag_workspace: Option<&'a backend::ExtWorkspaceHandleV1>, window_id: window::Id, @@ -484,12 +488,32 @@ fn workspaces_sidebar<'a>( drag_workspace.is_some(), )); } - let (axis, width, height) = match layout { - WorkspaceLayout::Vertical => (Axis::Vertical, Length::Shrink, Length::Fill), - WorkspaceLayout::Horizontal => (Axis::Horizontal, Length::Fill, Length::Shrink), + let (width, height) = match layout { + WorkspaceLayout::Vertical | WorkspaceLayout::Grid => (Length::Shrink, Length::Fill), + WorkspaceLayout::Horizontal => (Length::Fill, Length::Shrink), + }; + let sidebar_entries_container = match layout { + // Grid: chunk entries into rows of `grid_columns`, matching the + // compositor's 2D workspace arrangement. + WorkspaceLayout::Grid => { + let columns = grid_columns.max(1); + let mut grid = widget::grid().column_spacing(8).row_spacing(8); + for (i, entry) in sidebar_entries.into_iter().enumerate() { + if i > 0 && i % columns == 0 { + grid = grid.insert_row(); + } + grid = grid.push(entry); + } + widget::container(grid).padding(8.0) + } + WorkspaceLayout::Vertical | WorkspaceLayout::Horizontal => { + let axis = match layout { + WorkspaceLayout::Horizontal => Axis::Horizontal, + _ => Axis::Vertical, + }; + widget::container(crate::widgets::workspace_bar(sidebar_entries, axis)).padding(8.0) + } }; - let sidebar_entries_container = - widget::container(crate::widgets::workspace_bar(sidebar_entries, axis)).padding(8.0); widget::container( rectangle_track.container( @@ -644,7 +668,10 @@ fn toplevel_previews<'a>( rectangle_track: &rectangle_tracker::RectangleTracker, ) -> cosmic::Element<'a, Msg> { let (width, height) = match layout { - WorkspaceLayout::Vertical => (Length::FillPortion(4), Length::Fill), + // Grid places the workspace sidebar on the left like Vertical. + WorkspaceLayout::Vertical | WorkspaceLayout::Grid => { + (Length::FillPortion(4), Length::Fill) + } WorkspaceLayout::Horizontal => (Length::Fill, Length::FillPortion(4)), }; let entries = toplevels From 0f00f606f3840ffa5e911d783c1dfdd968fd912d Mon Sep 17 00:00:00 2001 From: Javier Graus Date: Sun, 12 Jul 2026 17:32:55 +0200 Subject: [PATCH 2/2] fix workspace overview sort order; add rename support Sort the overview in row-major reading order: coordinates are [x, y, ...] per protocol convention, and a plain lexicographic sort on the coordinate vector was sorting column-first instead of row-first, scrambling the Grid layout. Also adds a pencil button (shown on hover, next to the pin button) that turns a workspace's label into an inline text field. Enter submits a zcosmic_workspace_handle_v2 rename request; Escape or the X cancels. AI-assisted commit: implemented with Claude (Anthropic's AI coding assistant); the author directed the requirements, reviewed the generated diff, and tested the resulting behavior. --- src/backend/mock.rs | 3 +- src/backend/mod.rs | 1 + src/backend/wayland/mod.rs | 12 +++++++ src/main.rs | 44 ++++++++++++++++++++++++- src/view/mod.rs | 66 +++++++++++++++++++++++++++++++++----- 5 files changed, 116 insertions(+), 10 deletions(-) diff --git a/src/backend/mock.rs b/src/backend/mock.rs index 084e0e5..eea5c66 100644 --- a/src/backend/mock.rs +++ b/src/backend/mock.rs @@ -189,7 +189,8 @@ impl AppData { // TODO Cmd::MoveWorkspaceBefore(_, _) | Cmd::MoveWorkspaceAfter(_, _) - | Cmd::SetWorkspacePinned(_, _) => {} + | Cmd::SetWorkspacePinned(_, _) + | Cmd::RenameWorkspace(_, _) => {} } } } diff --git a/src/backend/mod.rs b/src/backend/mod.rs index df83810..54a23e9 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -97,4 +97,5 @@ pub enum Cmd { MoveWorkspaceAfter(ExtWorkspaceHandleV1, ExtWorkspaceHandleV1), ActivateWorkspace(ExtWorkspaceHandleV1), SetWorkspacePinned(ExtWorkspaceHandleV1, bool), + RenameWorkspace(ExtWorkspaceHandleV1, String), } diff --git a/src/backend/wayland/mod.rs b/src/backend/wayland/mod.rs index 937622d..ce95f29 100644 --- a/src/backend/wayland/mod.rs +++ b/src/backend/wayland/mod.rs @@ -173,6 +173,18 @@ impl AppData { workspace_manager.commit(); } } + Cmd::RenameWorkspace(workspace_handle, name) => { + if let Ok(workspace_manager) = self.workspace_state.workspace_manager().get() + && let Some(cosmic_workspace) = self + .workspace_state + .workspaces() + .find(|w| w.handle == workspace_handle) + .and_then(|w| w.cosmic_handle.as_ref()) + { + cosmic_workspace.rename(name); + workspace_manager.commit(); + } + } } } diff --git a/src/main.rs b/src/main.rs index 8ed6803..7a4e78c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -112,6 +112,10 @@ enum Msg { UpdateToplevelIcon(String, Option), OnScroll(wl_output::WlOutput, ScrollDelta), TogglePinned(ExtWorkspaceHandleV1), + StartRename(ExtWorkspaceHandleV1), + RenameInputChanged(String), + SubmitRename, + CancelRename, EnteredWorkspaceSidebarEntry(ExtWorkspaceHandleV1, bool), DbusInterface(zbus::Result), DBus(dbus::Event), @@ -212,6 +216,7 @@ struct App { rectangle_tracker: Option>, rects: HashMap, sub_ctr: u128, + renaming: Option<(ExtWorkspaceHandleV1, String)>, } #[derive(Debug, Default)] @@ -627,7 +632,21 @@ impl Application for App { self.wayland_cmd_sender = Some(sender); } backend::Event::Workspaces(mut workspaces) => { - workspaces.sort_by(|(_, w1), (_, w2)| w1.coordinates.cmp(&w2.coordinates)); + // Coordinates are [x, y, ...] per the protocol convention. + // Sort in natural reading order (row/y first, then + // column/x) rather than plain lexicographic (which would + // sort by column first, scrambling a 2D grid). For 1D + // (linear) coordinates this is a no-op. + fn reading_order_key(coords: &[u32]) -> (u32, u32) { + ( + coords.get(1).copied().unwrap_or(0), + coords.first().copied().unwrap_or(0), + ) + } + workspaces.sort_by(|(_, w1), (_, w2)| { + reading_order_key(&w1.coordinates) + .cmp(&reading_order_key(&w2.coordinates)) + }); let old_workspaces = mem::take(&mut self.workspaces); let mut new_active = Vec::new(); for (outputs, workspace) in workspaces { @@ -768,6 +787,9 @@ impl Application for App { } } Msg::Close => { + if self.renaming.take().is_some() { + return Task::none(); + } return self.hide(); } Msg::ActivateWorkspace(workspace_handle) => { @@ -984,6 +1006,26 @@ impl Application for App { )); } } + Msg::StartRename(workspace_handle) => { + if let Some(workspace) = self.workspaces.for_handle(&workspace_handle) { + self.renaming = Some((workspace_handle, workspace.info.name.clone())); + } + } + Msg::RenameInputChanged(name) => { + if let Some((_, buf)) = &mut self.renaming { + *buf = name; + } + } + Msg::SubmitRename => { + if let Some((workspace_handle, name)) = self.renaming.take() + && !name.trim().is_empty() + { + self.send_wayland_cmd(backend::Cmd::RenameWorkspace(workspace_handle, name)); + } + } + Msg::CancelRename => { + self.renaming = None; + } Msg::EnteredWorkspaceSidebarEntry(workspace_handle, entered) => { if let Some(workspace) = self.workspaces.for_handle_mut(&workspace_handle) { workspace.has_cursor = entered; diff --git a/src/view/mod.rs b/src/view/mod.rs index f0965f6..ee2d8f5 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -104,6 +104,7 @@ pub(crate) fn layer_surface<'a>( drag_workspace, window_id, rectangle_track, + app.renaming.as_ref(), ); let toplevels = toplevel_previews( app.toplevels.0.iter().filter(|i| { @@ -219,6 +220,24 @@ fn pin_button(workspace: &Workspace) -> cosmic::Element<'static, Msg> { .into() } +fn rename_button(workspace: &Workspace) -> cosmic::Element<'static, Msg> { + crate::widgets::visibility_wrapper( + widget::button::custom( + widget::icon::from_name("edit-symbolic") + .symbolic(true) + .size(16), + ) + .padding([4, 8]) + .on_press(Msg::StartRename(workspace.handle().clone())), + workspace.has_cursor + && workspace + .info + .cosmic_capabilities + .contains(zcosmic_workspace_handle_v2::WorkspaceCapabilities::Rename), + ) + .into() +} + fn workspace_item_appearance( theme: &cosmic::Theme, is_active: bool, @@ -247,6 +266,7 @@ fn workspace_item( layout: WorkspaceLayout, is_drop_target: bool, has_workspace_drag: bool, + renaming: Option, ) -> cosmic::Element<'static, Msg> { let (mut image, image_height, image_width) = if let Some(img) = workspace.img.as_ref() { let is_rotated = matches!( @@ -288,14 +308,37 @@ fn workspace_item( ) }; - let workspace_footer = row![ - widget::space::horizontal().width(Length::Fixed(32.0)), + let is_renaming = renaming.is_some(); + let label: cosmic::Element<'static, Msg> = if let Some(buf) = renaming { + widget::text_input("", buf) + .on_input(Msg::RenameInputChanged) + .on_submit(|_| Msg::SubmitRename) + .width(Length::Fill) + .into() + } else { widget::text::body(fl!("workspace", number = workspace.info.name.as_str())) .ellipsize(Ellipsize::Middle(EllipsizeHeightLimit::Lines(1))) .apply(widget::container) - .center_x(Length::Fill), - pin_button(workspace), - ]; + .center_x(Length::Fill) + .into() + }; + let workspace_footer = row![ + widget::space::horizontal().width(Length::Fixed(32.0)), + label, + ] + .push_maybe((!is_renaming).then(|| rename_button(workspace))) + .push(if is_renaming { + widget::button::custom( + widget::icon::from_name("window-close-symbolic") + .symbolic(true) + .size(16), + ) + .padding([4, 8]) + .on_press(Msg::CancelRename) + .into() + } else { + pin_button(workspace) + }); // Needed to prevent footer content getting pushed out when scaling on Vertical layout if layout == WorkspaceLayout::Vertical { @@ -360,7 +403,7 @@ fn workspace_drag_placeholder( }) .padding(8); let placeholder = crate::widgets::match_size( - workspace_item(other_workspace, other_output, layout, true, true), + workspace_item(other_workspace, other_output, layout, true, true, None), placeholder, ); dnd_destination_for_target(drop_target, placeholder.into(), Msg::DndWorkspaceDrop) @@ -373,6 +416,7 @@ fn workspace_sidebar_entry<'a>( is_drop_target: bool, has_toplevels: bool, has_workspace_drag: bool, + renaming: Option, ) -> cosmic::Element<'a, Msg> { /* XXX let mouse_interaction = if is_drop_target { @@ -387,6 +431,7 @@ fn workspace_sidebar_entry<'a>( layout, is_drop_target, has_workspace_drag, + renaming, ); let item = iced::widget::mouse_area(item) .on_enter(Msg::EnteredWorkspaceSidebarEntry( @@ -418,7 +463,7 @@ fn workspace_sidebar_entry<'a>( DragSurface::Workspace(workspace.handle().clone()), Some(workspace.dnd_source_id.clone()), destination, - move || workspace_item(&workspace_clone, &output_clone, layout, false, true), + move || workspace_item(&workspace_clone, &output_clone, layout, false, true, None), ) } else { destination @@ -436,6 +481,7 @@ fn workspaces_sidebar<'a>( drag_workspace: Option<&'a backend::ExtWorkspaceHandleV1>, window_id: window::Id, rectangle_track: &rectangle_tracker::RectangleTracker, + renaming: Option<&'a (backend::ExtWorkspaceHandleV1, String)>, ) -> cosmic::Element<'a, Msg> { let mut sidebar_entries = Vec::new(); for workspace in workspaces { @@ -451,7 +497,7 @@ fn workspaces_sidebar<'a>( .width(Length::Shrink) .height(Length::Shrink) .into(), - move || workspace_item(&workspace_clone, &output_clone, layout, false, true), + move || workspace_item(&workspace_clone, &output_clone, layout, false, true, None), ); sidebar_entries.push(source); continue; @@ -479,6 +525,9 @@ fn workspaces_sidebar<'a>( { sidebar_entries.push(workspace_drag_placeholder(workspace, output, layout)); } + let rename_buf = renaming + .filter(|(handle, _)| handle == workspace.handle()) + .map(|(_, name)| name.clone()); sidebar_entries.push(workspace_sidebar_entry( workspace, output, @@ -486,6 +535,7 @@ fn workspaces_sidebar<'a>( drop_target_is_workspace && drag_workspace.is_none(), workspaces_with_toplevels.contains(workspace.handle()), drag_workspace.is_some(), + rename_buf, )); } let (width, height) = match layout {