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 ae2b727..ee2d8f5 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -92,16 +92,19 @@ 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, rectangle_track, + app.renaming.as_ref(), ); let toplevels = toplevel_previews( app.toplevels.0.iter().filter(|i| { @@ -136,7 +139,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) @@ -216,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, @@ -244,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!( @@ -285,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 { @@ -357,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) @@ -370,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 { @@ -384,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( @@ -415,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 @@ -428,10 +476,12 @@ 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, 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 { @@ -447,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; @@ -475,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, @@ -482,14 +535,35 @@ 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 (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 +718,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