From f11300832be2a055f254bfa8724d4bd0efc8f40a Mon Sep 17 00:00:00 2001 From: aa Date: Tue, 22 Sep 2026 14:14:31 -0700 Subject: [PATCH 1/7] workspaces --- frontends/rioterm/src/application.rs | 32 ++++ frontends/rioterm/src/context/mod.rs | 97 +++++++++++ frontends/rioterm/src/main.rs | 1 + frontends/rioterm/src/renderer/mod.rs | 122 ++++++++++++++ frontends/rioterm/src/screen/mod.rs | 111 +++++++++++- frontends/rioterm/src/workspace.rs | 234 ++++++++++++++++++++++++++ 6 files changed, 594 insertions(+), 3 deletions(-) create mode 100644 frontends/rioterm/src/workspace.rs diff --git a/frontends/rioterm/src/application.rs b/frontends/rioterm/src/application.rs index 82ef3f2ae8..c78b8124f6 100644 --- a/frontends/rioterm/src/application.rs +++ b/frontends/rioterm/src/application.rs @@ -1361,6 +1361,15 @@ impl ApplicationHandler for Application<'_> { let chrome_press = route.window.screen.take_chrome_press(); if let MouseButton::Left = button { + if route + .window + .screen + .handle_workspace_click(&mut self.router.clipboard) + { + route.request_redraw(); + return; + } + // Check if clicking on a panel border to start resize { let mx = route.window.screen.mouse.x as f32; @@ -1531,6 +1540,13 @@ impl ApplicationHandler for Application<'_> { .process_mouse_bindings(button, &mut self.router.clipboard); } ElementState::Released => { + if button == MouseButton::Left + && route.window.screen.finish_workspace_drag() + { + route.request_redraw(); + return; + } + // Stop selection auto-scroll on button release. if let MouseButton::Left | MouseButton::Right = button { let scroll_timer_id = @@ -1684,6 +1700,22 @@ impl ApplicationHandler for Application<'_> { return; } + let scale_factor = route.window.screen.sugarloaf.scale_factor(); + if route.window.screen.workspace_interaction_active() { + route + .window + .screen + .update_workspace_drawer_width(x as f32 / scale_factor); + route.window.winit_window.set_cursor( + if route.window.screen.workspace_resize_active() { + CursorIcon::ColResize + } else { + CursorIcon::Default + }, + ); + route.request_redraw(); + return; + } // Handle assistant overlay hover if route.window.screen.renderer.assistant.is_active() { let scale = route.window.screen.sugarloaf.scale_factor(); diff --git a/frontends/rioterm/src/context/mod.rs b/frontends/rioterm/src/context/mod.rs index 1a2785a9d4..e0ddba0c77 100644 --- a/frontends/rioterm/src/context/mod.rs +++ b/frontends/rioterm/src/context/mod.rs @@ -8,6 +8,7 @@ use crate::event::{Msg, RioEvent}; pub use crate::layout::{ContextDimension, ContextGrid, ContextGridItem}; use crate::messenger::Messenger; use crate::performer::{self, Machine}; +use crate::workspace::WorkspaceManager; use renderable::Cursor; use renderable::RenderableContent; use rio_backend::config::layout::Margin; @@ -141,6 +142,8 @@ pub struct ContextManager { event_proxy: T, window_id: WindowId, pub config: ContextManagerConfig, + pub workspaces: WorkspaceManager, + base_scaled_margin: Margin, } /// Display name for the command a pane spawns: the configured program, @@ -479,6 +482,8 @@ impl ContextManager { event_proxy, window_id, config: ctx_config, + workspaces: WorkspaceManager::new(), + base_scaled_margin: scaled_margin, }; // The native titlebar starts as the placeholder; one poke makes // it converge on the displayed title even for shells that never @@ -521,6 +526,8 @@ impl ContextManager { event_proxy, window_id, config, + workspaces: WorkspaceManager::new(), + base_scaled_margin: Margin::default(), }) } @@ -555,6 +562,7 @@ impl ContextManager { // A whole tab dies. self.contexts[tab_index].remove_from_sugarloaf(sugarloaf); self.contexts.remove(tab_index); + self.workspaces.remove_tab(tab_index); if self.contexts.is_empty() { return true; @@ -571,6 +579,11 @@ impl ContextManager { self.current_index = new_index; } + if let Some(workspace) = self.workspaces.workspace_for_tab(self.current_index) { + self.workspaces.set_active(workspace); + self.workspaces.select_tab(self.current_index); + } + self.keep_only_active_context_visible(sugarloaf); false } @@ -716,6 +729,10 @@ impl ContextManager { return; } + if let Some(workspace) = self.workspaces.workspace_for_tab(tab_index) { + self.workspaces.set_active(workspace); + self.workspaces.select_tab(tab_index); + } self.set_current(tab_index); } @@ -781,6 +798,76 @@ impl ContextManager { self.contexts.len() } + #[inline] + pub fn workspace_count(&self) -> usize { + self.workspaces.len() + } + + #[inline] + pub fn active_workspace(&self) -> usize { + self.workspaces.active() + } + + #[inline] + pub fn workspace_name(&self, index: usize) -> Option<&str> { + self.workspaces + .get(index) + .map(|workspace| workspace.name.as_str()) + } + + #[inline] + pub fn workspace_tab_count(&self, index: usize) -> usize { + self.workspaces.tab_count(index) + } + + #[inline] + pub fn workspace_has_bell(&self, index: usize) -> bool { + self.workspaces + .tab_indices(index) + .iter() + .any(|&tab| self.contexts.get(tab).is_some_and(|grid| grid.bell)) + } + + /// Create a workspace. Its first tab is created by the screen after + /// selecting it, so workspaces never appear without a terminal. + #[inline] + pub fn create_workspace(&mut self) -> usize { + self.workspaces.create() + } + + #[inline] + pub fn select_workspace(&mut self, index: usize) -> Option { + self.workspaces.select(index) + } + + #[inline] + pub fn drawer_width(&self) -> f32 { + self.workspaces.drawer_width() + } + + pub fn set_drawer_width(&mut self, width: f32, scale: f32) { + self.workspaces.set_drawer_width(width); + let mut scaled_margin = self.base_scaled_margin; + scaled_margin.left += self.workspaces.drawer_width() * scale; + for grid in &mut self.contexts { + grid.update_scaled_margin(scaled_margin); + } + } + + pub fn update_base_margin_scale(&mut self, new_scale: f32) { + let old_scale = self + .contexts + .first() + .map(|grid| grid.current().dimension.dimension.scale) + .unwrap_or(new_scale) + .max(1.0); + let ratio = new_scale / old_scale; + self.base_scaled_margin.top *= ratio; + self.base_scaled_margin.right *= ratio; + self.base_scaled_margin.bottom *= ratio; + self.base_scaled_margin.left *= ratio; + } + #[cfg(test)] pub fn title(&self, index: usize) -> Option<&ContextTitle> { self.contexts.get(index).map(|grid| &grid.current().title) @@ -1106,11 +1193,17 @@ impl ContextManager { // Remove all rich text from the grid before removing the context self.contexts[index_to_remove].remove_from_sugarloaf(sugarloaf); self.contexts.remove(index_to_remove); + self.workspaces.remove_tab(index_to_remove); if should_set_current { self.set_current(0); } + if let Some(workspace) = self.workspaces.workspace_for_tab(self.current_index) { + self.workspaces.set_active(workspace); + self.workspaces.select_tab(self.current_index); + } + self.keep_only_active_context_visible(sugarloaf); } @@ -1178,6 +1271,7 @@ impl ContextManager { let current = self.current_index; let target_index = if current == 0 { len - 1 } else { current - 1 }; self.contexts.swap(current, target_index); + self.workspaces.swap_tabs(current, target_index); self.select_tab(target_index); } @@ -1191,6 +1285,7 @@ impl ContextManager { let current = self.current_index; let target_index = if current == len - 1 { 0 } else { current + 1 }; self.contexts.swap(current, target_index); + self.workspaces.swap_tabs(current, target_index); self.select_tab(target_index); } @@ -1207,6 +1302,7 @@ impl ContextManager { let grid = self.contexts.remove(current); self.contexts.insert(target, grid); + self.workspaces.move_tab(current, target); self.set_current(target); } @@ -1403,6 +1499,7 @@ impl ContextManager { self.config.split_active_color, self.config.panel, )); + self.workspaces.add_tab(last_index); if redirect { self.current_index = last_index; self.sync_current_route(); diff --git a/frontends/rioterm/src/main.rs b/frontends/rioterm/src/main.rs index 27a3af3ffe..0e7958cfb2 100644 --- a/frontends/rioterm/src/main.rs +++ b/frontends/rioterm/src/main.rs @@ -24,6 +24,7 @@ mod scheduler; mod screen; mod shell_integration; mod watcher; +mod workspace; use clap::Parser; use rio_backend::config::config_dir_path; diff --git a/frontends/rioterm/src/renderer/mod.rs b/frontends/rioterm/src/renderer/mod.rs index 62f7c11145..de0002e5ef 100644 --- a/frontends/rioterm/src/renderer/mod.rs +++ b/frontends/rioterm/src/renderer/mod.rs @@ -287,6 +287,126 @@ impl Renderer { } } + fn render_workspace_drawer( + &self, + sugarloaf: &mut Sugarloaf, + context_manager: &ContextManager, + ) { + let width = context_manager.drawer_width(); + let height = sugarloaf.window_size().height / sugarloaf.scale_factor(); + let background = self + .last_window_bg + .map(|color| { + [ + color.r as f32, + color.g as f32, + color.b as f32, + color.a as f32, + ] + }) + .unwrap_or(self.named_colors.background.0); + let panel = [ + (background[0] * 0.92).min(1.0), + (background[1] * 0.92).min(1.0), + (background[2] * 0.92).min(1.0), + 1.0, + ]; + let selected = [ + self.named_colors.tabs_active[0], + self.named_colors.tabs_active[1], + self.named_colors.tabs_active[2], + 0.28, + ]; + let divider = [ + self.named_colors.foreground[0], + self.named_colors.foreground[1], + self.named_colors.foreground[2], + 0.16, + ]; + let foreground = [ + (self.named_colors.foreground[0] * 255.0) as u8, + (self.named_colors.foreground[1] * 255.0) as u8, + (self.named_colors.foreground[2] * 255.0) as u8, + 255, + ]; + let muted = [foreground[0], foreground[1], foreground[2], 170]; + let title_opts = DrawOpts { + font_size: 14.0, + color: foreground, + bold: true, + ..DrawOpts::default() + }; + let row_opts = DrawOpts { + font_size: 13.0, + color: foreground, + ..DrawOpts::default() + }; + let muted_opts = DrawOpts { + font_size: 11.0, + color: muted, + ..DrawOpts::default() + }; + + sugarloaf.rect(None, 0.0, 0.0, width, height, panel, 0.0, 30); + sugarloaf.line(width - 1.0, 0.0, width - 1.0, height, 1.0, 0.0, divider, 31); + sugarloaf + .text_mut() + .draw(16.0, 17.0, "Workspaces", &title_opts); + sugarloaf + .text_mut() + .draw(width - 30.0, 17.0, "+", &title_opts); + + for index in 0..context_manager.workspace_count() { + let y = 51.0 + index as f32 * 48.0; + let active = index == context_manager.active_workspace(); + if active { + sugarloaf.rounded_rect( + None, + 8.0, + y, + width - 16.0, + 42.0, + selected, + 0.0, + 6.0, + 32, + ); + } + + let name = context_manager.workspace_name(index).unwrap_or("Workspace"); + let tab_count = context_manager.workspace_tab_count(index); + let label = format!( + "{}{}", + if context_manager.workspace_has_bell(index) { + "• " + } else { + "" + }, + name + ); + sugarloaf.text_mut().draw(18.0, y + 8.0, &label, &row_opts); + let count = format!( + "{tab_count} {}", + if tab_count == 1 { "tab" } else { "tabs" } + ); + sugarloaf + .text_mut() + .draw(18.0, y + 25.0, &count, &muted_opts); + } + + // The handle remains a small, quiet hit target at the drawer edge. + sugarloaf.rect( + None, + width - 4.0, + 0.0, + 8.0, + height, + [0.0, 0.0, 0.0, 0.001], + 0.0, + 33, + ); + } + #[inline] pub fn use_drawable_chars(&self) -> bool { self.use_drawable_chars @@ -824,6 +944,8 @@ impl Renderer { ); } + self.render_workspace_drawer(sugarloaf, context_manager); + self.assistant.render( sugarloaf, (window_size.width, window_size.height, scale_factor), diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index 1c13ed9fc0..a716895c2f 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -30,6 +30,7 @@ use crate::renderer::island::{self, TabStripLayout, ISLAND_HEIGHT}; use crate::renderer::{utils::padding_top_from_config, Renderer}; use crate::screen::hint::HintMatches; use crate::selection::{Selection, SelectionType}; +use crate::workspace::{DEFAULT_DRAWER_WIDTH, MAX_DRAWER_WIDTH, MIN_DRAWER_WIDTH}; use core::fmt::Debug; use raw_window_handle::{RawDisplayHandle, RawWindowHandle}; use rio_backend::clipboard::Clipboard; @@ -99,6 +100,8 @@ pub struct Screen<'screen> { /// still refreshes it. Reset on wheel scroll and highlight clears. last_hint_probe: Option<(Pos, rio_window::keyboard::ModifiersState)>, pub resize_state: Option, + workspace_dragging: bool, + workspace_consumed: bool, #[cfg(target_os = "macos")] pub allow_manual_dragging: bool, last_chrome_press: Option, @@ -299,7 +302,7 @@ impl Screen<'_> { state: CursorState::new(config.cursor.shape.into()), }; - let context_manager = context::ContextManager::start( + let mut context_manager = context::ContextManager::start( // config.cursor.blinking (&cursor, config.cursor.blinking), event_proxy, @@ -312,6 +315,13 @@ impl Screen<'_> { sugarloaf_errors, )?; + context_manager.set_drawer_width(DEFAULT_DRAWER_WIDTH, scale as f32); + context_manager.resize_all_grids( + size.width as f32, + size.height as f32, + &mut sugarloaf, + ); + sugarloaf.set_window_opaque(window_should_be_opaque(config)); sugarloaf.set_background_color(Some(renderer.dynamic_background.1)); @@ -349,6 +359,8 @@ impl Screen<'_> { bindings, last_ime_cursor_pos: None, resize_state: None, + workspace_dragging: false, + workspace_consumed: false, #[cfg(target_os = "macos")] allow_manual_dragging: config.navigation.is_enabled(), last_chrome_press: None, @@ -712,6 +724,7 @@ impl Screen<'_> { ) -> &mut Self { self.sugarloaf.rescale(new_scale); self.sugarloaf.resize(new_size.width, new_size.height); + self.context_manager.update_base_margin_scale(new_scale); for context_grid in self.context_manager.contexts_mut() { let old_scale = context_grid.current().dimension.dimension.scale.max(1.0); @@ -1734,6 +1747,11 @@ impl Screen<'_> { self.mark_dirty(); } + pub fn create_workspace(&mut self, clipboard: &mut Clipboard) { + self.context_manager.create_workspace(); + self.create_tab(clipboard); + } + pub fn close_split_or_tab(&mut self, clipboard: &mut Clipboard) { if self.context_manager.current_grid_len() > 1 { self.clear_selection(); @@ -2557,7 +2575,94 @@ impl Screen<'_> { .is_none() } - // return true if the click was handled by the island + /// Handle the workspace rail before terminal mouse handling. Selection + /// still flows through ContextManager so PTY visibility and focus remain + /// centralized. + pub fn handle_workspace_click(&mut self, clipboard: &mut Clipboard) -> bool { + if self.mouse.left_button_state != ElementState::Pressed { + return false; + } + + let scale = self.sugarloaf.scale_factor(); + let x = self.mouse.x as f32 / scale; + let y = self.mouse.y as f32 / scale; + let width = self.context_manager.drawer_width(); + if x > width { + return false; + } + + self.workspace_consumed = true; + + if x >= width - 10.0 { + self.workspace_dragging = true; + return true; + } + + if y < 48.0 { + if x >= width - 52.0 { + self.create_workspace(clipboard); + } + return true; + } + + let row = ((y - 51.0) / 48.0).floor() as usize; + if row < self.context_manager.workspace_count() { + let old_index = self.context_manager.current_index(); + if let Some(new_index) = self.context_manager.select_workspace(row) { + self.context_manager.set_current(new_index); + self.context_manager.switch_context_visibility( + &mut self.sugarloaf, + old_index, + new_index, + ); + self.stop_hint_mode_if_active(); + self.cancel_search(clipboard); + self.clear_selection(); + self.mark_dirty(); + } + } + true + } + + pub fn update_workspace_drawer_width(&mut self, x: f32) -> bool { + if !self.workspace_dragging { + return false; + } + + let width = x.clamp(MIN_DRAWER_WIDTH, MAX_DRAWER_WIDTH); + let old_width = self.context_manager.drawer_width(); + if (old_width - width).abs() < f32::EPSILON { + return true; + } + + let scale = self.sugarloaf.scale_factor(); + self.context_manager.set_drawer_width(width, scale); + let size = self.sugarloaf.window_size(); + self.context_manager.resize_all_grids( + size.width, + size.height, + &mut self.sugarloaf, + ); + self.refresh_titles(); + self.mark_dirty(); + true + } + + pub fn workspace_interaction_active(&self) -> bool { + self.workspace_consumed + } + + pub fn workspace_resize_active(&self) -> bool { + self.workspace_dragging + } + + pub fn finish_workspace_drag(&mut self) -> bool { + let was_dragging = self.workspace_consumed; + self.workspace_dragging = false; + self.workspace_consumed = false; + was_dragging + } + #[inline] pub fn handle_palette_click(&mut self, clipboard: &mut Clipboard) -> bool { if !self.renderer.command_palette.is_enabled() { @@ -3066,7 +3171,7 @@ impl Screen<'_> { self.cancel_search(clipboard); self.clear_selection(); let old_index = self.context_manager.current_index(); - self.context_manager.set_current(clicked_tab); + self.context_manager.select_tab(clicked_tab); let new_index = self.context_manager.current_index(); self.context_manager.switch_context_visibility( &mut self.sugarloaf, diff --git a/frontends/rioterm/src/workspace.rs b/frontends/rioterm/src/workspace.rs new file mode 100644 index 0000000000..70e84c50d3 --- /dev/null +++ b/frontends/rioterm/src/workspace.rs @@ -0,0 +1,234 @@ +pub const DEFAULT_DRAWER_WIDTH: f32 = 220.0; +pub const MIN_DRAWER_WIDTH: f32 = 160.0; +pub const MAX_DRAWER_WIDTH: f32 = 420.0; + +const DEFAULT_WORKSPACE_NAME: &str = "Main"; + +#[derive(Debug, Clone)] +pub struct Workspace { + pub name: String, + pub tabs: Vec, + selected_tab: usize, +} + +#[derive(Debug, Clone)] +pub struct WorkspaceManager { + workspaces: Vec, + active: usize, + drawer_width: f32, +} + +impl WorkspaceManager { + pub fn new() -> Self { + Self { + workspaces: vec![Workspace { + name: DEFAULT_WORKSPACE_NAME.to_string(), + tabs: vec![0], + selected_tab: 0, + }], + active: 0, + drawer_width: DEFAULT_DRAWER_WIDTH, + } + } + + #[inline] + pub fn drawer_width(&self) -> f32 { + self.drawer_width + } + + #[inline] + pub fn set_drawer_width(&mut self, width: f32) { + self.drawer_width = width.clamp(MIN_DRAWER_WIDTH, MAX_DRAWER_WIDTH); + } + + #[inline] + pub fn active(&self) -> usize { + self.active + } + + #[inline] + pub fn len(&self) -> usize { + self.workspaces.len() + } + + #[inline] + pub fn get(&self, index: usize) -> Option<&Workspace> { + self.workspaces.get(index) + } + + pub fn create(&mut self) -> usize { + let number = self.workspaces.len() + 1; + self.workspaces.push(Workspace { + name: format!("Workspace {number}"), + tabs: Vec::new(), + selected_tab: 0, + }); + self.active = self.workspaces.len() - 1; + self.active + } + + pub fn select(&mut self, index: usize) -> Option { + let workspace = self.workspaces.get(index)?; + let tab = workspace + .tabs + .iter() + .copied() + .find(|&tab| tab == workspace.selected_tab) + .or_else(|| workspace.tabs.first().copied())?; + self.active = index; + Some(tab) + } + + pub fn workspace_for_tab(&self, tab_index: usize) -> Option { + self.workspaces + .iter() + .position(|workspace| workspace.tabs.contains(&tab_index)) + } + + pub fn set_active(&mut self, index: usize) { + if index < self.workspaces.len() { + self.active = index; + } + } + + pub fn select_tab(&mut self, tab_index: usize) { + if let Some(workspace) = self + .workspaces + .iter_mut() + .find(|workspace| workspace.tabs.contains(&tab_index)) + { + workspace.selected_tab = tab_index; + } + } + + pub fn add_tab(&mut self, tab_index: usize) { + if let Some(workspace) = self.workspaces.get_mut(self.active) { + workspace.tabs.push(tab_index); + if workspace.tabs.len() == 1 { + workspace.selected_tab = tab_index; + } + } + } + + pub fn remove_tab(&mut self, removed: usize) { + for workspace in &mut self.workspaces { + workspace.tabs.retain(|&tab| tab != removed); + for tab in &mut workspace.tabs { + if *tab > removed { + *tab -= 1; + } + } + if workspace.selected_tab == removed { + workspace.selected_tab = workspace.tabs.first().copied().unwrap_or(0); + } else if workspace.selected_tab > removed { + workspace.selected_tab -= 1; + } + } + + // A workspace is a useful container only while it has a tab. Keep + // the last workspace alive so Rio always has somewhere to create a + // new terminal. + if self.workspaces.len() > 1 { + self.workspaces + .retain(|workspace| !workspace.tabs.is_empty()); + } + if self.workspaces.is_empty() { + self.workspaces.push(Workspace { + name: DEFAULT_WORKSPACE_NAME.to_string(), + tabs: Vec::new(), + selected_tab: 0, + }); + } + self.active = self.active.min(self.workspaces.len() - 1); + } + + pub fn swap_tabs(&mut self, first: usize, second: usize) { + for workspace in &mut self.workspaces { + if workspace.selected_tab == first { + workspace.selected_tab = second; + } else if workspace.selected_tab == second { + workspace.selected_tab = first; + } + for tab in &mut workspace.tabs { + if *tab == first { + *tab = second; + } else if *tab == second { + *tab = first; + } + } + } + } + + pub fn move_tab(&mut self, from: usize, to: usize) { + for workspace in &mut self.workspaces { + workspace.selected_tab = + Self::remap_tab_index(workspace.selected_tab, from, to); + for tab in &mut workspace.tabs { + *tab = Self::remap_tab_index(*tab, from, to); + } + } + } + + fn remap_tab_index(index: usize, from: usize, to: usize) -> usize { + if index == from { + to + } else if from < to && index > from && index <= to { + index - 1 + } else if to < from && index >= to && index < from { + index + 1 + } else { + index + } + } + + pub fn tab_count(&self, index: usize) -> usize { + self.workspaces + .get(index) + .map_or(0, |workspace| workspace.tabs.len()) + } + + pub fn tab_indices(&self, index: usize) -> &[usize] { + self.workspaces + .get(index) + .map_or(&[], |workspace| workspace.tabs.as_slice()) + } +} + +impl Default for WorkspaceManager { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::WorkspaceManager; + + #[test] + fn removing_a_tab_keeps_workspace_indices_consistent() { + let mut manager = WorkspaceManager::new(); + manager.add_tab(1); + manager.remove_tab(0); + assert_eq!(manager.tab_indices(0), &[0]); + } + + #[test] + fn new_workspaces_are_selected_and_can_receive_a_tab() { + let mut manager = WorkspaceManager::new(); + let workspace = manager.create(); + manager.add_tab(1); + assert_eq!(manager.active(), workspace); + assert_eq!(manager.tab_indices(workspace), &[1]); + } + + #[test] + fn moving_a_tab_preserves_membership_and_selection() { + let mut manager = WorkspaceManager::new(); + manager.add_tab(1); + manager.add_tab(2); + manager.select_tab(0); + manager.move_tab(0, 2); + assert_eq!(manager.tab_indices(0), &[2, 0, 1]); + assert_eq!(manager.select(0), Some(2)); + } +} From c6bbc7b614a02c33e96ff01068ce1310332e4324 Mon Sep 17 00:00:00 2001 From: aa Date: Tue, 22 Sep 2026 14:30:01 -0700 Subject: [PATCH 2/7] tabs --- frontends/rioterm/src/context/mod.rs | 73 ++++++++++++++++-- frontends/rioterm/src/renderer/island.rs | 94 ++++++++++++++++++----- frontends/rioterm/src/renderer/mod.rs | 11 ++- frontends/rioterm/src/screen/mod.rs | 98 +++++++++++++++++++----- frontends/rioterm/src/workspace.rs | 21 ++++- 5 files changed, 248 insertions(+), 49 deletions(-) diff --git a/frontends/rioterm/src/context/mod.rs b/frontends/rioterm/src/context/mod.rs index e0ddba0c77..a6f3458f00 100644 --- a/frontends/rioterm/src/context/mod.rs +++ b/frontends/rioterm/src/context/mod.rs @@ -676,7 +676,7 @@ impl ContextManager { self.sync_current_route(); return; } - self.switch_to_next(); + self.switch_to_next_workspace_tab(); // Make sure first split is selected - get the root key let current_tab = &mut self.contexts[self.current_index]; if let Some(root) = current_tab.root { @@ -691,7 +691,7 @@ impl ContextManager { self.sync_current_route(); return; } - self.switch_to_prev(); + self.switch_to_prev_workspace_tab(); // Make sure last split is selected - get the last key in order let current_tab = &mut self.contexts[self.current_index]; let ordered_keys = current_tab.get_ordered_keys(); @@ -779,7 +779,13 @@ impl ContextManager { return; } - self.set_current(self.contexts.len() - 1); + let Some(&last_tab) = + self.workspaces.tab_indices(self.workspaces.active()).last() + else { + return; + }; + self.workspaces.select_tab(last_tab); + self.set_current(last_tab); } #[inline] @@ -820,6 +826,11 @@ impl ContextManager { self.workspaces.tab_count(index) } + #[inline] + pub fn active_workspace_tab_indices(&self) -> &[usize] { + self.workspaces.tab_indices(self.workspaces.active()) + } + #[inline] pub fn workspace_has_bell(&self, index: usize) -> bool { self.workspaces @@ -1261,6 +1272,48 @@ impl ContextManager { self.sync_current_route(); } + /// Switch to another tab in the active workspace without exposing tabs + /// that belong to a different workspace. + #[inline] + pub fn switch_to_next_workspace_tab(&mut self) { + if self.config.is_native { + self.event_proxy + .send_event(RioEvent::SelectNativeTabNext, self.window_id); + return; + } + + let tabs = self.workspaces.tab_indices(self.workspaces.active()); + if tabs.len() <= 1 { + return; + } + let Some(current) = tabs.iter().position(|&tab| tab == self.current_index) else { + return; + }; + let next = tabs[(current + 1) % tabs.len()]; + self.workspaces.select_tab(next); + self.set_current(next); + } + + #[inline] + pub fn switch_to_prev_workspace_tab(&mut self) { + if self.config.is_native { + self.event_proxy + .send_event(RioEvent::SelectNativeTabPrev, self.window_id); + return; + } + + let tabs = self.workspaces.tab_indices(self.workspaces.active()); + if tabs.len() <= 1 { + return; + } + let Some(current) = tabs.iter().position(|&tab| tab == self.current_index) else { + return; + }; + let previous = tabs[(current + tabs.len() - 1) % tabs.len()]; + self.workspaces.select_tab(previous); + self.set_current(previous); + } + #[inline] pub fn move_current_to_prev(&mut self) { let len = self.contexts.len(); @@ -1270,9 +1323,7 @@ impl ContextManager { let current = self.current_index; let target_index = if current == 0 { len - 1 } else { current - 1 }; - self.contexts.swap(current, target_index); - self.workspaces.swap_tabs(current, target_index); - self.select_tab(target_index); + self.swap_current_tab_with(target_index); } #[inline] @@ -1284,6 +1335,15 @@ impl ContextManager { let current = self.current_index; let target_index = if current == len - 1 { 0 } else { current + 1 }; + self.swap_current_tab_with(target_index); + } + + #[inline] + pub fn swap_current_tab_with(&mut self, target_index: usize) { + let current = self.current_index; + if current == target_index || target_index >= self.contexts.len() { + return; + } self.contexts.swap(current, target_index); self.workspaces.swap_tabs(current, target_index); self.select_tab(target_index); @@ -1303,6 +1363,7 @@ impl ContextManager { let grid = self.contexts.remove(current); self.contexts.insert(target, grid); self.workspaces.move_tab(current, target); + self.workspaces.select_tab(target); self.set_current(target); } diff --git a/frontends/rioterm/src/renderer/island.rs b/frontends/rioterm/src/renderer/island.rs index 00a2ba717d..e1f1f1a28b 100644 --- a/frontends/rioterm/src/renderer/island.rs +++ b/frontends/rioterm/src/renderer/island.rs @@ -563,25 +563,43 @@ impl Island { } } + #[inline] + fn remap_global_index(i: usize, from: usize, to: usize) -> usize { + Self::remap_index(i, from, to) + } + /// Re-key all per-tab-index state after the tab at `from` moved to /// `to` (rotate semantics, matching /// `ContextManager::move_current_tab_to`), then seed slide springs /// on the displaced tabs so they animate into their new slot. - pub fn remap_tab_move(&mut self, from: usize, to: usize, tab_width: f32) { + pub fn remap_tab_move( + &mut self, + visible_tabs: &[usize], + from: usize, + to: usize, + tab_width: f32, + ) { if from == to { return; } + let Some(from_slot) = visible_tabs.iter().position(|&tab| tab == from) else { + return; + }; + let Some(to_slot) = visible_tabs.iter().position(|&tab| tab == to) else { + return; + }; + self.slide_springs = self .slide_springs .drain() - .map(|(i, v)| (Self::remap_index(i, from, to), v)) + .map(|(i, v)| (Self::remap_global_index(i, from, to), v)) .collect(); if let Some(picker) = self.color_picker_tab { - self.color_picker_tab = Some(Self::remap_index(picker, from, to)); + self.color_picker_tab = Some(Self::remap_global_index(picker, from, to)); } if let Some(ref mut drag) = self.drag { - drag.tab_index = Self::remap_index(drag.tab_index, from, to); + drag.tab_index = Self::remap_global_index(drag.tab_index, from, to); } // Displaced tabs shifted one slot away from `from` toward `to`'s @@ -590,15 +608,24 @@ impl Island { // at `to`, which both ranges exclude — while dragging it floats, // and on a keyboard move it jumps (no old position to animate // from that wouldn't fight the selection change). - let (range, delta) = if from < to { + let (range, delta) = if from_slot < to_slot { // Tabs at from+1..=to moved left by one: now at from..to. - (from..to, tab_width) + (from_slot..to_slot, tab_width) } else { // Tabs at to..from moved right by one: now at to+1..=from. - (to + 1..from + 1, -tab_width) + (to_slot + 1..from_slot + 1, -tab_width) }; - for i in range { - let spring = self.slide_springs.entry(i).or_insert_with(Spring::new); + for slot in range { + let old_global = if from_slot < to_slot { + visible_tabs[slot + 1] + } else { + visible_tabs[slot - 1] + }; + let new_global = Self::remap_global_index(old_global, from, to); + let spring = self + .slide_springs + .entry(new_global) + .or_insert_with(Spring::new); spring.position += delta; } } @@ -608,11 +635,24 @@ impl Island { /// (including the wrap-around end-to-end case) instead of rotating. /// Adjacent swaps get slide springs; wrap-around jumps don't (a /// full-bar slide reads as glitch, not motion). - pub fn remap_tab_swap(&mut self, a: usize, b: usize, tab_width: f32) { + pub fn remap_tab_swap( + &mut self, + visible_tabs: &[usize], + a: usize, + b: usize, + tab_width: f32, + ) { if a == b { return; } + let Some(a_slot) = visible_tabs.iter().position(|&tab| tab == a) else { + return; + }; + let Some(b_slot) = visible_tabs.iter().position(|&tab| tab == b) else { + return; + }; + let swap_key = |i: usize| { if i == a { b @@ -631,8 +671,8 @@ impl Island { self.color_picker_tab = Some(swap_key(picker)); } - if a.abs_diff(b) == 1 { - let delta = (b as f32 - a as f32) * tab_width; + if a_slot.abs_diff(b_slot) == 1 { + let delta = (b_slot as f32 - a_slot as f32) * tab_width; let spring = self.slide_springs.entry(a).or_insert_with(Spring::new); spring.position += delta; let spring = self.slide_springs.entry(b).or_insert_with(Spring::new); @@ -745,10 +785,11 @@ impl Island { sugarloaf: &mut Sugarloaf, dimensions: (f32, f32, f32), context_manager: &ContextManager, + tab_indices: &[usize], bg_color: [f32; 4], ) { let (window_width, _window_height, scale_factor) = dimensions; - let num_tabs = context_manager.len(); + let num_tabs = tab_indices.len(); let current_tab_index = context_manager.current_index(); // Immediate-mode: no cached ids to hide. If we early-return @@ -818,7 +859,7 @@ impl Island { let fills = island_fills(bg_color); // Render each tab - for tab_index in 0..num_tabs { + for &tab_index in tab_indices { // The dragged tab floats — drawn after the loop instead. if Some(tab_index) == drag_index { x_position += tab_width; @@ -1067,8 +1108,10 @@ impl Island { // Render color picker if open if let Some(picker_tab) = self.color_picker_tab { - if picker_tab < num_tabs { - let picker_tab_x = left_margin + picker_tab as f32 * tab_width; + if let Some(picker_slot) = + tab_indices.iter().position(|&tab| tab == picker_tab) + { + let picker_tab_x = left_margin + picker_slot as f32 * tab_width; let selected = context_manager.custom_color(picker_tab); self.render_color_picker(sugarloaf, picker_tab_x, tab_width, selected); } @@ -1188,7 +1231,7 @@ impl Island { mouse_y: f32, scale_factor: f32, window_width: f32, - num_tabs: usize, + tab_indices: &[usize], context_manager: &mut ContextManager, ) -> bool { let picker_tab = match self.color_picker_tab { @@ -1204,8 +1247,19 @@ impl Island { left_margin, tab_width, .. - } = tab_strip_layout(window_width, scale_factor, num_tabs, self.max_tab_width); - let tab_x = left_margin + picker_tab as f32 * tab_width; + } = tab_strip_layout( + window_width, + scale_factor, + tab_indices.len(), + self.max_tab_width, + ); + let Some(picker_slot) = tab_indices.iter().position(|&tab| tab == picker_tab) + else { + self.apply_rename(context_manager); + self.color_picker_tab = None; + return false; + }; + let tab_x = left_margin + picker_slot as f32 * tab_width; // Picker is rendered just below the island let picker_y = ISLAND_HEIGHT; @@ -1892,7 +1946,7 @@ mod tests { // and titles now live on the tab in ContextManager (see // context::test::test_custom_color_* / test_custom_title_*), so they // no longer need remapping here. - island.remap_tab_move(1, 3, 100.0); + island.remap_tab_move(&[0, 1, 2, 3], 1, 3, 100.0); assert_eq!(island.color_picker_tab, Some(2)); // Displaced tabs (now at 1 and 2) got slide springs of +width. diff --git a/frontends/rioterm/src/renderer/mod.rs b/frontends/rioterm/src/renderer/mod.rs index de0002e5ef..aaa39e73cd 100644 --- a/frontends/rioterm/src/renderer/mod.rs +++ b/frontends/rioterm/src/renderer/mod.rs @@ -347,11 +347,19 @@ impl Renderer { ..DrawOpts::default() }; + // The macOS traffic lights occupy the upper-left corner of the + // unified titlebar. Keep the drawer heading clear of that chrome; + // workspace rows start below it and retain their normal inset. + #[cfg(target_os = "macos")] + let title_x = 76.0; + #[cfg(not(target_os = "macos"))] + let title_x = 16.0; + sugarloaf.rect(None, 0.0, 0.0, width, height, panel, 0.0, 30); sugarloaf.line(width - 1.0, 0.0, width - 1.0, height, 1.0, 0.0, divider, 31); sugarloaf .text_mut() - .draw(16.0, 17.0, "Workspaces", &title_opts); + .draw(title_x, 17.0, "Workspaces", &title_opts); sugarloaf .text_mut() .draw(width - 30.0, 17.0, "+", &title_opts); @@ -940,6 +948,7 @@ impl Renderer { sugarloaf, (window_size.width, window_size.height, scale_factor), context_manager, + context_manager.active_workspace_tab_indices(), island_bg, ); } diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index a716895c2f..e21f7327c4 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -1536,8 +1536,15 @@ impl Screen<'_> { self.mark_dirty(); } Act::SelectTab(tab_index) => { + let Some(&tab_index) = self + .context_manager + .active_workspace_tab_indices() + .get(*tab_index) + else { + return false; + }; let old_index = self.context_manager.current_index(); - self.context_manager.select_tab(*tab_index); + self.context_manager.select_tab(tab_index); let new_index = self.context_manager.current_index(); self.context_manager.switch_context_visibility( &mut self.sugarloaf, @@ -1563,7 +1570,7 @@ impl Screen<'_> { self.cancel_search(clipboard); self.clear_selection(); let old_index = self.context_manager.current_index(); - self.context_manager.switch_to_next(); + self.context_manager.switch_to_next_workspace_tab(); let new_index = self.context_manager.current_index(); self.context_manager.switch_context_visibility( &mut self.sugarloaf, @@ -1575,8 +1582,19 @@ impl Screen<'_> { Act::MoveCurrentTabToPrev => { self.cancel_search(clipboard); self.clear_selection(); + let workspace_tabs = + self.context_manager.active_workspace_tab_indices().to_vec(); let old_index = self.context_manager.current_index(); - self.context_manager.move_current_to_prev(); + if let Some(position) = + workspace_tabs.iter().position(|&tab| tab == old_index) + { + if workspace_tabs.len() > 1 { + let target = + workspace_tabs[(position + workspace_tabs.len() - 1) + % workspace_tabs.len()]; + self.context_manager.swap_current_tab_with(target); + } + } let new_index = self.context_manager.current_index(); self.context_manager.switch_context_visibility( &mut self.sugarloaf, @@ -1584,17 +1602,32 @@ impl Screen<'_> { new_index, ); let tab_width = - self.island_tab_layout(self.context_manager.len()).tab_width; + self.island_tab_layout(workspace_tabs.len()).tab_width; if let Some(ref mut island) = self.renderer.island { - island.remap_tab_swap(old_index, new_index, tab_width); + island.remap_tab_swap( + &workspace_tabs, + old_index, + new_index, + tab_width, + ); } self.mark_dirty(); } Act::MoveCurrentTabToNext => { self.cancel_search(clipboard); self.clear_selection(); + let workspace_tabs = + self.context_manager.active_workspace_tab_indices().to_vec(); let old_index = self.context_manager.current_index(); - self.context_manager.move_current_to_next(); + if let Some(position) = + workspace_tabs.iter().position(|&tab| tab == old_index) + { + if workspace_tabs.len() > 1 { + let target = + workspace_tabs[(position + 1) % workspace_tabs.len()]; + self.context_manager.swap_current_tab_with(target); + } + } let new_index = self.context_manager.current_index(); self.context_manager.switch_context_visibility( &mut self.sugarloaf, @@ -1602,9 +1635,14 @@ impl Screen<'_> { new_index, ); let tab_width = - self.island_tab_layout(self.context_manager.len()).tab_width; + self.island_tab_layout(workspace_tabs.len()).tab_width; if let Some(ref mut island) = self.renderer.island { - island.remap_tab_swap(old_index, new_index, tab_width); + island.remap_tab_swap( + &workspace_tabs, + old_index, + new_index, + tab_width, + ); } self.mark_dirty(); } @@ -1612,7 +1650,7 @@ impl Screen<'_> { self.cancel_search(clipboard); self.clear_selection(); let old_index = self.context_manager.current_index(); - self.context_manager.switch_to_prev(); + self.context_manager.switch_to_prev_workspace_tab(); let new_index = self.context_manager.current_index(); self.context_manager.switch_context_visibility( &mut self.sugarloaf, @@ -2997,15 +3035,20 @@ impl Screen<'_> { } pub fn update_close_button_hover(&mut self, mouse_x: f64, mouse_y: f64) -> bool { - let num_tabs = self.context_manager.len(); + let tab_indices = self.context_manager.active_workspace_tab_indices(); + let num_tabs = tab_indices.len(); let scale_factor = self.sugarloaf.scale_factor(); + let current_slot = tab_indices + .iter() + .position(|&tab| tab == self.context_manager.current_index()) + .unwrap_or(0); let hovering = num_tabs > 1 && self.renderer.navigation.island_visible(num_tabs) && mouse_y <= (ISLAND_HEIGHT * scale_factor) as f64 && island::close_button_hit( &self.island_tab_layout(num_tabs), - self.context_manager.current_index(), + current_slot, mouse_x as f32 / scale_factor, ); @@ -3036,7 +3079,8 @@ impl Screen<'_> { let island_height_px = (ISLAND_HEIGHT * scale_factor) as f64; let window_width = self.sugarloaf.window_size().width; - let num_tabs = self.context_manager.len(); + let tab_indices = self.context_manager.active_workspace_tab_indices().to_vec(); + let num_tabs = tab_indices.len(); let island_visible = self.renderer.navigation.island_visible(num_tabs); if let Some(ref mut island) = self.renderer.island { @@ -3046,7 +3090,7 @@ impl Screen<'_> { mouse_y as f32, scale_factor, window_width, - num_tabs, + &tab_indices, &mut self.context_manager, ); if consumed { @@ -3106,7 +3150,10 @@ impl Screen<'_> { && self.is_close_press_tail(mouse_x_unscaled) && !island::close_button_hit( &layout, - self.context_manager.current_index(), + tab_indices + .iter() + .position(|&tab| tab == self.context_manager.current_index()) + .unwrap_or(0), mouse_x_unscaled, ) { @@ -3127,7 +3174,8 @@ impl Screen<'_> { // `.min` guards the float edge where x_in_tabs / tab_width // lands exactly on num_tabs despite x_in_tabs < tabs_width. - let clicked_tab = ((x_in_tabs / layout.tab_width) as usize).min(num_tabs - 1); + let clicked_slot = ((x_in_tabs / layout.tab_width) as usize).min(num_tabs - 1); + let clicked_tab = tab_indices[clicked_slot]; #[cfg(target_os = "macos")] if !is_right_click && self.modifiers.state().super_key() { @@ -3158,7 +3206,7 @@ impl Screen<'_> { } if clicked_tab == self.context_manager.current_index() - && island::close_button_hit(&layout, clicked_tab, mouse_x_unscaled) + && island::close_button_hit(&layout, clicked_slot, mouse_x_unscaled) { self.stop_hint_mode_if_active(); self.last_close_press = Some((std::time::Instant::now(), mouse_x_unscaled)); @@ -3195,7 +3243,8 @@ impl Screen<'_> { let can_reorder = true; if num_tabs > 1 && can_reorder { if let Some(ref mut island) = self.renderer.island { - let tab_left = layout.left_margin + clicked_tab as f32 * layout.tab_width; + let tab_left = + layout.left_margin + clicked_slot as f32 * layout.tab_width; island.start_drag( clicked_tab, mouse_x_unscaled - tab_left, @@ -3208,7 +3257,8 @@ impl Screen<'_> { } pub fn handle_tab_drag_move(&mut self, x_unscaled: f32) { - let num_tabs = self.context_manager.len(); + let tab_indices = self.context_manager.active_workspace_tab_indices().to_vec(); + let num_tabs = tab_indices.len(); // A tab closed mid-drag invalidates the armed indices. if num_tabs < 2 { @@ -3243,8 +3293,9 @@ impl Screen<'_> { return; } - let target = (((center - layout.left_margin) / layout.tab_width) as usize) + let target_slot = (((center - layout.left_margin) / layout.tab_width) as usize) .min(num_tabs - 1); + let target = tab_indices[target_slot]; if target != old_index { self.context_manager.move_current_tab_to(target); let new_index = self.context_manager.current_index(); @@ -3254,14 +3305,19 @@ impl Screen<'_> { new_index, ); if let Some(ref mut island) = self.renderer.island { - island.remap_tab_move(old_index, new_index, layout.tab_width); + island.remap_tab_move( + &tab_indices, + old_index, + new_index, + layout.tab_width, + ); } } self.mark_dirty(); } pub fn handle_tab_drag_release(&mut self) -> bool { - let num_tabs = self.context_manager.len(); + let num_tabs = self.context_manager.active_workspace_tab_indices().len(); let layout = self.island_tab_layout(num_tabs); if let Some(ref mut island) = self.renderer.island { diff --git a/frontends/rioterm/src/workspace.rs b/frontends/rioterm/src/workspace.rs index 70e84c50d3..2c6a42e3c7 100644 --- a/frontends/rioterm/src/workspace.rs +++ b/frontends/rioterm/src/workspace.rs @@ -156,6 +156,7 @@ impl WorkspaceManager { *tab = first; } } + workspace.tabs.sort_unstable(); } } @@ -166,6 +167,7 @@ impl WorkspaceManager { for tab in &mut workspace.tabs { *tab = Self::remap_tab_index(*tab, from, to); } + workspace.tabs.sort_unstable(); } } @@ -221,6 +223,23 @@ mod tests { assert_eq!(manager.tab_indices(workspace), &[1]); } + #[test] + fn tabs_are_kept_in_their_workspace() { + let mut manager = WorkspaceManager::new(); + manager.add_tab(1); + let second = manager.create(); + manager.add_tab(2); + manager.add_tab(3); + + assert_eq!(manager.tab_indices(0), &[0, 1]); + assert_eq!(manager.tab_indices(second), &[2, 3]); + + manager.set_active(0); + manager.add_tab(4); + assert_eq!(manager.tab_indices(0), &[0, 1, 4]); + assert_eq!(manager.tab_indices(second), &[2, 3]); + } + #[test] fn moving_a_tab_preserves_membership_and_selection() { let mut manager = WorkspaceManager::new(); @@ -228,7 +247,7 @@ mod tests { manager.add_tab(2); manager.select_tab(0); manager.move_tab(0, 2); - assert_eq!(manager.tab_indices(0), &[2, 0, 1]); + assert_eq!(manager.tab_indices(0), &[0, 1, 2]); assert_eq!(manager.select(0), Some(2)); } } From 254b9c1441f2405bea67fe884355750d2e9259b2 Mon Sep 17 00:00:00 2001 From: aa Date: Tue, 22 Sep 2026 15:00:27 -0700 Subject: [PATCH 3/7] save settings --- Cargo.lock | 1 + frontends/rioterm/Cargo.toml | 1 + frontends/rioterm/src/application.rs | 12 ++ frontends/rioterm/src/context/mod.rs | 166 +++++++++++++++++++++++++-- frontends/rioterm/src/workspace.rs | 148 ++++++++++++++++++++++++ 5 files changed, 319 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dc1cef71bb..0f3075e7f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4072,6 +4072,7 @@ dependencies = [ "rio-window", "rustc-hash 2.1.3", "serde", + "serde_json", "smallvec", "sugarloaf", "taffy", diff --git a/frontends/rioterm/Cargo.toml b/frontends/rioterm/Cargo.toml index 760d916e52..bfb4cdcd72 100644 --- a/frontends/rioterm/Cargo.toml +++ b/frontends/rioterm/Cargo.toml @@ -46,6 +46,7 @@ image_rs = { workspace = true } libc = { workspace = true } parking_lot = { workspace = true } serde = { workspace = true } +serde_json = "1.0" taffy = { version = "0.10.1", features = ["flexbox","grid"] } teletypewriter = { workspace = true } rio-unicode = { workspace = true } diff --git a/frontends/rioterm/src/application.rs b/frontends/rioterm/src/application.rs index c78b8124f6..b7f1ef80db 100644 --- a/frontends/rioterm/src/application.rs +++ b/frontends/rioterm/src/application.rs @@ -2364,6 +2364,10 @@ impl ApplicationHandler for Application<'_> { } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + for route in self.router.routes.values_mut() { + route.window.screen.context_manager.save_workspace_state(); + } + // A confirmed quit can be set from any input path (keys today, // a future mouse or menu handler tomorrow); honoring it here, // once per event batch, keeps the exit independent of which @@ -2433,6 +2437,14 @@ impl ApplicationHandler for Application<'_> { // This is irreversible - if this event is emitted, it is guaranteed to be the last event that gets emitted. // You generally want to treat this as an “do on quit” event. fn exiting(&mut self, _event_loop: &ActiveEventLoop) { + for route in self.router.routes.values_mut() { + route + .window + .screen + .context_manager + .save_workspace_state_now(); + } + // Ensure that all the windows are dropped, so the destructors for // Renderer and contexts ran. self.router.routes.clear(); diff --git a/frontends/rioterm/src/context/mod.rs b/frontends/rioterm/src/context/mod.rs index a6f3458f00..810cc2b0ec 100644 --- a/frontends/rioterm/src/context/mod.rs +++ b/frontends/rioterm/src/context/mod.rs @@ -8,7 +8,7 @@ use crate::event::{Msg, RioEvent}; pub use crate::layout::{ContextDimension, ContextGrid, ContextGridItem}; use crate::messenger::Messenger; use crate::performer::{self, Machine}; -use crate::workspace::WorkspaceManager; +use crate::workspace::{PersistedTab, WorkspaceManager}; use renderable::Cursor; use renderable::RenderableContent; use rio_backend::config::layout::Margin; @@ -22,10 +22,10 @@ use rio_backend::event::WindowId; use rio_backend::selection::SelectionRange; use rio_backend::sugarloaf::{font::SugarloafFont, Rect, Sugarloaf, SugarloafErrors}; use std::error::Error; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::thread::JoinHandle; -use std::time::Instant; +use std::time::{Duration, Instant}; // Global atomic counter for generating unique route IDs static ROUTE_ID_COUNTER: AtomicUsize = AtomicUsize::new(1); @@ -33,6 +33,11 @@ static ROUTE_ID_COUNTER: AtomicUsize = AtomicUsize::new(1); // Global atomic counter for generating unique rich text IDs static RICH_TEXT_ID_COUNTER: AtomicUsize = AtomicUsize::new(0); +// The on-disk snapshot represents the app's primary workspace set. Only the +// first terminal route owns it; additional windows keep their independent, +// in-memory workspace state instead of overwriting the same file. +static WORKSPACE_PERSISTENCE_OWNER: AtomicBool = AtomicBool::new(false); + /// Generate a unique rich text ID for terminal contexts pub fn next_rich_text_id() -> usize { RICH_TEXT_ID_COUNTER.fetch_add(1, Ordering::Relaxed) @@ -144,6 +149,9 @@ pub struct ContextManager { pub config: ContextManagerConfig, pub workspaces: WorkspaceManager, base_scaled_margin: Margin, + persistence_enabled: bool, + last_saved_state: Option>, + persistence_ready_at: Option, } /// Display name for the command a pane spawns: the configured program, @@ -421,13 +429,34 @@ impl ContextManager { scaled_margin: Margin, sugarloaf_errors: Option, ) -> Result> { + let persistence_enabled = !ctx_config.is_native + && !cfg!(test) + && !WORKSPACE_PERSISTENCE_OWNER.swap(true, Ordering::SeqCst); + let restored = persistence_enabled.then(WorkspaceManager::load).flatten(); + let mut initial_config = ctx_config.clone(); + if let Some(directory) = restored + .as_ref() + .and_then(|(_, tabs)| tabs.first()) + .and_then(|tab| tab.current_directory.as_deref()) + .filter(|directory| std::path::Path::new(directory).is_dir()) + { + initial_config.working_dir = Some(directory.to_string()); + // The fork-based PTY inherits Rio's cwd. Use the spawn path for + // restored tabs so the persisted directory is applied before the + // shell starts (the default on Linux/BSD is otherwise fork). + #[cfg(not(target_os = "windows"))] + { + initial_config.use_fork = false; + } + } + let initial_context = match ContextManager::create_context( cursor_state, event_proxy.clone(), window_id, rich_text_id, size, - &ctx_config, + &initial_config, ) { Ok(context) => context, Err(err_message) => { @@ -482,9 +511,44 @@ impl ContextManager { event_proxy, window_id, config: ctx_config, - workspaces: WorkspaceManager::new(), + workspaces: restored + .as_ref() + .map(|(workspaces, _)| workspaces.clone()) + .unwrap_or_default(), base_scaled_margin: scaled_margin, + persistence_enabled, + last_saved_state: None, + persistence_ready_at: restored + .as_ref() + .map(|_| Instant::now() + Duration::from_secs(1)), }; + + if let Some((_, tabs)) = restored { + let mut restore_succeeded = true; + for tab in tabs.iter().skip(1) { + let directory = tab + .current_directory + .as_deref() + .filter(|directory| std::path::Path::new(directory).is_dir()) + .map(str::to_string); + if !manager.add_restored_context(directory, next_rich_text_id()) { + restore_succeeded = false; + break; + } + } + if !restore_succeeded { + // A shell can fail to spawn independently of the saved + // layout. Keep the contexts that did start usable rather + // than leaving workspace indices pointing past the vector. + manager.workspaces = WorkspaceManager::new(); + for tab_index in 1..manager.contexts.len() { + manager.workspaces.add_tab(tab_index); + } + } + if let Some(selected_tab) = manager.workspaces.selected_tab_for_active() { + manager.set_current(selected_tab); + } + } // The native titlebar starts as the placeholder; one poke makes // it converge on the displayed title even for shells that never // emit an OSC title or OSC 7. @@ -528,6 +592,9 @@ impl ContextManager { config, workspaces: WorkspaceManager::new(), base_scaled_margin: Margin::default(), + persistence_enabled: false, + last_saved_state: None, + persistence_ready_at: None, }) } @@ -851,6 +918,62 @@ impl ContextManager { self.workspaces.select(index) } + /// Persist the workspace layout and the last OSC 7 directory reported by + /// each tab. The serialized bytes are cached so the event loop can call + /// this after ordinary terminal events without rewriting an unchanged + /// snapshot. + pub fn save_workspace_state(&mut self) { + self.save_workspace_state_inner(false); + } + + /// Save immediately during shutdown, even if restored shells have not yet + /// had time to report their initial OSC 7 directories. + pub fn save_workspace_state_now(&mut self) { + self.save_workspace_state_inner(true); + } + + fn save_workspace_state_inner(&mut self, force: bool) { + if !self.persistence_enabled { + return; + } + if let Some(ready_at) = self.persistence_ready_at { + if !force && Instant::now() < ready_at { + return; + } + self.persistence_ready_at = None; + } + + let tabs: Vec = (0..self.contexts.len()) + .map(|index| { + let title = self.displayed_title_for_tab(index); + let context = self.contexts[index].current(); + let terminal = context.terminal.lock(); + PersistedTab { + title, + current_directory: terminal + .current_directory + .as_ref() + .map(|directory| directory.to_string_lossy().into_owned()), + } + }) + .collect(); + let state = self.workspaces.snapshot(|tab_index| { + tabs.get(tab_index).cloned().unwrap_or(PersistedTab { + title: String::new(), + current_directory: None, + }) + }); + let Ok(data) = serde_json::to_vec(&state) else { + return; + }; + if self.last_saved_state.as_deref() == Some(data.as_slice()) { + return; + } + if WorkspaceManager::save_snapshot(&state) { + self.last_saved_state = Some(data); + } + } + #[inline] pub fn drawer_width(&self) -> f32 { self.workspaces.drawer_width() @@ -1518,10 +1641,27 @@ impl ContextManager { } } + self.append_context(working_dir, redirect, rich_text_id, true, false); + } + + fn add_restored_context( + &mut self, + working_dir: Option, + rich_text_id: usize, + ) -> bool { + self.append_context(working_dir, false, rich_text_id, false, true) + } + + fn append_context( + &mut self, + working_dir: Option, + redirect: bool, + rich_text_id: usize, + add_to_workspace: bool, + force_spawn: bool, + ) -> bool { if self.config.is_native { - self.event_proxy - .send_event(RioEvent::CreateNativeTab(working_dir), self.window_id); - return; + return false; } let size = self.contexts.len(); @@ -1531,6 +1671,10 @@ impl ContextManager { let mut cloned_config = self.config.clone(); if working_dir.is_some() { cloned_config.working_dir = working_dir; + #[cfg(not(target_os = "windows"))] + if force_spawn { + cloned_config.use_fork = false; + } } let current = self.current(); @@ -1560,17 +1704,21 @@ impl ContextManager { self.config.split_active_color, self.config.panel, )); - self.workspaces.add_tab(last_index); + if add_to_workspace { + self.workspaces.add_tab(last_index); + } if redirect { self.current_index = last_index; self.sync_current_route(); } + return true; } Err(..) => { tracing::error!("not able to create a new context"); } } } + false } /// Hide all rich text components except for the current tab diff --git a/frontends/rioterm/src/workspace.rs b/frontends/rioterm/src/workspace.rs index 2c6a42e3c7..badea9b821 100644 --- a/frontends/rioterm/src/workspace.rs +++ b/frontends/rioterm/src/workspace.rs @@ -1,8 +1,14 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + pub const DEFAULT_DRAWER_WIDTH: f32 = 220.0; pub const MIN_DRAWER_WIDTH: f32 = 160.0; pub const MAX_DRAWER_WIDTH: f32 = 420.0; const DEFAULT_WORKSPACE_NAME: &str = "Main"; +const PERSISTED_STATE_VERSION: u32 = 1; +const MAX_RESTORED_TABS: usize = 28; #[derive(Debug, Clone)] pub struct Workspace { @@ -18,6 +24,26 @@ pub struct WorkspaceManager { drawer_width: f32, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PersistedWorkspaceState { + pub version: u32, + pub active_workspace: usize, + pub workspaces: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PersistedWorkspace { + pub name: String, + pub selected_tab: usize, + pub tabs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PersistedTab { + pub title: String, + pub current_directory: Option, +} + impl WorkspaceManager { pub fn new() -> Self { Self { @@ -31,6 +57,122 @@ impl WorkspaceManager { } } + pub fn load() -> Option<(Self, Vec)> { + let data = fs::read(Self::state_path()).ok()?; + let state = serde_json::from_slice::(&data).ok()?; + Self::from_persisted(state) + } + + fn from_persisted( + state: PersistedWorkspaceState, + ) -> Option<(Self, Vec)> { + if state.version != PERSISTED_STATE_VERSION + || state.workspaces.is_empty() + || state + .workspaces + .iter() + .any(|workspace| workspace.tabs.is_empty()) + { + return None; + } + + let tab_count: usize = state + .workspaces + .iter() + .map(|workspace| workspace.tabs.len()) + .sum(); + if tab_count == 0 || tab_count > MAX_RESTORED_TABS { + return None; + } + + let workspace_count = state.workspaces.len(); + let active_workspace = state.active_workspace.min(workspace_count - 1); + let mut tabs = Vec::with_capacity(tab_count); + let mut workspaces = Vec::with_capacity(workspace_count); + for persisted in state.workspaces { + let first_tab = tabs.len(); + let tab_len = persisted.tabs.len(); + tabs.extend(persisted.tabs); + let selected_tab = first_tab + persisted.selected_tab.min(tab_len - 1); + workspaces.push(Workspace { + name: persisted.name, + tabs: (first_tab..first_tab + tab_len).collect(), + selected_tab, + }); + } + + Some(( + Self { + workspaces, + active: active_workspace, + drawer_width: DEFAULT_DRAWER_WIDTH, + }, + tabs, + )) + } + + pub fn snapshot(&self, mut tab: F) -> PersistedWorkspaceState + where + F: FnMut(usize) -> PersistedTab, + { + PersistedWorkspaceState { + version: PERSISTED_STATE_VERSION, + active_workspace: self.active, + workspaces: self + .workspaces + .iter() + .map(|workspace| PersistedWorkspace { + name: workspace.name.clone(), + selected_tab: workspace + .tabs + .iter() + .position(|&tab_index| tab_index == workspace.selected_tab) + .unwrap_or(0), + tabs: workspace.tabs.iter().copied().map(&mut tab).collect(), + }) + .collect(), + } + } + + pub fn save_snapshot(state: &PersistedWorkspaceState) -> bool { + let data = match serde_json::to_vec(state) { + Ok(data) => data, + Err(error) => { + tracing::warn!("could not serialize workspace state: {error}"); + return false; + } + }; + + let path = Self::state_path(); + let Some(directory) = path.parent() else { + return false; + }; + if let Err(error) = fs::create_dir_all(directory) { + tracing::warn!("could not create workspace state directory: {error}"); + return false; + } + + let temporary_path = path.with_extension("json.tmp"); + if let Err(error) = fs::write(&temporary_path, data) { + tracing::warn!("could not write workspace state: {error}"); + return false; + } + if let Err(error) = fs::rename(&temporary_path, &path) { + tracing::warn!("could not replace workspace state: {error}"); + let _ = fs::remove_file(temporary_path); + return false; + } + true + } + + fn state_path() -> PathBuf { + dirs::data_local_dir() + .or_else(dirs::data_dir) + .unwrap_or_else(|| Path::new(".").to_path_buf()) + .join("rio") + .join("workspaces.json") + } + #[inline] pub fn drawer_width(&self) -> f32 { self.drawer_width @@ -46,6 +188,12 @@ impl WorkspaceManager { self.active } + pub fn selected_tab_for_active(&self) -> Option { + self.workspaces + .get(self.active) + .map(|workspace| workspace.selected_tab) + } + #[inline] pub fn len(&self) -> usize { self.workspaces.len() From c6f352808fab619b86d11b38949eb039e4320076 Mon Sep 17 00:00:00 2001 From: aa Date: Tue, 22 Sep 2026 15:11:12 -0700 Subject: [PATCH 4/7] quit ok --- .../src/platform_impl/macos/app_delegate.rs | 49 +------------------ 1 file changed, 1 insertion(+), 48 deletions(-) diff --git a/rio-window/src/platform_impl/macos/app_delegate.rs b/rio-window/src/platform_impl/macos/app_delegate.rs index 201dbfcc1b..1abf6db61d 100644 --- a/rio-window/src/platform_impl/macos/app_delegate.rs +++ b/rio-window/src/platform_impl/macos/app_delegate.rs @@ -39,10 +39,7 @@ pub const UTF8_ENCODING: usize = 4; #[repr(u64)] #[derive(Copy, Clone, PartialEq)] pub enum NSApplicationTerminateReply { - Cancel = 0, Now = 1, - #[allow(unused)] - Later = 2, } #[derive(Debug)] @@ -100,51 +97,7 @@ declare_class!( unsafe impl NSApplicationDelegate for ApplicationDelegate { #[method(applicationShouldTerminate:)] fn should_terminate(&self, _sender: Option<&AnyObject>) -> u64 { - if !self.ivars().set_confirm_before_quit.get() { - return NSApplicationTerminateReply::Now as u64; - } - - use objc::runtime::Object; - use objc::msg_send; - use objc::sel; - use objc::class; - use objc::sel_impl; - unsafe { - let panel: *mut Object = msg_send![class!(NSAlert), new]; - - let prompt = "All sessions will be closed"; - let title = "Quit Rio terminal?"; - let yes = "Yes"; - let no = "No"; - let cancel = "Cancel"; - - let prompt_string: *mut Object = msg_send![class!(NSString), alloc]; - let prompt_allocated_string: *mut Object = msg_send![prompt_string, initWithBytes:prompt.as_ptr() length:prompt.len() encoding:UTF8_ENCODING]; - - let title_string: *mut Object = msg_send![class!(NSString), alloc]; - let title_allocated_string: *mut Object = msg_send![title_string, initWithBytes:title.as_ptr() length:title.len() encoding:UTF8_ENCODING]; - - let yes_string: *mut Object = msg_send![class!(NSString), alloc]; - let yes_allocated_string: *mut Object = msg_send![yes_string, initWithBytes:yes.as_ptr() length:yes.len() encoding:UTF8_ENCODING]; - - let no_string: *mut Object = msg_send![class!(NSString), alloc]; - let no_allocated_string: *mut Object = msg_send![no_string, initWithBytes:no.as_ptr() length:no.len() encoding:UTF8_ENCODING]; - - let cancel_string: *mut Object = msg_send![class!(NSString), alloc]; - let cancel_allocated_string: *mut Object = msg_send![cancel_string, initWithBytes:cancel.as_ptr() length:cancel.len() encoding:UTF8_ENCODING]; - - let _: () = msg_send![panel, setMessageText: title_allocated_string]; - let _: () = msg_send![panel, setInformativeText: prompt_allocated_string]; - let _: () = msg_send![panel, addButtonWithTitle: yes_allocated_string]; - let _: () = msg_send![panel, addButtonWithTitle: no_allocated_string]; - let _: () = msg_send![panel, addButtonWithTitle: cancel_allocated_string]; - let response: std::ffi::c_long = msg_send![panel, runModal]; - match response { - 1000 => NSApplicationTerminateReply::Now as u64, - 1001 => NSApplicationTerminateReply::Cancel as u64, - _ => NSApplicationTerminateReply::Cancel as u64, - } - } + NSApplicationTerminateReply::Now as u64 } #[method(applicationDockMenu:)] From 433ed31f973c650e8c00c867af6d3da839c2a435 Mon Sep 17 00:00:00 2001 From: aa Date: Tue, 22 Sep 2026 15:43:22 -0700 Subject: [PATCH 5/7] polish --- frontends/rioterm/src/application.rs | 8 ++ frontends/rioterm/src/context/mod.rs | 54 +++++++++-- frontends/rioterm/src/renderer/island.rs | 56 +++++++---- frontends/rioterm/src/renderer/mod.rs | 76 ++++++++++++--- frontends/rioterm/src/screen/mod.rs | 50 ++++++++-- frontends/rioterm/src/workspace.rs | 113 ++++++++++++++++++++++- 6 files changed, 311 insertions(+), 46 deletions(-) diff --git a/frontends/rioterm/src/application.rs b/frontends/rioterm/src/application.rs index b7f1ef80db..f18469f9dd 100644 --- a/frontends/rioterm/src/application.rs +++ b/frontends/rioterm/src/application.rs @@ -1814,6 +1814,14 @@ impl ApplicationHandler for Application<'_> { return; } + if let Some(cursor) = route.window.screen.workspace_cursor_icon() { + if route.window.screen.clear_close_button_hover() { + route.request_redraw(); + } + route.window.winit_window.set_cursor(cursor); + return; + } + if route.window.screen.update_close_button_hover(x, y) { route.request_redraw(); } diff --git a/frontends/rioterm/src/context/mod.rs b/frontends/rioterm/src/context/mod.rs index 810cc2b0ec..f9bcb7ace0 100644 --- a/frontends/rioterm/src/context/mod.rs +++ b/frontends/rioterm/src/context/mod.rs @@ -8,7 +8,10 @@ use crate::event::{Msg, RioEvent}; pub use crate::layout::{ContextDimension, ContextGrid, ContextGridItem}; use crate::messenger::Messenger; use crate::performer::{self, Machine}; -use crate::workspace::{PersistedTab, WorkspaceManager}; +use crate::workspace::{ + tab_title_for_directory, workspace_title_for_directory, PersistedTab, + WorkspaceManager, +}; use renderable::Cursor; use renderable::RenderableContent; use rio_backend::config::layout::Margin; @@ -882,10 +885,18 @@ impl ContextManager { } #[inline] - pub fn workspace_name(&self, index: usize) -> Option<&str> { - self.workspaces - .get(index) - .map(|workspace| workspace.name.as_str()) + pub fn workspace_name(&self, index: usize) -> Option { + let workspace = self.workspaces.get(index)?; + let directory = workspace + .tabs + .first() + .and_then(|&tab| self.current_directory_for_tab(tab)); + Some(match directory { + Some(directory) => { + workspace_title_for_directory(&directory, dirs::home_dir().as_deref()) + } + None => workspace.name.clone(), + }) } #[inline] @@ -1140,7 +1151,11 @@ impl ContextManager { let template = self.config.title.content.clone(); let context = self.contexts[tab_index].current_mut(); context.title_dirty = false; - Self::refresh_item_title(&template, context, raw_title) + let title_changed = Self::refresh_item_title(&template, context, raw_title); + // A directory change also changes the pwd-derived tab and workspace + // labels, even when the configured window-title template does not use + // a path variable. + title_changed || raw_title.is_none() } /// Mark every pane's title stale. For changes that affect panes no @@ -1184,13 +1199,16 @@ impl ContextManager { } /// The title the strip displays for `index`'s tab: the user rename, - /// else the rendered content, else the foreground program, else - /// "~". The native titlebar reads the same chain, so the two can - /// never disagree. + /// else the last component of its OSC 7 directory, else the rendered + /// content, foreground program, or "~". The native titlebar reads the + /// same chain, so the two can never disagree. pub fn displayed_title_for_tab(&self, index: usize) -> String { if let Some(custom) = self.custom_title(index) { return custom.to_string(); } + if let Some(directory) = self.current_directory_for_tab(index) { + return tab_title_for_directory(&directory); + } if let Some(grid) = self.contexts.get(index) { let context = grid.current(); if !context.title.content.is_empty() { @@ -1203,6 +1221,12 @@ impl ContextManager { String::from("~") } + fn current_directory_for_tab(&self, index: usize) -> Option { + self.contexts + .get(index) + .and_then(|grid| grid.current().terminal.lock().current_directory.clone()) + } + #[inline] pub fn displayed_title_for_current_tab(&self) -> String { self.displayed_title_for_tab(self.current_index) @@ -1968,6 +1992,18 @@ pub mod test { assert!(!cm.on_title_change(usize::MAX, Some("x"))); } + #[test] + fn pwd_titles_tabs_and_workspaces_like_simple_cmux() { + let cm = + ContextManager::start_with_capacity(5, VoidListener {}, WindowId::from(0)) + .unwrap(); + let directory = dirs::home_dir().unwrap().join("projects").join("rio"); + cm.contexts[0].current().terminal.lock().current_directory = Some(directory); + + assert_eq!(cm.displayed_title_for_tab(0), "rio"); + assert_eq!(cm.workspace_name(0).as_deref(), Some("projects")); + } + fn set_tab_title(cm: &mut ContextManager, index: usize, content: &str) { cm.contexts[index].current_mut().title.content = content.to_string(); } diff --git a/frontends/rioterm/src/renderer/island.rs b/frontends/rioterm/src/renderer/island.rs index e1f1f1a28b..d656a2ea2d 100644 --- a/frontends/rioterm/src/renderer/island.rs +++ b/frontends/rioterm/src/renderer/island.rs @@ -145,20 +145,22 @@ pub struct TabStripLayout { } /// Compute the tab strip layout from the physical window width. -/// `max_tab_width` comes from `navigation.max-tab-width` (logical px). +/// `max_tab_width` and `content_left` are logical pixels; the latter keeps +/// tabs and their hit targets out from under persistent left-side chrome. pub fn tab_strip_layout( window_width: f32, scale_factor: f32, num_tabs: usize, max_tab_width: f32, + content_left: f32, ) -> TabStripLayout { #[cfg(target_os = "macos")] - let left_margin = ISLAND_MARGIN_LEFT_MACOS; + let left_margin = content_left.max(ISLAND_MARGIN_LEFT_MACOS); #[cfg(not(target_os = "macos"))] - let left_margin = 0.0; + let left_margin = content_left; let available_width = - (window_width / scale_factor) - ISLAND_MARGIN_RIGHT - left_margin; + ((window_width / scale_factor) - ISLAND_MARGIN_RIGHT - left_margin).max(0.0); let tab_width = (available_width / num_tabs.max(1) as f32).clamp(0.0, max_tab_width.max(0.0)); TabStripLayout { @@ -276,7 +278,9 @@ fn single_title_x( text_width: f32, left_margin: f32, ) -> f32 { - (((window_width / scale_factor) - text_width) / 2.0).max(left_margin + TAB_PADDING_X) + let right = (window_width / scale_factor) - ISLAND_MARGIN_RIGHT; + let available_width = (right - left_margin).max(0.0); + (left_margin + (available_width - text_width) / 2.0).max(left_margin + TAB_PADDING_X) } #[inline] @@ -836,8 +840,13 @@ impl Island { self.slide_springs .retain(|_, s| s.update(dt, DRAG_ANIMATION_LENGTH)); - let layout = - tab_strip_layout(window_width, scale_factor, num_tabs, self.max_tab_width); + let layout = tab_strip_layout( + window_width, + scale_factor, + num_tabs, + self.max_tab_width, + context_manager.drawer_width(), + ); let TabStripLayout { left_margin, tab_width, @@ -1252,6 +1261,7 @@ impl Island { scale_factor, tab_indices.len(), self.max_tab_width, + context_manager.drawer_width(), ); let Some(picker_slot) = tab_indices.iter().position(|&tab| tab == picker_tab) else { @@ -1559,15 +1569,19 @@ mod tests { /// 2x display and nothing appeared at all. #[test] fn single_title_is_centred_in_logical_pixels() { - // 1600 physical at 2x is an 800pt strip, so a 100pt title starts at + // 1600 physical at 2x is an 800pt strip, so a 100pt title starts near // 350, not at 750 (which would be centred on the physical width and - // sit past the right edge). + // sit past the right edge). The 8pt right inset makes it 346 exactly. let x = single_title_x(1600.0, 2.0, 100.0, 0.0); - assert_eq!(x, 350.0); + assert_eq!(x, 346.0); assert!(x + 100.0 <= 800.0, "title must stay on screen: {x}"); // At 1x the two agree, which is why this only showed up on retina. - assert_eq!(single_title_x(800.0, 1.0, 100.0, 0.0), 350.0); + assert_eq!(single_title_x(800.0, 1.0, 100.0, 0.0), 346.0); + + // With a drawer, centre in the remaining content area rather than + // behind the drawer or across the whole window. + assert_eq!(single_title_x(1600.0, 2.0, 100.0, 220.0), 456.0); } #[test] @@ -1600,7 +1614,7 @@ mod tests { /// is the point of dropping the island. #[test] fn single_title_budget_beats_a_tab_slot() { - let slot = tab_strip_layout(1600.0, 2.0, 1, 240.0).tab_width; + let slot = tab_strip_layout(1600.0, 2.0, 1, 240.0, 0.0).tab_width; let slot_budget = (slot - TAB_PADDING_X * 2.0).max(0.0); assert!( single_title_budget(1600.0, 2.0, 0.0) > slot_budget, @@ -1878,7 +1892,7 @@ mod tests { // 1000 physical px @ 2x scale → 500 logical px window. Slots // stay below the cap here, so the math matches the old // fill-the-strip layout. - let layout = tab_strip_layout(1000.0, 2.0, 4, 240.0); + let layout = tab_strip_layout(1000.0, 2.0, 4, 240.0, 0.0); #[cfg(target_os = "macos")] { assert_eq!(layout.left_margin, ISLAND_MARGIN_LEFT_MACOS); @@ -1892,19 +1906,19 @@ mod tests { assert_eq!(layout.tabs_width, 492.0); } // Zero tabs clamps the divisor. - assert!(tab_strip_layout(1000.0, 2.0, 0, 240.0) + assert!(tab_strip_layout(1000.0, 2.0, 0, 240.0, 0.0) .tab_width .is_finite()); } #[test] fn tab_strip_layout_caps_slot_width() { - let layout = tab_strip_layout(3000.0, 2.0, 2, 240.0); + let layout = tab_strip_layout(3000.0, 2.0, 2, 240.0, 0.0); assert_eq!(layout.tab_width, 240.0); assert_eq!(layout.tabs_width, 480.0); // The cap is configurable via navigation.max-tab-width. - let layout = tab_strip_layout(3000.0, 2.0, 2, 280.0); + let layout = tab_strip_layout(3000.0, 2.0, 2, 280.0, 0.0); assert_eq!(layout.tab_width, 280.0); assert_eq!(layout.tabs_width, 560.0); // The tabs region ends well before the 1500 logical px strip. @@ -1912,11 +1926,19 @@ mod tests { // Pathologically narrow window: width clamps at 0 instead of // going negative. - let layout = tab_strip_layout(10.0, 2.0, 4, 240.0); + let layout = tab_strip_layout(10.0, 2.0, 4, 240.0, 0.0); assert_eq!(layout.tab_width, 0.0); assert_eq!(layout.tabs_width, 0.0); } + #[test] + fn tab_strip_layout_starts_after_the_drawer() { + let layout = tab_strip_layout(1600.0, 2.0, 2, 240.0, 220.0); + assert_eq!(layout.left_margin, 220.0); + assert_eq!(layout.tab_width, 240.0); + assert!(layout.left_margin + layout.tabs_width <= 800.0); + } + #[test] fn remap_tab_move_forward_rotates_indices() { // Move tab 1 → 3: tabs 2 and 3 shift left by one. diff --git a/frontends/rioterm/src/renderer/mod.rs b/frontends/rioterm/src/renderer/mod.rs index aaa39e73cd..c3c320c4bd 100644 --- a/frontends/rioterm/src/renderer/mod.rs +++ b/frontends/rioterm/src/renderer/mod.rs @@ -22,6 +22,9 @@ use rio_backend::event::TerminalDamage; use crate::context::renderable::{PendingUpdate, RenderableContent}; use crate::context::ContextManager; use crate::crosswords::style::{Style as CellStyle, StyleFlags}; +use crate::workspace::{ + DRAWER_RESIZE_HIT_HALF_WIDTH, DRAWER_ROW_HEIGHT, DRAWER_ROW_STRIDE, DRAWER_ROW_TOP, +}; use rio_backend::config::colors::term::TermColors; use rio_backend::config::colors::{ term::{List, DIM_FACTOR}, @@ -305,10 +308,13 @@ impl Renderer { ] }) .unwrap_or(self.named_colors.background.0); + let luminance = + 0.2126 * background[0] + 0.7152 * background[1] + 0.0722 * background[2]; + let surface_tint = if luminance > 0.5 { 0.0 } else { 1.0 }; let panel = [ - (background[0] * 0.92).min(1.0), - (background[1] * 0.92).min(1.0), - (background[2] * 0.92).min(1.0), + background[0] * 0.965 + surface_tint * 0.035, + background[1] * 0.965 + surface_tint * 0.035, + background[2] * 0.965 + surface_tint * 0.035, 1.0, ]; let selected = [ @@ -341,6 +347,10 @@ impl Renderer { color: foreground, ..DrawOpts::default() }; + let active_row_opts = DrawOpts { + bold: true, + ..row_opts + }; let muted_opts = DrawOpts { font_size: 11.0, color: muted, @@ -357,15 +367,45 @@ impl Renderer { sugarloaf.rect(None, 0.0, 0.0, width, height, panel, 0.0, 30); sugarloaf.line(width - 1.0, 0.0, width - 1.0, height, 1.0, 0.0, divider, 31); + let add_x = width - 36.0; + let header_width = (add_x - title_x - 8.0).max(0.0); + let header = { + let ui = sugarloaf.text_mut(); + elide_tail("Workspaces", header_width, |text| { + ui.measure(text, &title_opts) + }) + }; sugarloaf .text_mut() - .draw(title_x, 17.0, "Workspaces", &title_opts); - sugarloaf - .text_mut() - .draw(width - 30.0, 17.0, "+", &title_opts); + .draw(title_x, 17.0, &header, &title_opts); + + let button_fill = if luminance > 0.5 { + [0.0, 0.0, 0.0, 0.06] + } else { + [1.0, 1.0, 1.0, 0.08] + }; + sugarloaf.rounded_rect(None, add_x, 10.0, 26.0, 26.0, button_fill, 0.0, 6.0, 32); + let plus = [ + self.named_colors.foreground[0], + self.named_colors.foreground[1], + self.named_colors.foreground[2], + 0.82, + ]; + let add_center_x = add_x + 13.0; + sugarloaf.line( + add_center_x - 4.0, + 23.0, + add_center_x + 4.0, + 23.0, + 1.25, + 0.0, + plus, + 33, + ); + sugarloaf.line(add_center_x, 19.0, add_center_x, 27.0, 1.25, 0.0, plus, 33); for index in 0..context_manager.workspace_count() { - let y = 51.0 + index as f32 * 48.0; + let y = DRAWER_ROW_TOP + index as f32 * DRAWER_ROW_STRIDE; let active = index == context_manager.active_workspace(); if active { sugarloaf.rounded_rect( @@ -373,7 +413,7 @@ impl Renderer { 8.0, y, width - 16.0, - 42.0, + DRAWER_ROW_HEIGHT, selected, 0.0, 6.0, @@ -381,7 +421,9 @@ impl Renderer { ); } - let name = context_manager.workspace_name(index).unwrap_or("Workspace"); + let name = context_manager + .workspace_name(index) + .unwrap_or_else(|| String::from("Workspace")); let tab_count = context_manager.workspace_tab_count(index); let label = format!( "{}{}", @@ -392,7 +434,15 @@ impl Renderer { }, name ); - sugarloaf.text_mut().draw(18.0, y + 8.0, &label, &row_opts); + let label = { + let ui = sugarloaf.text_mut(); + let opts = if active { &active_row_opts } else { &row_opts }; + elide_tail(&label, (width - 36.0).max(0.0), |text| { + ui.measure(text, opts) + }) + }; + let opts = if active { &active_row_opts } else { &row_opts }; + sugarloaf.text_mut().draw(18.0, y + 8.0, &label, opts); let count = format!( "{tab_count} {}", if tab_count == 1 { "tab" } else { "tabs" } @@ -405,9 +455,9 @@ impl Renderer { // The handle remains a small, quiet hit target at the drawer edge. sugarloaf.rect( None, - width - 4.0, + width - DRAWER_RESIZE_HIT_HALF_WIDTH, 0.0, - 8.0, + DRAWER_RESIZE_HIT_HALF_WIDTH * 2.0, height, [0.0, 0.0, 0.0, 0.001], 0.0, diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index e21f7327c4..b73053e1ed 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -30,7 +30,11 @@ use crate::renderer::island::{self, TabStripLayout, ISLAND_HEIGHT}; use crate::renderer::{utils::padding_top_from_config, Renderer}; use crate::screen::hint::HintMatches; use crate::selection::{Selection, SelectionType}; -use crate::workspace::{DEFAULT_DRAWER_WIDTH, MAX_DRAWER_WIDTH, MIN_DRAWER_WIDTH}; +use crate::workspace::{ + DEFAULT_DRAWER_WIDTH, DRAWER_ADD_HIT_WIDTH, DRAWER_HEADER_HEIGHT, + DRAWER_RESIZE_HIT_HALF_WIDTH, DRAWER_ROW_HEIGHT, DRAWER_ROW_STRIDE, DRAWER_ROW_TOP, + MAX_DRAWER_WIDTH, MIN_DRAWER_WIDTH, +}; use core::fmt::Debug; use raw_window_handle::{RawDisplayHandle, RawWindowHandle}; use rio_backend::clipboard::Clipboard; @@ -2625,25 +2629,32 @@ impl Screen<'_> { let x = self.mouse.x as f32 / scale; let y = self.mouse.y as f32 / scale; let width = self.context_manager.drawer_width(); - if x > width { + if x > width + DRAWER_RESIZE_HIT_HALF_WIDTH { return false; } self.workspace_consumed = true; - if x >= width - 10.0 { + if x >= width - DRAWER_RESIZE_HIT_HALF_WIDTH { self.workspace_dragging = true; return true; } - if y < 48.0 { - if x >= width - 52.0 { + if y < DRAWER_HEADER_HEIGHT { + if x >= width - DRAWER_ADD_HIT_WIDTH { self.create_workspace(clipboard); } return true; } - let row = ((y - 51.0) / 48.0).floor() as usize; + if y < DRAWER_ROW_TOP { + return true; + } + let row_offset = y - DRAWER_ROW_TOP; + let row = (row_offset / DRAWER_ROW_STRIDE).floor() as usize; + if row_offset % DRAWER_ROW_STRIDE > DRAWER_ROW_HEIGHT { + return true; + } if row < self.context_manager.workspace_count() { let old_index = self.context_manager.current_index(); if let Some(new_index) = self.context_manager.select_workspace(row) { @@ -2694,6 +2705,32 @@ impl Screen<'_> { self.workspace_dragging } + pub fn workspace_cursor_icon(&self) -> Option { + let scale = self.sugarloaf.scale_factor(); + let x = self.mouse.x as f32 / scale; + let y = self.mouse.y as f32 / scale; + let width = self.context_manager.drawer_width(); + if x > width + DRAWER_RESIZE_HIT_HALF_WIDTH { + return None; + } + if x >= width - DRAWER_RESIZE_HIT_HALF_WIDTH { + return Some(CursorIcon::ColResize); + } + if y < DRAWER_HEADER_HEIGHT && x >= width - DRAWER_ADD_HIT_WIDTH { + return Some(CursorIcon::Pointer); + } + if y >= DRAWER_ROW_TOP { + let row_offset = y - DRAWER_ROW_TOP; + let row = (row_offset / DRAWER_ROW_STRIDE).floor() as usize; + if row < self.context_manager.workspace_count() + && row_offset % DRAWER_ROW_STRIDE <= DRAWER_ROW_HEIGHT + { + return Some(CursorIcon::Pointer); + } + } + Some(CursorIcon::Default) + } + pub fn finish_workspace_drag(&mut self) -> bool { let was_dragging = self.workspace_consumed; self.workspace_dragging = false; @@ -2976,6 +3013,7 @@ impl Screen<'_> { self.sugarloaf.scale_factor(), num_tabs, max_tab_width, + self.context_manager.drawer_width(), ) } diff --git a/frontends/rioterm/src/workspace.rs b/frontends/rioterm/src/workspace.rs index badea9b821..7e82eda6ee 100644 --- a/frontends/rioterm/src/workspace.rs +++ b/frontends/rioterm/src/workspace.rs @@ -5,11 +5,77 @@ use std::path::{Path, PathBuf}; pub const DEFAULT_DRAWER_WIDTH: f32 = 220.0; pub const MIN_DRAWER_WIDTH: f32 = 160.0; pub const MAX_DRAWER_WIDTH: f32 = 420.0; +pub const DRAWER_HEADER_HEIGHT: f32 = 48.0; +pub const DRAWER_ROW_TOP: f32 = 51.0; +pub const DRAWER_ROW_HEIGHT: f32 = 42.0; +pub const DRAWER_ROW_STRIDE: f32 = 48.0; +pub const DRAWER_RESIZE_HIT_HALF_WIDTH: f32 = 4.0; +pub const DRAWER_ADD_HIT_WIDTH: f32 = 44.0; const DEFAULT_WORKSPACE_NAME: &str = "Main"; const PERSISTED_STATE_VERSION: u32 = 1; const MAX_RESTORED_TABS: usize = 28; +fn standardized_path(path: &Path) -> PathBuf { + use std::path::Component; + + let mut standardized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if matches!( + standardized.components().next_back(), + Some(Component::Normal(_)) + ) { + standardized.pop(); + } else if !path.is_absolute() { + standardized.push(component.as_os_str()); + } + } + _ => standardized.push(component.as_os_str()), + } + } + standardized +} + +/// Match simple-cmux's tab label: once OSC 7 reports a directory, show its +/// last path component and keep the full path only for roots or other paths +/// without a terminal component. +pub(crate) fn tab_title_for_directory(directory: &Path) -> String { + let directory = standardized_path(directory); + directory + .file_name() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| directory.as_os_str()) + .to_string_lossy() + .into_owned() +} + +/// Match simple-cmux's workspace label. A workspace rooted under the user's +/// home is named for the first directory below home; other paths use their +/// last component. The caller supplies the first tab's directory. +pub(crate) fn workspace_title_for_directory( + directory: &Path, + home: Option<&Path>, +) -> String { + let directory = standardized_path(directory); + let home = home.map(standardized_path); + if let Some(relative) = home + .as_deref() + .and_then(|home| directory.strip_prefix(home).ok()) + { + if relative.as_os_str().is_empty() { + return String::from("~"); + } + if let Some(component) = relative.components().next() { + return component.as_os_str().to_string_lossy().into_owned(); + } + } + + tab_title_for_directory(&directory) +} + #[derive(Debug, Clone)] pub struct Workspace { pub name: String, @@ -352,7 +418,52 @@ impl Default for WorkspaceManager { #[cfg(test)] mod tests { - use super::WorkspaceManager; + use super::{ + standardized_path, tab_title_for_directory, workspace_title_for_directory, + WorkspaceManager, + }; + use std::path::Path; + + #[test] + fn tab_titles_use_the_last_pwd_component() { + assert_eq!( + tab_title_for_directory(Path::new("/Users/aa/os/rio")), + "rio" + ); + assert_eq!(tab_title_for_directory(Path::new("/")), "/"); + assert_eq!( + tab_title_for_directory(Path::new("/Users/aa/os/../rio")), + "rio" + ); + assert_eq!( + standardized_path(Path::new("../../rio")), + Path::new("../../rio") + ); + } + + #[test] + fn workspace_titles_use_the_first_directory_below_home() { + let home = Path::new("/Users/aa"); + assert_eq!( + workspace_title_for_directory(Path::new("/Users/aa/os/rio"), Some(home)), + "os" + ); + assert_eq!( + workspace_title_for_directory(Path::new("/Users/aa"), Some(home)), + "~" + ); + assert_eq!( + workspace_title_for_directory(Path::new("/tmp/rio"), Some(home)), + "rio" + ); + assert_eq!( + workspace_title_for_directory( + Path::new("/Users/aa/os/../rio/project"), + Some(home) + ), + "rio" + ); + } #[test] fn removing_a_tab_keeps_workspace_indices_consistent() { From 29af3638ec02d1a42303c8de02a0dcb816608f21 Mon Sep 17 00:00:00 2001 From: aa Date: Tue, 22 Sep 2026 15:55:14 -0700 Subject: [PATCH 6/7] makefile --- Makefile | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Makefile b/Makefile index 27f3285806..c13aa62997 100644 --- a/Makefile +++ b/Makefile @@ -54,6 +54,16 @@ $(TARGET)-universal: @lipo target/{x86_64,aarch64}-apple-darwin/release/$(TARGET) -create -output $(APP_BINARY) app-universal: $(APP_NAME)-universal ## Create a universal Rio.app + +app-aarch64: ## Create an Apple Silicon-only Rio.app + RUSTFLAGS='-C link-arg=-s' MACOSX_DEPLOYMENT_TARGET="11.0" cargo build --release --target=aarch64-apple-darwin + @mkdir -p $(APP_BINARY_DIR) + @mkdir -p $(APP_EXTRAS_DIR) + @cp -fRp $(APP_TEMPLATE) $(TARGET_DIR_OSX) + @cp -fp target/aarch64-apple-darwin/release/$(TARGET) $(APP_BINARY_DIR) + @touch -r "target/aarch64-apple-darwin/release/$(TARGET)" "$(TARGET_DIR_OSX)/$(APP_NAME)" + @echo "Created '$(APP_NAME)' in '$(TARGET_DIR_OSX)'" + $(APP_NAME)-%: $(TARGET)-% @mkdir -p $(APP_BINARY_DIR) @mkdir -p $(APP_EXTRAS_DIR) From 42db665321f35e20cf15400672d88d14e17d3eae Mon Sep 17 00:00:00 2001 From: aa Date: Tue, 22 Sep 2026 16:13:58 -0700 Subject: [PATCH 7/7] windows uses different slash gnr --- frontends/rioterm/src/workspace.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontends/rioterm/src/workspace.rs b/frontends/rioterm/src/workspace.rs index 7e82eda6ee..5d22310457 100644 --- a/frontends/rioterm/src/workspace.rs +++ b/frontends/rioterm/src/workspace.rs @@ -422,7 +422,7 @@ mod tests { standardized_path, tab_title_for_directory, workspace_title_for_directory, WorkspaceManager, }; - use std::path::Path; + use std::path::{Path, MAIN_SEPARATOR_STR}; #[test] fn tab_titles_use_the_last_pwd_component() { @@ -430,7 +430,10 @@ mod tests { tab_title_for_directory(Path::new("/Users/aa/os/rio")), "rio" ); - assert_eq!(tab_title_for_directory(Path::new("/")), "/"); + assert_eq!( + tab_title_for_directory(Path::new(MAIN_SEPARATOR_STR)), + MAIN_SEPARATOR_STR + ); assert_eq!( tab_title_for_directory(Path::new("/Users/aa/os/../rio")), "rio"