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/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) 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 82ef3f2ae8..f18469f9dd 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(); @@ -1782,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(); } @@ -2332,6 +2372,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 @@ -2401,6 +2445,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 1a2785a9d4..f9bcb7ace0 100644 --- a/frontends/rioterm/src/context/mod.rs +++ b/frontends/rioterm/src/context/mod.rs @@ -8,6 +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::{ + tab_title_for_directory, workspace_title_for_directory, PersistedTab, + WorkspaceManager, +}; use renderable::Cursor; use renderable::RenderableContent; use rio_backend::config::layout::Margin; @@ -21,10 +25,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); @@ -32,6 +36,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) @@ -141,6 +150,11 @@ pub struct ContextManager { event_proxy: T, window_id: WindowId, 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, @@ -418,13 +432,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) => { @@ -479,7 +514,44 @@ impl ContextManager { event_proxy, window_id, config: ctx_config, + 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. @@ -521,6 +593,11 @@ impl ContextManager { event_proxy, window_id, config, + workspaces: WorkspaceManager::new(), + base_scaled_margin: Margin::default(), + persistence_enabled: false, + last_saved_state: None, + persistence_ready_at: None, }) } @@ -555,6 +632,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 +649,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 } @@ -663,7 +746,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 { @@ -678,7 +761,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(); @@ -716,6 +799,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); } @@ -762,7 +849,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] @@ -781,6 +874,145 @@ 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 { + 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] + pub fn workspace_tab_count(&self, index: usize) -> usize { + 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 + .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) + } + + /// 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() + } + + 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) @@ -919,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 @@ -963,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() { @@ -982,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) @@ -1106,11 +1351,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); } @@ -1168,6 +1419,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(); @@ -1177,8 +1470,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.select_tab(target_index); + self.swap_current_tab_with(target_index); } #[inline] @@ -1190,7 +1482,17 @@ 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); } @@ -1207,6 +1509,8 @@ 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); } @@ -1361,10 +1665,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(); @@ -1374,6 +1695,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(); @@ -1403,16 +1728,21 @@ impl ContextManager { self.config.split_active_color, self.config.panel, )); + 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 @@ -1662,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/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/island.rs b/frontends/rioterm/src/renderer/island.rs index 00a2ba717d..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] @@ -563,25 +567,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 +612,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 +639,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 +675,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 +789,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 @@ -795,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, @@ -818,7 +868,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 +1117,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 +1240,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 +1256,20 @@ 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, + context_manager.drawer_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; @@ -1505,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] @@ -1546,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, @@ -1824,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); @@ -1838,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. @@ -1858,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. @@ -1892,7 +1968,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 62f7c11145..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}, @@ -287,6 +290,181 @@ 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 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.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 = [ + 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 active_row_opts = DrawOpts { + bold: true, + ..row_opts + }; + let muted_opts = DrawOpts { + font_size: 11.0, + color: muted, + ..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); + 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, &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 = DRAWER_ROW_TOP + index as f32 * DRAWER_ROW_STRIDE; + let active = index == context_manager.active_workspace(); + if active { + sugarloaf.rounded_rect( + None, + 8.0, + y, + width - 16.0, + DRAWER_ROW_HEIGHT, + selected, + 0.0, + 6.0, + 32, + ); + } + + 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!( + "{}{}", + if context_manager.workspace_has_bell(index) { + "• " + } else { + "" + }, + name + ); + 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" } + ); + 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 - DRAWER_RESIZE_HIT_HALF_WIDTH, + 0.0, + DRAWER_RESIZE_HIT_HALF_WIDTH * 2.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 @@ -820,10 +998,13 @@ impl Renderer { sugarloaf, (window_size.width, window_size.height, scale_factor), context_manager, + context_manager.active_workspace_tab_indices(), island_bg, ); } + 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..b73053e1ed 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -30,6 +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, 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; @@ -99,6 +104,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 +306,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 +319,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 +363,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 +728,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); @@ -1523,8 +1540,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, @@ -1550,7 +1574,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, @@ -1562,8 +1586,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, @@ -1571,17 +1606,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, @@ -1589,9 +1639,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(); } @@ -1599,7 +1654,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, @@ -1734,6 +1789,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 +2617,127 @@ 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 + DRAWER_RESIZE_HIT_HALF_WIDTH { + return false; + } + + self.workspace_consumed = true; + + if x >= width - DRAWER_RESIZE_HIT_HALF_WIDTH { + self.workspace_dragging = true; + return true; + } + + if y < DRAWER_HEADER_HEIGHT { + if x >= width - DRAWER_ADD_HIT_WIDTH { + self.create_workspace(clipboard); + } + return true; + } + + 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) { + 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 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; + 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() { @@ -2833,6 +3013,7 @@ impl Screen<'_> { self.sugarloaf.scale_factor(), num_tabs, max_tab_width, + self.context_manager.drawer_width(), ) } @@ -2892,15 +3073,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, ); @@ -2931,7 +3117,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 { @@ -2941,7 +3128,7 @@ impl Screen<'_> { mouse_y as f32, scale_factor, window_width, - num_tabs, + &tab_indices, &mut self.context_manager, ); if consumed { @@ -3001,7 +3188,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, ) { @@ -3022,7 +3212,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() { @@ -3053,7 +3244,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)); @@ -3066,7 +3257,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, @@ -3090,7 +3281,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, @@ -3103,7 +3295,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 { @@ -3138,8 +3331,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(); @@ -3149,14 +3343,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 new file mode 100644 index 0000000000..5d22310457 --- /dev/null +++ b/frontends/rioterm/src/workspace.rs @@ -0,0 +1,515 @@ +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; +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, + pub tabs: Vec, + selected_tab: usize, +} + +#[derive(Debug, Clone)] +pub struct WorkspaceManager { + workspaces: Vec, + active: usize, + 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 { + workspaces: vec![Workspace { + name: DEFAULT_WORKSPACE_NAME.to_string(), + tabs: vec![0], + selected_tab: 0, + }], + active: 0, + drawer_width: DEFAULT_DRAWER_WIDTH, + } + } + + 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 + } + + #[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 + } + + 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() + } + + #[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; + } + } + workspace.tabs.sort_unstable(); + } + } + + 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); + } + workspace.tabs.sort_unstable(); + } + } + + 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::{ + standardized_path, tab_title_for_directory, workspace_title_for_directory, + WorkspaceManager, + }; + use std::path::{Path, MAIN_SEPARATOR_STR}; + + #[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(MAIN_SEPARATOR_STR)), + MAIN_SEPARATOR_STR + ); + 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() { + 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 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(); + manager.add_tab(1); + manager.add_tab(2); + manager.select_tab(0); + manager.move_tab(0, 2); + assert_eq!(manager.tab_indices(0), &[0, 1, 2]); + assert_eq!(manager.select(0), Some(2)); + } +} 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:)]