diff --git a/CHANGELOG.md b/CHANGELOG.md index cf81ddd6..425504e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Documents dock beside the terminal** (#625). Opening a file, toggling the + code panel or opening a diff no longer covers the workspace: the document + takes a column to the right of the terminal — half the space between the + sidebar and the right panel by default — and the pane you were reading stays + visible and typeable underneath none of it. Reviewing a file while an agent + talks stopped being a toggle loop. Drag the divider for any width, double-click + it to cycle a third, a half and two thirds, or use **Document: Third / Half / + Two-Thirds Width** in the palette. Right-click the document's header for + **Fill window** — the old overlay, unchanged, and per tab, so a file read + over the whole window in one tab leaves the agent beside its own in the next. + The terminal keeps its floor through all of it, and a window too narrow to + seat both fills for that file only, without changing what any tab chose. New + in `config.json`: `document_ratio`, and `document_layout` for what a fresh + tab starts as. + - **A tab can be dropped into another tab, as a pane of it** (#621). Drag a tab by its chip or by its sidebar row, out over the panes, and it lands where the highlight says — the same reading as dragging a pane, minus the middle, which diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index ef6309ad..e896130f 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -183,6 +183,19 @@ pub struct Config { /// `diffEditor.renderSideBySide` makes. #[serde(default, deserialize_with = "de_lenient")] pub diff_view: DiffViewMode, + /// How the code / diff surface shares the window with the terminal. Global + /// for the same reason `diff_view` is: someone who wants documents beside + /// the terminal wants that in every workspace, not once per tab. + #[serde(default, deserialize_with = "de_lenient")] + pub document_layout: DocumentLayout, + /// The share of the terminal column — the flex area between the sidebar and + /// the right panel — the document column takes when docked. The named + /// widths land on a third, a half and two thirds; a drag leaves whatever it + /// leaves. Live layout narrows this further when the terminal's floor needs + /// the width, so the bounds here only have to keep a hand-written config + /// from hiding one side or the other outright. + #[serde(default = "default_document_ratio")] + pub document_ratio: f32, /// The source control panel's history section starts collapsed: a graph /// unfurling the first time someone opens the panel is a worse first /// impression than one they asked for. @@ -586,6 +599,8 @@ impl Default for Config { right_panel_width: default_right_panel_width(), right_panel_tab: RightPanelTab::Info, diff_view: DiffViewMode::Split, + document_layout: DocumentLayout::default(), + document_ratio: default_document_ratio(), scm_graph_expanded: false, sidebar_grouping: SidebarGrouping::Repo, sidebar_diff_preview: true, @@ -759,6 +774,10 @@ impl Config { self.right_panel_width = default_right_panel_width(); } self.right_panel_width = self.right_panel_width.clamp(100.0, 2000.0); + if !self.document_ratio.is_finite() || self.document_ratio <= 0.0 { + self.document_ratio = default_document_ratio(); + } + self.document_ratio = self.document_ratio.clamp(0.2, 0.8); if let Some(command) = &self.link_file_command && command.trim().is_empty() { @@ -1073,6 +1092,36 @@ fn default_right_panel_width() -> f32 { 260. } +/// Where the code / diff surface is drawn. +/// +/// It used to be one thing — a full-workspace overlay — so there was nothing to +/// name. Docking it beside the terminal is the default now: opening a file to +/// read it while an agent talks underneath was the reason the built-in editor +/// exists, and an overlay covers the agent. `Fill` is that overlay, kept for +/// anyone who wants the whole window for the file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DocumentLayout { + #[default] + Dock, + Fill, +} + +fn default_document_ratio() -> f32 { + 0.5 +} + +/// The named shares of the terminal column a document column can be snapped to, +/// in the order the segmented control and the divider's double-click cycle use. +pub const DOCUMENT_RATIO_THIRD: f32 = 1. / 3.; +pub const DOCUMENT_RATIO_HALF: f32 = 0.5; +pub const DOCUMENT_RATIO_TWO_THIRDS: f32 = 2. / 3.; +pub const DOCUMENT_RATIO_STOPS: [f32; 3] = [ + DOCUMENT_RATIO_THIRD, + DOCUMENT_RATIO_HALF, + DOCUMENT_RATIO_TWO_THIRDS, +]; + /// The rem the chrome has always been laid out against — gpui's own default, /// which is what `text_sm()` and `text_xs()` resolve 14px and 12px from. Left /// alone, the interface looks exactly as it did before the size was settable. diff --git a/docs/git/diffs.mdx b/docs/git/diffs.mdx index 836d825a..8915db72 100644 --- a/docs/git/diffs.mdx +++ b/docs/git/diffs.mdx @@ -11,7 +11,10 @@ description: "The diff overlay: side-by-side or unified, from the sidebar or the | Source Control | **Open Changes** on a file, or click the row | | History | Click a file inside a commit's detail view | -The overlay covers the window; Esc closes it. +A diff docks beside the terminal, in the same column an open file uses, so the +pane you were reading stays on screen. Esc closes it, and its header +right-clicks to a menu that fills the window with it if you prefer that — +[more about the column →](/window/side-panel#where-it-opens) The tty7 diff overlay diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index 3ff0d300..8e32b60e 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -77,6 +77,8 @@ their id from the file name. [More about themes →](/customization/themes) | `right_panel_width` | number | `260` | Pixels (100–2000). | | `right_panel_tab` | enum | `"info"` | `info`, `changes`, `files`. | | `diff_view` | enum | `"split"` | Or `unified`. Global, not per file. | +| `document_layout` | enum | `"dock"` | Where an open file or diff is drawn: `dock` beside the terminal, or `fill` over the workspace. What a fresh tab starts as — each tab keeps its own from there. | +| `document_ratio` | number | `0.5` | The docked column’s share of the terminal column (0.2–0.8). Named widths are `0.333`, `0.5`, `0.667`. | | `scm_graph_expanded` | bool | `false` | Whether the history section starts open. | | `show_tray_icon` | bool | `true` | The tray / menu bar status item. | diff --git a/docs/window/side-panel.mdx b/docs/window/side-panel.mdx index 71277d56..70b09eeb 100644 --- a/docs/window/side-panel.mdx +++ b/docs/window/side-panel.mdx @@ -75,3 +75,27 @@ have opened `vim` for. Files are watched on disk: a change underneath you is picked up, and closing with unsaved edits asks before discarding them. Files over 4 MB and anything that looks binary are refused with a note rather than opened badly. + +### Where it opens + +The editor docks beside the terminal, taking half the space between the sidebar +and the right panel. The terminal keeps running, stays visible, and stays +typeable — click it, read what your agent said, click back. Diffs open in the +same column. + +Drag the divider for any width; double-click it to cycle a third, a half and +two thirds. **Document: Third / Half / Two-Thirds Width** in the command palette +do the same. + +Right-click the document's header for **Fill window**, which is the old +full-workspace overlay, unchanged. **Document: Fill Window** and **Document: +Dock Beside Terminal** in the palette are the same switch. + +Fill or dock is **per tab**: read a long file over the whole window in one tab +while an agent keeps half of another, and neither moves the other. A fresh tab +starts from `document_layout` in `config.json`. The width is shared, and +persists as `document_ratio`. + +A window too narrow to give both the terminal and the document a readable width +fills for that file only — widen it and the column comes back, without your +setting having changed. diff --git a/src/core/actions.rs b/src/core/actions.rs index d4689f7b..09604411 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -102,6 +102,10 @@ actions!( ToggleSftp, ShowSshForwards, ToggleCodePanel, + ToggleDocumentFill, + DocumentWidthThird, + DocumentWidthHalf, + DocumentWidthTwoThirds, EditorSave, OpenSshProfiles, RestartSshSession, diff --git a/src/ui/app.rs b/src/ui/app.rs index eb2c7fc6..a3c6aa7b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -175,10 +175,12 @@ pub(crate) const HOME_CURSOR_BLINK_MS: u64 = 600; /// dropping it would have let a panel grow past where it could before under /// cover of a change that is only meant to take width away from panels. /// -/// `other_floor` is zero when the other panel is closed, which is why this -/// takes floors rather than reading them: only the caller knows what is up. -pub(crate) fn side_panel_max(viewport: f32, own_floor: f32, other_floor: f32) -> f32 { - (viewport - TERMINAL_MIN_W - other_floor) +/// `others_floor` is the sum of the floors under every *other* column that is +/// open — the panel opposite, and the document column when a file or a diff is +/// docked. It is zero for each of them that is closed, which is why this takes +/// floors rather than reading them: only the caller knows what is up. +pub(crate) fn side_panel_max(viewport: f32, own_floor: f32, others_floor: f32) -> f32 { + (viewport - TERMINAL_MIN_W - others_floor) .min(viewport * SIDE_PANEL_MAX_RATIO) .max(own_floor) } @@ -186,6 +188,35 @@ pub(crate) fn side_panel_max(viewport: f32, own_floor: f32, other_floor: f32) -> /// The half of the window neither panel may grow past on its own. const SIDE_PANEL_MAX_RATIO: f32 = 0.5; +/// The narrowest a docked document column may be squeezed to: the header, a +/// readable run of about thirty columns, and the status bar under them. Below +/// this a file is a ribbon of hyphenated fragments and the column is worth +/// less than the terminal width it costs. +pub(crate) const DOCUMENT_MIN_W: f32 = 280.; + +/// How wide the docked document column is, given the width the terminal and the +/// document share — the window less the sidebar and the right panel — and the +/// share of it the user asked for. +/// +/// `None` is the narrow-window answer: there is no way to give both the +/// terminal and a document a width worth reading, so the caller falls back to +/// filling the workspace for this frame. That fallback is *derived*, never +/// stored — widening the window docks again on the next frame, and the user's +/// saved `document_layout` is untouched throughout. +/// +/// The named two-thirds deliberately runs past the half-window cap the side +/// panels obey. That cap is there so neither *panel* can dominate a wide +/// display; the document is the thing the user is reading, and an increment +/// that silently became a half on every window wider than about 720 points of +/// body would be a lie. The terminal's floor still binds. +pub(crate) fn document_column_px(body: f32, ratio: f32) -> Option { + if !body.is_finite() || body < TERMINAL_MIN_W + DOCUMENT_MIN_W { + return None; + } + let ratio = if ratio.is_finite() { ratio } else { 0.5 }; + Some((body * ratio).clamp(DOCUMENT_MIN_W, body - TERMINAL_MIN_W)) +} + pub(crate) const TITLE_BAR_HEIGHT: f32 = 40.; pub(crate) const TILE_SIZE: f32 = 32.; @@ -353,6 +384,16 @@ pub struct Tab { pub(crate) code: Option>, pub(crate) sidebar_group: std::cell::RefCell>, pub(crate) overlay_top: OverlayTop, + /// Whether this tab's document fills the workspace or docks beside the + /// terminal, once the tab has been told. `None` follows `document_layout` + /// in the config, which is the default a fresh tab starts from and the + /// last explicit choice anyone made. + /// + /// Per tab rather than per window because what you are doing differs per + /// tab: reading a long file in one while an agent works in another wants + /// the whole window here and half of it there, and a global switch made + /// each of those flip the other. + pub(crate) document_layout: Option, pub(crate) tree_id: std::cell::Cell, /// Monotonic stamp of when this tab was last activated, used to order the /// switcher's tab column most-recently-used first. Zero means never. @@ -367,7 +408,7 @@ pub(crate) enum OverlayTop { } impl Tab { - fn new(pane: Pane) -> Self { + pub(crate) fn new(pane: Pane) -> Self { Self { pane, name: None, @@ -376,6 +417,7 @@ impl Tab { diff_overlay: None, code: None, overlay_top: OverlayTop::default(), + document_layout: None, sidebar_group: std::cell::RefCell::new(None), tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), @@ -391,6 +433,7 @@ impl Tab { diff_overlay: None, code: None, overlay_top: OverlayTop::default(), + document_layout: None, sidebar_group: std::cell::RefCell::new( tree.sidebar_group.clone().map(std::path::PathBuf::from), ), @@ -636,6 +679,12 @@ pub struct Tty7App { pub(crate) settings_hit_anchored: Cell, pub(crate) right_panel_width: Rc>, pub(crate) right_panel_dragging: Rc>, + /// The docked document column's share of the terminal column, live. Held + /// beside the config value rather than in it for the same reason the two + /// panel widths are: a drag writes this cell on every mouse move and the + /// config once, on mouse up. + pub(crate) document_ratio: Rc>, + pub(crate) document_dragging: Rc>, pub(crate) right_panel_visible: bool, pub(crate) right_panel_tab: RightPanelTab, pub(crate) sidebar_collapsed: bool, @@ -1057,6 +1106,7 @@ impl Tty7App { }); let sidebar_width = cx.global::().sidebar_width; let right_panel_width = cx.global::().right_panel_width; + let document_ratio = cx.global::().document_ratio; let right_panel_visible = cx.global::().right_panel_visible; let right_panel_tab = cx.global::().right_panel_tab; let scm_graph_expanded = cx.global::().scm_graph_expanded; @@ -1227,6 +1277,8 @@ impl Tty7App { settings_hit_anchored: Cell::new(false), right_panel_width: Rc::new(Cell::new(right_panel_width)), right_panel_dragging: Rc::new(Cell::new(false)), + document_ratio: Rc::new(Cell::new(document_ratio)), + document_dragging: Rc::new(Cell::new(false)), right_panel_visible, right_panel_tab, sidebar_collapsed, @@ -1550,6 +1602,7 @@ impl Tty7App { diff_overlay: None, code: None, overlay_top: OverlayTop::default(), + document_layout: None, sidebar_group: std::cell::RefCell::new(st.sidebar_group), tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), @@ -4878,6 +4931,16 @@ impl Tty7App { ToggleSftp => self.toggle_sftp(window, cx), ShowSshForwards => self.show_ssh_forwards(window, cx), ToggleCodePanel => self.toggle_code_panel(window, cx), + ToggleDocumentFill => self.toggle_document_fill(cx), + DocumentWidthThird => { + self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_THIRD, cx) + } + DocumentWidthHalf => { + self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_HALF, cx) + } + DocumentWidthTwoThirds => { + self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_TWO_THIRDS, cx) + } RestartSshSession => self.restart_ssh_session(window, cx), SetTheme(i) => { if let Some(id) = crate::ui::presets::all(cx).get(i).map(|t| t.id.clone()) { @@ -5735,6 +5798,8 @@ impl Tty7App { self.sidebar_width.set(cx.global::().sidebar_width); self.right_panel_width .set(cx.global::().right_panel_width); + self.document_ratio + .set(cx.global::().document_ratio); if font_size != self.font_size { self.font_size = font_size; let px_size = px(font_size); @@ -6972,27 +7037,72 @@ impl Render for Tty7App { this.child(el) }); - let diff_overlay = self.render_diff_overlay(window, cx); - - let code_overlay = self.render_code_overlay(window, cx); - - let overlays: Vec = { - let mut pair = vec![ - (OverlayTop::Diff, diff_overlay), - (OverlayTop::Code, code_overlay), - ]; - if self - .tabs - .get(self.active) - .is_some_and(|t| t.overlay_top == OverlayTop::Diff) - { - pair.reverse(); - } - pair.into_iter().filter_map(|(_, el)| el).collect() + // One decision for the whole document surface. Docked, exactly one of + // the two surfaces is drawn — a column has one child, and two `flex_1` + // siblings would split it and fight — so `overlay_top` stops ordering a + // pair and starts choosing between them. Filling, nothing changes: both + // are rendered, ordered by `overlay_top`, and the front one wins on + // paint order as it always has. + let document_dock_px = self.document_dock_px(window, cx); + // Where the docked header sits. Everywhere but macOS the title bar + // spans the workspace and leaves the strip above the column empty, so + // the header goes up into it; on macOS the column already reaches the + // top of the window and its own first row lands there. + let document_chrome = if cfg!(target_os = "macos") { + crate::ui::document_column::DocumentChrome::Dock + } else { + crate::ui::document_column::DocumentChrome::DockHoisted }; + let document_header = document_dock_px + .is_some() + .then(|| self.render_document_header(document_chrome, window, cx)) + .flatten(); + let (overlays, document_column) = match document_dock_px { + Some(w) => ( + Vec::new(), + self.render_document_column(w, document_chrome, window, cx), + ), + None => { + let diff_overlay = self.render_diff_overlay( + crate::ui::document_column::DocumentChrome::Fill, + window, + cx, + ); + let code_overlay = self.render_code_overlay( + crate::ui::document_column::DocumentChrome::Fill, + window, + cx, + ); + let mut pair = vec![ + (OverlayTop::Diff, diff_overlay), + (OverlayTop::Code, code_overlay), + ]; + if self + .tabs + .get(self.active) + .is_some_and(|t| t.overlay_top == OverlayTop::Diff) + { + pair.reverse(); + } + ( + pair.into_iter() + .filter_map(|(_, el)| el) + .collect::>(), + None, + ) + } + }; + let document_px = document_column + .as_ref() + .map_or(0., |_| document_dock_px.unwrap_or_default()); let right_panel = self.render_right_panel(window, cx); - let panel_below_title_bar = right_panel.is_some() && !cfg!(target_os = "macos"); + // A docked document takes the same fork the right panel does: on + // Windows and Linux the window controls live at the right end of the + // title bar, so the bar has to span the workspace rather than sit + // inside the terminal column with a column drawn to the right of it. + let panel_below_title_bar = + (right_panel.is_some() || document_column.is_some()) && !cfg!(target_os = "macos"); let (column_title_bar, spanning_title_bar) = if panel_below_title_bar { (None, Some(title_bar)) } else { @@ -7003,7 +7113,11 @@ impl Render for Tty7App { } else { (overlays, Vec::new()) }; - let panel_px = self.right_panel_px(window, cx); + let panel_px = if right_panel.is_some() { + self.right_panel_px(window, cx) + } else { + 0. + }; let terminal_column = div() .flex_1() .min_w_0() @@ -7020,6 +7134,7 @@ impl Render for Tty7App { .flex() .flex_row() .child(terminal_column) + .when_some(document_column, |this, column| this.child(column)) .when_some(right_panel, |this, panel| this.child(panel)); let main_layout = div() .flex_1() @@ -7039,18 +7154,59 @@ impl Render for Tty7App { div() .relative() .flex_none() - .child( - div() - .absolute() - .top_0() - .bottom_0() - .right_0() - .w(px(self.right_panel_px(window, cx))) - .bg(crate::ui::theme::workspace_surface_color(cx)) - .border_l_1() - .border_color(cx.theme().sidebar_border), - ) - .child(bar), + // One patch per column below, rather than one for + // both: each carries the left border its own column + // carries, so the rule between the document and the + // detail panel runs the full height of the window + // instead of stopping at the title bar. + .when(panel_px > 0., |this| { + this.child( + div() + .absolute() + .top_0() + .bottom_0() + .right_0() + .w(px(panel_px)) + .bg(crate::ui::theme::workspace_surface_color(cx)) + .border_l_1() + .border_color(cx.theme().sidebar_border), + ) + }) + .when(document_px > 0., |this| { + this.child( + div() + .absolute() + .top_0() + .bottom_0() + .right(px(panel_px)) + .w(px(document_px)) + .bg(crate::ui::theme::workspace_surface_color(cx)) + .border_l_1() + .border_color(cx.theme().sidebar_border), + ) + }) + .child(bar) + // The document's header, in the strip the spanning + // title bar leaves empty above its column. Drawn + // after the bar so it sits over the tab strip's + // slack — and stopping short of the trailing + // chrome, which is only in the way when the detail + // panel is closed and this column is the one at the + // window's right edge. + .when_some(document_header, |this, header| { + this.child( + div() + .absolute() + .top_0() + .h(px(TITLE_BAR_HEIGHT)) + .right(px(panel_px)) + .w(px(document_px)) + .when(panel_px <= 0., |d| { + d.pr(px(crate::ui::tab_strip::trailing_chrome_w())) + }) + .child(header), + ) + }), ) .child(panel_row) .children(hoisted_overlays.into_iter().map(|overlay| { @@ -7266,6 +7422,20 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &ToggleDiffViewMode, _window, cx| { this.toggle_diff_view_mode(cx) })) + .on_action(cx.listener(|this, _: &ToggleDocumentFill, _window, cx| { + this.toggle_document_fill(cx) + })) + .on_action(cx.listener(|this, _: &DocumentWidthThird, _window, cx| { + this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_THIRD, cx) + })) + .on_action(cx.listener(|this, _: &DocumentWidthHalf, _window, cx| { + this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_HALF, cx) + })) + .on_action( + cx.listener(|this, _: &DocumentWidthTwoThirds, _window, cx| { + this.set_document_ratio(crate::core::config::DOCUMENT_RATIO_TWO_THIRDS, cx) + }), + ) .on_action(cx.listener(|this, _: &ScmCommit, window, cx| { this.run_scm_action(ScmIntent::Commit, window, cx) })) @@ -7642,6 +7812,7 @@ fn tabs_from_session( diff_overlay: None, code: None, overlay_top: OverlayTop::default(), + document_layout: None, sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()), tree_id: std::cell::Cell::new( st.tree_id @@ -8439,10 +8610,10 @@ mod window_drag_tests { #[cfg(test)] mod tests { use super::{ - CloseReason, TERMINAL_MIN_W, TabAgentSession, clear_window_override_values, close_prompt, - join_shell_args, leaf_shares_the_window_daemon, mru_order, pane_free_for, - parse_ssh_connect_input, parse_ssh_option_words, side_panel_max, split_shell_args, - wd_path_saveable, + CloseReason, DOCUMENT_MIN_W, TERMINAL_MIN_W, TabAgentSession, clear_window_override_values, + close_prompt, document_column_px, join_shell_args, leaf_shares_the_window_daemon, + mru_order, pane_free_for, parse_ssh_connect_input, parse_ssh_option_words, side_panel_max, + split_shell_args, wd_path_saveable, }; const SIDEBAR_MIN: f32 = crate::ui::tab_sidebar::MIN_SIDEBAR_WIDTH; @@ -8468,6 +8639,47 @@ mod tests { assert_eq!(side_panel_max(mid, SIDEBAR_MIN, 0.), mid / 2.); } + /// A docked document is a third column in the same budget, so it has to be + /// reserved by the two panels the way they already reserve each other — + /// otherwise a panel dragged to its old limit takes the width out of the + /// document, which then has nowhere to take it from but the terminal. + #[test] + fn a_docked_document_is_reserved_by_the_panels_too() { + let wide = 1440.; + let max = side_panel_max(wide, PANEL_MIN, SIDEBAR_MIN + DOCUMENT_MIN_W); + assert_eq!( + wide - SIDEBAR_MIN - DOCUMENT_MIN_W - max, + TERMINAL_MIN_W, + "a panel at its cap, with both other columns at their floors, leaves the terminal exactly its floor" + ); + assert!( + max < side_panel_max(wide, PANEL_MIN, SIDEBAR_MIN), + "the reservation only ever takes width away" + ); + } + + /// The floors are not what the panels are actually drawn at. Both are + /// draggable and both persist, so the budget has to be fed the live widths + /// or a widened sidebar is width the terminal silently loses. + #[test] + fn widened_panels_still_leave_the_terminal_its_floor() { + let viewport = 1440.; + // Both dragged well past their floors, and the document asked for the + // widest named share there is. + let body = viewport - 400. - 320.; + let document = document_column_px(body, 2. / 3.).expect("720 points seats both"); + assert!( + body - document >= TERMINAL_MIN_W, + "terminal got {}", + body - document + ); + + // Squeezed further, the document is the one that gives up first — and + // then stops existing rather than dropping under its own floor. + let squeezed = viewport - 600. - 400.; + assert_eq!(document_column_px(squeezed, 0.5), None); + } + /// The reservation must only ever take width away from a panel. On a wide /// window it works out *larger* than the half-window cap that was already /// there, and letting it win would widen the ceiling instead. diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 150b49b4..428714d6 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -9,11 +9,13 @@ use gpui::{ }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{Input, InputEvent, InputState, Position, TabSize}; +use gpui_component::menu::ContextMenuExt as _; use gpui_component::{ ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex, }; use crate::ui::app::Tty7App; +use crate::ui::document_column::DocumentChrome; use crate::ui::host_ops::{HostId, HostOps, MTime, SharedHost, WatchSub}; use crate::ui::i18n::{L10nKey, t, t_fmt}; @@ -1081,6 +1083,7 @@ impl Tty7App { impl Tty7App { pub(crate) fn render_code_overlay( &mut self, + chrome: DocumentChrome, window: &mut Window, cx: &mut Context, ) -> Option { @@ -1132,17 +1135,25 @@ impl Tty7App { .filter(|f| f.conflict) .map(|_| self.render_editor_conflict_banner(cx)); + let header = chrome + .renders_own_header() + .then(|| self.render_editor_header(chrome, window, cx)); let editor_col = v_flex() .flex_1() .min_w_0() .h_full() - .child(self.render_editor_header(window, cx)) + .children(header) .when_some(conflict_banner, |this, b| this.child(b)) .child(div().flex_1().min_h_0().child(body)); - Some( - v_flex() - .id("code-panel") + // The panel's own paint is the same either way; only the box is not. + // Filling the workspace means stopping the window's translucency and + // repainting the theme image the root's copy now sits under; docking + // means sitting in the same plane as the right panel, which the column + // wrapper has already painted. + let shell = v_flex().id("code-panel"); + let shell = match chrome { + DocumentChrome::Fill => shell .absolute() .inset_0() .occlude() @@ -1154,33 +1165,58 @@ impl Tty7App { // theme background image is repainted on top of it, since the // root's copy now sits below this fill. .bg(crate::ui::theme::overlay_background(cx)) + .children(crate::ui::app::overlay_surface_layers(cx)), + DocumentChrome::Dock | DocumentChrome::DockHoisted => shell.size_full().min_w_0(), + }; + Some( + shell .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { if ev.keystroke.key == "escape" { this.toggle_code_panel(window, cx); } })) - .children(crate::ui::app::overlay_surface_layers(cx)) .child(h_flex().flex_1().min_h_0().w_full().child(editor_col)) .child(self.render_code_status_bar(window, cx)) .into_any_element(), ) } - fn render_editor_header( + /// The editor header alone, for the strip above a docked column. + pub(crate) fn render_editor_header_only( &self, + chrome: DocumentChrome, window: &mut Window, cx: &mut Context, - ) -> gpui::Stateful { + ) -> gpui::AnyElement { + self.render_editor_header(chrome, window, cx) + .into_any_element() + } + + fn render_editor_header( + &self, + chrome: DocumentChrome, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement + use<> { let active = self.tab_code().and_then(|c| c.active_file()); let name = active.map(|f| f.label()); let dirty = active.is_some_and(|f| f.dirty); - let lead = if self.left_panel_open(cx) { + // `TITLE_BAR_LEAD` is the room macOS's traffic lights need. Only a + // header that starts at the left edge of the window has them to clear, + // and a docked column never does. + let lead = if self.left_panel_open(cx) || chrome.is_dock() { crate::ui::app::CONTENT_INSET } else { crate::ui::app::TITLE_BAR_LEAD }; - crate::ui::app::title_bar_drag(h_flex().id("editor-header"), "editor-header", window, cx) - .flex_none() + let row = h_flex().id("editor-header"); + let row = if chrome.header_is_title_strip() { + crate::ui::app::title_bar_drag(row, "editor-header", window, cx) + } else { + row + }; + let menu_app = cx.entity().downgrade(); + row.flex_none() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) .items_center() .gap_1p5() @@ -1226,6 +1262,9 @@ impl Tty7App { })), ), ) + .context_menu(move |menu, _window, cx| { + Tty7App::document_header_menu(menu, &menu_app, cx) + }) } fn render_code_status_bar(&self, _window: &Window, cx: &mut Context) -> gpui::Div { diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index a3706413..2e869e31 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -7,6 +7,7 @@ use gpui::{ Window, div, prelude::*, px, }; use gpui_component::button::Button; +use gpui_component::menu::ContextMenuExt as _; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; use crate::core::config::{Config, DiffViewMode}; @@ -22,6 +23,7 @@ use crate::terminal::git_diff::{ const MAX_PREVIEW_BYTES: u64 = 4 * 1024 * 1024; use crate::ui::app::Tty7App; use crate::ui::diff_rows::{Side, SplitCell, SplitRow, UnifiedRow, split_hunk, unified_rows}; +use crate::ui::document_column::DocumentChrome; use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; use crate::ui::right_panel::info_chip; use crate::ui::rounding; @@ -388,6 +390,7 @@ impl Tty7App { pub(crate) fn render_diff_overlay( &mut self, + chrome: DocumentChrome, window: &mut Window, cx: &mut Context, ) -> Option { @@ -426,10 +429,14 @@ impl Tty7App { ), }; - let header = self.diff_header(overlay, window, cx); + let header = chrome + .renders_own_header() + .then(|| self.diff_header(overlay, chrome, window, cx)); + let focus_handle = overlay.focus_handle.clone(); - Some( - v_flex() + let shell = v_flex(); + let shell = match chrome { + DocumentChrome::Fill => shell .absolute() .inset_0() .occlude() @@ -439,27 +446,49 @@ impl Tty7App { cx.try_global::(), cx.theme().background, )) + // The opaque fill above covers the theme background image the + // workspace root paints, so the overlay carries its own copy, + // dimmed back to the strength it had when this overlay was + // itself translucent. + .children(crate::ui::app::overlay_surface_layers(cx)), + // Docked, the column wrapper has already painted the surface this + // sits on — the same one the right panel uses — and nothing behind + // it needs stopping. + DocumentChrome::Dock | DocumentChrome::DockHoisted => shell.size_full().min_w_0(), + }; + Some( + shell .text_color(cx.theme().foreground) - .track_focus(&overlay.focus_handle) + .track_focus(&focus_handle) .on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| { if ev.keystroke.key.as_str() == "escape" { this.close_diff_overlay(window, cx); } })) - // The opaque fill above covers the theme background image the - // workspace root paints, so the overlay carries its own copy, - // dimmed back to the strength it had when this overlay was - // itself translucent. - .children(crate::ui::app::overlay_surface_layers(cx)) - .child(header) + .children(header) .child(content) .into_any_element(), ) } + /// The diff header alone, for the strip above a docked column. + pub(crate) fn render_diff_header_only( + &mut self, + chrome: DocumentChrome, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?; + Some( + self.diff_header(overlay, chrome, window, cx) + .into_any_element(), + ) + } + fn diff_header( &self, overlay: &DiffOverlayState, + chrome: DocumentChrome, window: &mut Window, cx: &mut Context, ) -> impl IntoElement + use<> { @@ -471,19 +500,25 @@ impl Tty7App { } _ => (String::new(), 0, 0, 0, 0), }; - let lead = if self.left_panel_open(cx) { + // See `render_editor_header`: the traffic-light inset belongs to a + // header that starts at the window's left edge, which a column's does + // not. + let lead = if self.left_panel_open(cx) || chrome.is_dock() { crate::ui::app::CONTENT_INSET } else { crate::ui::app::TITLE_BAR_LEAD }; let mono = SharedString::from(self.font_family.clone()); let subject = source_subject(&overlay.source, branch); - let row = crate::ui::app::title_bar_drag( - h_flex().id("diff-overlay-header"), - "diff-overlay-header", - window, - cx, - ); + let subject_takes_the_slack = + chrome.is_dock() && !subject.is_rev && subject.label.is_none(); + let menu_app = cx.entity().downgrade(); + let row = h_flex().id("diff-overlay-header"); + let row = if chrome.header_is_title_strip() { + crate::ui::app::title_bar_drag(row, "diff-overlay-header", window, cx) + } else { + row + }; row.flex_shrink_0() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) .pl(px(lead)) @@ -509,7 +544,16 @@ impl Tty7App { .child(subject.text) .into_any_element() } else { + // Docked, this is the name that gives: the header has a + // column's width rather than a window's, and a branch name that + // refused to yield any of it pushed the view toggle and the + // close tile off the end. It takes the slack the spacer below + // would otherwise have — the same trade the label branch makes, + // and for the same reason two `flex_1` siblings would split the + // line and truncate the name with empty space beside it. div() + .when(subject_takes_the_slack, |d| d.flex_1().min_w_0().truncate()) + .when(!subject_takes_the_slack, |d| d.flex_shrink_0()) .text_sm() .font_weight(FontWeight::MEDIUM) .child(subject.text) @@ -588,12 +632,17 @@ impl Tty7App { if untracked > 0 { summary.push_str(&t_plural(L10nKey::DiffUntrackedCount, untracked, &[])); } - bar.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(summary), - ) + // The file count is the first thing a column drops: the + // same number is one line down, at the top of the list. + // The totals stay — they have no second home. + bar.when(!chrome.is_dock(), |bar| { + bar.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(summary), + ) + }) .when(added > 0, |bar| { bar.child( div() @@ -623,7 +672,9 @@ impl Tty7App { ) }, ) - .when(subject.label.is_none(), |bar| bar.child(div().flex_1())) + .when(subject.label.is_none() && !subject_takes_the_slack, |bar| { + bar.child(div().flex_1()) + }) .child(div().occlude().flex_shrink_0().child({ let sf = cx.global::().window; let selected = usize::from(view_mode(cx) == DiffViewMode::Unified); @@ -659,6 +710,9 @@ impl Tty7App { })), ), ) + .context_menu(move |menu, _window, cx| { + Tty7App::document_header_menu(menu, &menu_app, cx) + }) } /// Dispatch the byte read behind an untracked file's preview, at most diff --git a/src/ui/document_column.rs b/src/ui/document_column.rs new file mode 100644 index 00000000..68bbd807 --- /dev/null +++ b/src/ui/document_column.rs @@ -0,0 +1,768 @@ +//! The slot the code panel and the diff overlay are drawn in. +//! +//! Both surfaces used to be full-workspace overlays — `absolute`, `inset_0`, +//! `occlude` — so opening a file to read it hid the agent that told you to read +//! it, and reviewing one turned into a toggle loop. They now dock as a column +//! beside the terminal instead, on the same pattern the right panel has always +//! used: a flex sibling with a drag handle and a persisted share of the width. +//! +//! A sibling column, rather than a narrower overlay, is the whole point: the +//! terminal element's laid-out bounds are what drive `set_grid_size`, so a +//! column takes width *away* from the grid and the PTY reflows to what is left. +//! An overlay painted over half the workspace would leave the grid full width +//! with half of it under a card. +//! +//! The overlay is not gone — [`DocumentLayout::Fill`] is exactly the old paint, +//! one command away, and a window too narrow to seat both a terminal and a +//! document falls back to it for that frame without touching what the user +//! saved. + +use gpui::{AnyElement, Context, Window, div, prelude::*, px}; +use gpui_component::menu::{PopupMenu, PopupMenuItem}; +use gpui_component::{ActiveTheme as _, InteractiveElementExt as _, v_flex}; +use std::cell::Cell as StdCell; +use std::rc::Rc; + +use crate::core::config::{Config, DOCUMENT_RATIO_STOPS, DocumentLayout}; +use crate::ui::app::{DOCUMENT_MIN_W, OverlayTop, TERMINAL_MIN_W, Tty7App, document_column_px}; +use crate::ui::i18n::{L10nKey, t}; +use crate::ui::right_panel::RESIZE_HANDLE_WIDTH; + +/// Which wrapper a document surface is being asked to paint itself in. +/// +/// The *content* of the code panel and of the diff overlay is the same in all +/// three; only the box around it changes, and with it where the header sits and +/// what its gestures mean. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DocumentChrome { + /// The historical full-workspace overlay. + Fill, + /// A column beside the terminal, carrying its own header as its first row. + /// macOS only: there the title bar lives inside the terminal column, so the + /// document column runs to the top of the window and its header lands in + /// the title strip by itself. + Dock, + /// A column beside the terminal whose header has been lifted into the + /// spanning title bar above it. + /// + /// Windows and Linux put the window controls at the right end of a title + /// bar that spans the workspace, which leaves the strip directly above the + /// document column empty — a title bar's height of nothing, with the file + /// name one row below it. The header goes there instead, and the column + /// renders its body alone. + DockHoisted, +} + +impl DocumentChrome { + /// Whether this is one of the two column layouts. + pub(crate) fn is_dock(self) -> bool { + !matches!(self, DocumentChrome::Fill) + } + + /// Whether the surface draws its own header, or has had it lifted away. + pub(crate) fn renders_own_header(self) -> bool { + !matches!(self, DocumentChrome::DockHoisted) + } + + /// Whether the header is the strip along the top of the window, and so has + /// to behave like a title bar — dragging the window, zooming on a + /// double-click. + /// + /// True for the overlay, whose header stands in for the title bar. True for + /// a hoisted header, which is drawn *into* the title bar. True for a docked + /// column only on macOS, where the column reaches the top of the window + /// anyway. False for a plain docked header, which sits inside the workspace + /// and would be a second, fake title bar if it moved the window. + pub(crate) fn header_is_title_strip(self) -> bool { + match self { + DocumentChrome::Fill | DocumentChrome::DockHoisted => true, + DocumentChrome::Dock => cfg!(target_os = "macos"), + } + } +} + +impl Tty7App { + /// The layout the active tab's document was asked for, which is not always + /// the one it gets — see [`Tty7App::document_dock_px`]. + /// + /// A tab that has never been told follows the config, which holds the last + /// explicit choice anyone made and is therefore what a fresh tab starts + /// from. Telling one tab never moves another. + pub(crate) fn document_layout(&self, cx: &gpui::App) -> DocumentLayout { + self.tabs + .get(self.active) + .and_then(|t| t.document_layout) + .unwrap_or_else(|| cx.global::().document_layout) + } + + /// Which of the two surfaces the docked column shows. + /// + /// `overlay_top` orders a *pair* of overlays in fill mode, where both are + /// painted and the front one wins on paint order. A column has one child, + /// so the ordering has to become a choice: the surface on top, unless it is + /// closed, in which case there is no front and the survivor is it. + pub(crate) fn document_front(&self) -> Option { + let tab = self.tabs.get(self.active)?; + let code = tab.code.as_ref().is_some_and(|c| c.visible); + let diff = tab.diff_overlay.is_some(); + match (tab.overlay_top, code, diff) { + (_, false, false) => None, + (OverlayTop::Code, true, _) | (OverlayTop::Diff, true, false) => Some(OverlayTop::Code), + (OverlayTop::Diff, _, true) | (OverlayTop::Code, false, true) => Some(OverlayTop::Diff), + } + } + + /// What the document column has reserved, from a side panel's point of + /// view. + /// + /// Read from the user's intent and from whether a surface is open — never + /// from the *effective* layout, which is derived from the widths this feeds + /// and would close the loop on itself. + pub(crate) fn document_floor(&self, cx: &gpui::App) -> f32 { + if self.document_layout(cx) == DocumentLayout::Dock && self.document_front().is_some() { + DOCUMENT_MIN_W + } else { + 0. + } + } + + /// The width the terminal and a docked document share: the window less + /// whichever side panels are open, at the widths they are actually drawn + /// at rather than at their floors. A sidebar someone dragged wider is width + /// the terminal no longer has. + pub(crate) fn document_body_px(&self, window: &Window, cx: &gpui::App) -> f32 { + let viewport = window.viewport_size().width.as_f32(); + let sidebar = if self.sidebar_open(cx) { + self.sidebar_px(window, cx) + } else { + 0. + }; + let panel = if self.right_panel_open(cx) { + self.right_panel_px(window, cx) + } else { + 0. + }; + viewport - sidebar - panel + } + + /// How wide the document column is drawn this frame, or `None` when the + /// surface is closed, the user chose fill, or the window is too narrow to + /// seat both. + pub(crate) fn document_dock_px(&self, window: &Window, cx: &gpui::App) -> Option { + if self.document_layout(cx) != DocumentLayout::Dock || self.document_front().is_none() { + return None; + } + document_column_px(self.document_body_px(window, cx), self.document_ratio.get()) + } + + /// Fill ↔ dock, for the active tab. One of the two writers of the layout; + /// the narrow window fallback is not, on purpose — running this while the + /// fallback is showing is the user saying they meant the overlay, and that + /// is worth keeping. + pub(crate) fn toggle_document_fill(&mut self, cx: &mut Context) { + let next = match self.document_layout(cx) { + DocumentLayout::Dock => DocumentLayout::Fill, + DocumentLayout::Fill => DocumentLayout::Dock, + }; + self.set_document_layout(next, cx); + } + + /// Snap the column to a named share of the terminal column. Docks first if + /// the surface is filling the window: asking for a third of the width is + /// asking for a column. + pub(crate) fn set_document_ratio(&mut self, ratio: f32, cx: &mut Context) { + self.document_ratio.set(ratio); + self.update_config(cx, |cfg| cfg.document_ratio = ratio); + self.set_document_layout(DocumentLayout::Dock, cx); + } + + /// Third → half → two thirds → third, the double-click on the divider. + /// Starts from whichever named width the current one is nearest, so a + /// dragged column joins the cycle where it looks like it is. + pub(crate) fn cycle_document_ratio(&mut self, cx: &mut Context) { + let current = self.document_ratio.get(); + let nearest = next_ratio_stop(current); + self.set_document_ratio(nearest, cx); + } + + /// Point the active tab at a layout, and only that tab. + /// + /// Deliberately not written back to `document_layout` in the config: that + /// key is the value a tab starts from, and a tab that has not been told is + /// still reading it. Writing it here would reach every one of those at + /// once, which is the window-wide switch this is not. + /// + /// The narrow-window fallback does not come through here either — it is + /// derived at render time and stored nowhere. + pub(crate) fn set_document_layout(&mut self, layout: DocumentLayout, cx: &mut Context) { + if let Some(tab) = self.tabs.get_mut(self.active) { + tab.document_layout = Some(layout); + } + cx.notify(); + } + + /// What right-clicking a document's header offers: where it sits. + /// + /// On the header rather than in Settings because this is where the question + /// comes up — the moment a file covers the terminal is the moment you want + /// it not to, and a preference three pages into a settings panel is not an + /// answer to that. On the header rather than on a tile beside the close + /// button because the row already carries a file name, a dirty dot and, for + /// a diff, a view toggle; a fifth control in a column's width is one too + /// many for something you set once. + /// + pub(crate) fn document_header_menu( + menu: PopupMenu, + app: &gpui::WeakEntity, + cx: &gpui::App, + ) -> PopupMenu { + let docked = app + .upgrade() + .is_none_or(|this| this.read(cx).document_layout(cx) == DocumentLayout::Dock); + let mut menu = menu.min_w(px(220.)); + + for (label, layout) in [ + (L10nKey::DocumentDock, DocumentLayout::Dock), + (L10nKey::DocumentFill, DocumentLayout::Fill), + ] { + menu = menu.item( + PopupMenuItem::new(t(label)) + .checked(docked == (layout == DocumentLayout::Dock)) + .on_click({ + let app = app.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| this.set_document_layout(layout, cx)); + } + }), + ); + } + menu + } + + /// The column itself: the surface `overlay_top` selects, sized, bordered, + /// and given the divider on its left edge. + /// The header on its own, for the strip above the column — see + /// [`DocumentChrome::DockHoisted`]. Returns `None` for the layouts that + /// keep their header inside the surface. + pub(crate) fn render_document_header( + &mut self, + chrome: DocumentChrome, + window: &mut Window, + cx: &mut Context, + ) -> Option { + if chrome.renders_own_header() { + return None; + } + match self.document_front()? { + OverlayTop::Code => Some(self.render_editor_header_only(chrome, window, cx)), + OverlayTop::Diff => self.render_diff_header_only(chrome, window, cx), + } + } + + pub(crate) fn render_document_column( + &mut self, + width: f32, + chrome: DocumentChrome, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let body = self.document_body_px(window, cx); + let surface = match self.document_front()? { + OverlayTop::Code => self.render_code_overlay(chrome, window, cx), + OverlayTop::Diff => self.render_diff_overlay(chrome, window, cx), + }?; + let (backing, handle) = self.document_resize(body, cx); + Some( + v_flex() + .id("document-column") + .relative() + .flex_none() + .w(px(width)) + .h_full() + .bg(crate::ui::theme::workspace_surface_color(cx)) + .border_l_1() + .border_color(cx.theme().sidebar_border) + .child(backing) + // The clip belongs to the content, not to the column: the + // divider hangs half a handle past the left edge, the way the + // panels' do, and clipping the column would have taken that + // half — and the grab with it — away. + .child( + div() + .flex_1() + .min_h_0() + .w_full() + .overflow_hidden() + .child(surface), + ) + .child(handle) + .into_any_element(), + ) + } + + /// The divider, on the same contract as the right panel's: the cell moves + /// on every mouse move, the config is written once, on mouse up. + /// + /// `body` is read here, while there is still a `cx` to read it from — the + /// drag handler only ever sees a `Window`, and a limit that disagreed with + /// the one the layout applies would spring the column back from wherever it + /// was dropped. + fn document_resize(&self, body: f32, cx: &mut Context) -> (AnyElement, AnyElement) { + use gpui::{Bounds, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, canvas}; + + let container: Rc>>> = Rc::new(StdCell::new(None)); + let backing = canvas( + { + let container = container.clone(); + move |bounds, _window, _cx| container.set(Some(bounds)) + }, + { + let container = container.clone(); + let ratio_cell = self.document_ratio.clone(); + let dragging = self.document_dragging.clone(); + move |_bounds, _state, window, _cx| { + window.on_mouse_event({ + let container = container.clone(); + let ratio_cell = ratio_cell.clone(); + let dragging = dragging.clone(); + move |ev: &MouseMoveEvent, _phase, window, _cx| { + if !dragging.get() || body <= 0. { + return; + } + let Some(b) = container.get() else { + return; + }; + let right = b.origin.x + b.size.width; + let raw = (right - ev.position.x).as_f32(); + let w = raw + .clamp(DOCUMENT_MIN_W, (body - TERMINAL_MIN_W).max(DOCUMENT_MIN_W)); + ratio_cell.set(w / body); + window.refresh(); + } + }); + window.on_mouse_event({ + let ratio_cell = ratio_cell.clone(); + let dragging = dragging.clone(); + move |_ev: &MouseUpEvent, _phase, window, cx| { + if !dragging.get() { + return; + } + dragging.set(false); + let r = ratio_cell.get(); + let cfg = cx.global_mut::(); + if cfg.document_ratio != r { + cfg.document_ratio = r; + cfg.save(); + } + window.refresh(); + } + }); + } + }, + ) + .absolute() + .size_full() + .into_any_element(); + + let active = self.document_dragging.get(); + let handle = div() + .id("document-resize") + .group("document-resize") + .occlude() + .absolute() + .top_0() + .left(px(-(RESIZE_HANDLE_WIDTH / 2.))) + .w(px(RESIZE_HANDLE_WIDTH)) + .h_full() + .flex() + .items_center() + .justify_center() + .cursor_col_resize() + .child( + div() + .w(px(1.)) + .h_full() + .when(active, |d| d.bg(cx.theme().drag_border)) + .group_hover("document-resize", |s| s.bg(cx.theme().drag_border)), + ) + .on_mouse_down(MouseButton::Left, { + let dragging = self.document_dragging.clone(); + move |_ev, window, _cx| { + dragging.set(true); + window.refresh(); + } + }) + // A double-click lands a mouse-down first, which arms the drag; the + // mouse-up disarms it without having moved, so the cycle below is + // the only thing that ends up happening. + .on_double_click(cx.listener(|this, _, _window, cx| { + this.document_dragging.set(false); + this.cycle_document_ratio(cx); + })) + .into_any_element(); + + (backing, handle) + } +} + +/// The width the divider's double-click moves to from `current`: the one after +/// whichever named stop `current` is nearest, wrapping round. +pub(crate) fn next_ratio_stop(current: f32) -> f32 { + let nearest = DOCUMENT_RATIO_STOPS + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| (*a - current).abs().total_cmp(&(*b - current).abs())) + .map(|(i, _)| i) + .unwrap_or(1); + DOCUMENT_RATIO_STOPS[(nearest + 1) % DOCUMENT_RATIO_STOPS.len()] +} + +#[cfg(test)] +mod tests { + use super::next_ratio_stop; + use crate::core::config::{ + DOCUMENT_RATIO_HALF, DOCUMENT_RATIO_THIRD, DOCUMENT_RATIO_TWO_THIRDS, + }; + use crate::ui::app::{DOCUMENT_MIN_W, TERMINAL_MIN_W, document_column_px}; + + /// Third, half, two thirds, round again — and a dragged width joins at + /// whichever stop it looks nearest to rather than always restarting. + #[test] + fn the_divider_cycles_the_named_widths() { + assert_eq!(next_ratio_stop(DOCUMENT_RATIO_THIRD), DOCUMENT_RATIO_HALF); + assert_eq!( + next_ratio_stop(DOCUMENT_RATIO_HALF), + DOCUMENT_RATIO_TWO_THIRDS + ); + assert_eq!( + next_ratio_stop(DOCUMENT_RATIO_TWO_THIRDS), + DOCUMENT_RATIO_THIRD + ); + // Dragged to just under half: nearest stop is half, so the cycle goes + // on to two thirds rather than back to a third. + assert_eq!(next_ratio_stop(0.47), DOCUMENT_RATIO_TWO_THIRDS); + assert_eq!(next_ratio_stop(0.8), DOCUMENT_RATIO_THIRD); + } + + /// The default is half of what the terminal and the document share, and + /// what they share is the window less the panels — not the window. + #[test] + fn half_of_the_body_is_half_of_the_body() { + let body = 1440. - 220. - 260.; + assert_eq!(document_column_px(body, 0.5), Some(body / 2.)); + } + + /// Two thirds is allowed past the half-window cap the side panels obey. + /// Only the terminal's floor binds it. + #[test] + fn two_thirds_is_two_thirds_until_the_terminal_floor_says_otherwise() { + let wide = 1200.; + assert_eq!(document_column_px(wide, 2. / 3.), Some(wide * 2. / 3.)); + + // 800 * 2/3 is 533, which would leave the terminal 267 — under its + // floor — so the column stops where the terminal starts. + let tight = 800.; + assert_eq!( + document_column_px(tight, 2. / 3.), + Some(tight - TERMINAL_MIN_W) + ); + } + + /// A column narrower than it can be read at is not a column. The ratio + /// floors out rather than shrinking with the window. + #[test] + fn a_thin_share_still_gets_the_documents_floor() { + let body = 700.; + assert_eq!(document_column_px(body, 0.2), Some(DOCUMENT_MIN_W)); + } + + /// Below the width where both fit there is no docked layout to draw, and + /// the caller falls back to the overlay for the frame. The threshold is + /// exact so that widening by a point re-docks. + #[test] + fn a_window_too_narrow_for_both_has_no_docked_width() { + let floor = TERMINAL_MIN_W + DOCUMENT_MIN_W; + assert_eq!(document_column_px(floor - 1., 0.5), None); + assert_eq!(document_column_px(floor, 0.5), Some(DOCUMENT_MIN_W)); + assert_eq!(document_column_px(f32::NAN, 0.5), None); + } + + /// Whatever the terminal is left, it is never less than its floor. That is + /// the invariant the whole budget exists for. + #[test] + fn the_terminal_keeps_its_floor_at_every_share() { + for body in [640., 700., 900., 1200., 2400.] { + for ratio in [0.2, 1. / 3., 0.5, 2. / 3., 0.8] { + let Some(doc) = document_column_px(body, ratio) else { + continue; + }; + assert!( + body - doc >= TERMINAL_MIN_W - f32::EPSILON, + "body {body} ratio {ratio} left the terminal {}", + body - doc + ); + assert!(doc >= DOCUMENT_MIN_W - f32::EPSILON); + } + } + } +} + +#[cfg(test)] +mod gpui_tests { + use super::*; + use crate::core::config::{DOCUMENT_RATIO_TWO_THIRDS, DocumentLayout}; + use crate::ui::app::test_window; + use crate::ui::pane::{Pane, PaneSlot}; + use crate::ui::pending_pane::{PendingPane, PendingSpawn}; + use gpui::{Entity, TestAppContext, VisualTestContext, px, size}; + + /// One quiet tab. The pane is a *connecting* one so the harness needs no + /// PTY and runs on every platform. + fn push_tab(app: &mut Tty7App, cx: &mut Context) { + let pending = cx.new(|cx| { + PendingPane::new( + "test-box", + PendingSpawn { + workspace: None, + working_directory: None, + restore_pane: None, + shell: None, + agent: None, + agent_session_id: None, + agent_launch_argv: None, + owner: None, + font_size: 14.0, + }, + cx, + ) + }); + app.tabs + .push(crate::ui::app::Tab::new(Pane::leaf(PaneSlot::Connecting( + pending, + )))); + cx.notify(); + } + + /// A window with `tabs` quiet tabs, active on the first, sized to order. + fn window_with( + cx: &mut TestAppContext, + w: f32, + tabs: usize, + ) -> (Entity, VisualTestContext) { + let (app, mut vcx) = test_window::harness(cx); + app.update_in(&mut vcx, |app, _, cx| { + for _ in 0..tabs { + push_tab(app, cx); + } + app.active = 0; + }); + vcx.simulate_resize(size(px(w), px(900.))); + vcx.run_until_parked(); + (app, vcx) + } + + fn window(cx: &mut TestAppContext, w: f32) -> (Entity, VisualTestContext) { + window_with(cx, w, 1) + } + + fn dock_px(app: &Entity, vcx: &mut VisualTestContext) -> Option { + app.update_in(vcx, |app, window, cx| app.document_dock_px(window, cx)) + } + + /// The config value: the default under tabs that were never told, not + /// necessarily what the active tab is doing. + fn layout(vcx: &mut VisualTestContext) -> DocumentLayout { + vcx.update(|_, cx| cx.global::().document_layout) + } + + /// What the active tab is actually doing. + fn tab_layout(app: &Entity, vcx: &mut VisualTestContext) -> DocumentLayout { + app.update_in(vcx, |app, _, cx| app.document_layout(cx)) + } + + /// Fill is one tab's answer, not the window's. Reading a long file in one + /// tab while an agent works in another wants the whole window here and half + /// of it there, and a global switch made each of those flip the other. + #[gpui::test] + fn one_tabs_fill_leaves_the_other_docked(cx: &mut TestAppContext) { + let (app, mut vcx) = window_with(cx, 1440., 2); + + // A file open in each tab, both docked to start with. + for i in [0, 1] { + app.update_in(&mut vcx, |app, window, cx| { + app.active = i; + app.toggle_code_panel(window, cx); + }); + } + vcx.run_until_parked(); + + app.update_in(&mut vcx, |app, _, cx| { + app.active = 1; + app.toggle_document_fill(cx); + }); + vcx.run_until_parked(); + assert_eq!(dock_px(&app, &mut vcx), None, "the tab that asked, fills"); + + app.update_in(&mut vcx, |app, _, _cx| app.active = 0); + vcx.run_until_parked(); + assert!( + dock_px(&app, &mut vcx).is_some(), + "the tab that did not ask, does not" + ); + + // The config is untouched — it is what every tab that was never told is + // still reading, so writing it would have reached all of them at once. + assert_eq!(layout(&mut vcx), DocumentLayout::Dock); + app.update_in(&mut vcx, |app, window, cx| { + push_tab(app, cx); + app.active = 2; + app.toggle_code_panel(window, cx); + }); + vcx.run_until_parked(); + assert!( + dock_px(&app, &mut vcx).is_some(), + "a fresh tab starts from the config, not from what tab 1 chose" + ); + } + + /// The complaint in #625: opening a file must leave the terminal on screen. + /// The column is half of what the terminal and the document share, and the + /// terminal's own laid-out area gives up exactly that width — which is what + /// makes the PTY reflow rather than hide half its columns under a card. + #[gpui::test] + fn opening_the_code_panel_docks_a_column_beside_the_terminal(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx, 1440.); + + app.update_in(&mut vcx, |app, window, cx| { + app.toggle_code_panel(window, cx); + }); + vcx.run_until_parked(); + + let body = app.update_in(&mut vcx, |app, window, cx| app.document_body_px(window, cx)); + let docked = dock_px(&app, &mut vcx).expect("a 1440 window seats both"); + assert!( + (docked - body / 2.).abs() < 0.5, + "half the terminal column, not half the window: {docked} of {body}" + ); + + // The terminal is laid out at what is left, not at the full width with + // a card over half of it. is the rectangle the grid sizes + // itself from, so this is the assertion the PTY reflow rests on. + let pane = app + .update_in(&mut vcx, |app, _, _| app.pane_area.get()) + .expect("the body area painted"); + assert!( + (pane.size.width.as_f32() - (body - docked)).abs() < 1.5, + "terminal laid out at {} of a {body} body beside a {docked} column", + pane.size.width.as_f32() + ); + assert!(pane.size.width.as_f32() >= TERMINAL_MIN_W); + } + + /// Esc — which is what `toggle_code_panel` runs — puts the width back. + #[gpui::test] + fn closing_the_surface_gives_the_width_back(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx, 1440.); + app.update_in(&mut vcx, |app, window, cx| { + app.toggle_code_panel(window, cx); + }); + vcx.run_until_parked(); + assert!(dock_px(&app, &mut vcx).is_some()); + + app.update_in(&mut vcx, |app, window, cx| { + app.toggle_code_panel(window, cx); + }); + vcx.run_until_parked(); + assert_eq!( + app.update_in(&mut vcx, |app, _, _| app.document_front()), + None + ); + assert_eq!(dock_px(&app, &mut vcx), None); + assert_eq!( + app.update_in(&mut vcx, |app, _, cx| app.document_floor(cx)), + 0., + "a closed surface reserves nothing from the panels" + ); + } + + /// A window with no room for both falls back to the overlay for the frame + /// and leaves the saved layout alone. Widening re-docks with no command run + /// in between — the fallback is derived, not stored. + #[gpui::test] + fn a_narrow_window_falls_back_without_saving_it(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx, 560.); + app.update_in(&mut vcx, |app, window, cx| { + app.toggle_code_panel(window, cx); + }); + vcx.run_until_parked(); + + assert_eq!(dock_px(&app, &mut vcx), None, "no room for both"); + assert_eq!( + layout(&mut vcx), + DocumentLayout::Dock, + "the fallback must never write the user's choice" + ); + + vcx.simulate_resize(size(px(1440.), px(900.))); + vcx.run_until_parked(); + assert!( + dock_px(&app, &mut vcx).is_some(), + "widening re-docks on the next frame" + ); + assert_eq!(layout(&mut vcx), DocumentLayout::Dock); + } + + /// Filling is still there, and asking for it *is* a choice worth keeping — + /// including from inside the narrow-window fallback, where the user is + /// looking at an overlay and saying they want it. + #[gpui::test] + fn asking_to_fill_is_kept_and_a_named_width_docks_again(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx, 560.); + app.update_in(&mut vcx, |app, window, cx| { + app.toggle_code_panel(window, cx); + }); + app.update_in(&mut vcx, |app, _, cx| app.toggle_document_fill(cx)); + vcx.run_until_parked(); + assert_eq!(tab_layout(&app, &mut vcx), DocumentLayout::Fill); + + vcx.simulate_resize(size(px(1440.), px(900.))); + vcx.run_until_parked(); + assert_eq!( + dock_px(&app, &mut vcx), + None, + "a wide window does not overrule a chosen fill" + ); + + // Asking for two thirds of the width is asking for a column. + app.update_in(&mut vcx, |app, _, cx| { + app.set_document_ratio(DOCUMENT_RATIO_TWO_THIRDS, cx) + }); + vcx.run_until_parked(); + assert_eq!(tab_layout(&app, &mut vcx), DocumentLayout::Dock); + let body = app.update_in(&mut vcx, |app, window, cx| app.document_body_px(window, cx)); + let docked = dock_px(&app, &mut vcx).expect("docked again"); + // Two thirds, or as near as the terminal's floor allows — with both + // side panels open a 1440 window has 960 to share, and two thirds of + // that would leave the terminal 320. + let want = (body * 2. / 3.).min(body - TERMINAL_MIN_W); + assert!((docked - want).abs() < 0.5, "{docked} of {body}"); + assert!(docked > body / 2., "wider than the half it started at"); + assert_eq!( + vcx.update(|_, cx| cx.global::().document_ratio), + DOCUMENT_RATIO_TWO_THIRDS + ); + + // The header menu writes the layout outright rather than toggling it. + app.update_in(&mut vcx, |app, _, cx| { + app.set_document_layout(DocumentLayout::Fill, cx) + }); + vcx.run_until_parked(); + assert_eq!(tab_layout(&app, &mut vcx), DocumentLayout::Fill); + assert_eq!(dock_px(&app, &mut vcx), None); + assert_eq!( + vcx.update(|_, cx| cx.global::().document_ratio), + DOCUMENT_RATIO_TWO_THIRDS, + "filling does not forget the width to come back to" + ); + } +} diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 1db56b4f..09ad93db 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -554,6 +554,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsDiffPreviewFromCountsDesc => { "Click a row's +N −N to open the working-tree diff in an overlay. Off leaves the counts visible, just not clickable." } + L10nKey::DocumentDock => "Dock beside terminal", + L10nKey::DocumentFill => "Fill window", L10nKey::SettingsNotifications => "Notifications", L10nKey::SettingsNotifyOnCommandFinish => "Notify on command finish", L10nKey::SettingsNotifyOnCommandFinishDesc => { @@ -1412,6 +1414,12 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdResetFontSize => "Reset Font Size", L10nKey::CmdEnterFullScreen => "Enter Full Screen", L10nKey::CmdToggleDiffViewMode => "Toggle Unified / Side-by-Side Diff", + L10nKey::CmdDocumentDock => "Document: Dock Beside Terminal", + L10nKey::CmdDocumentFill => "Document: Fill Window", + L10nKey::CmdToggleDocumentFill => "Toggle Document Fill / Dock", + L10nKey::CmdDocumentWidthThird => "Document: Third Width", + L10nKey::CmdDocumentWidthHalf => "Document: Half Width", + L10nKey::CmdDocumentWidthTwoThirds => "Document: Two-Thirds Width", L10nKey::CmdGitCommit => "Git: Commit", L10nKey::CmdGitStageAll => "Git: Stage All Changes", L10nKey::CmdGitUnstageAll => "Git: Unstage All Changes", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 4b7edcd9..6d73b2d2 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -563,6 +563,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsDiffPreviewFromCountsDesc => { "行の +N −N をクリックすると、オーバーレイでワーキングツリーの Diff を開きます。オフならカウントは表示されたまま、クリックだけできません" } + L10nKey::DocumentDock => "ターミナルの隣にドック", + L10nKey::DocumentFill => "ウィンドウ全体", L10nKey::SettingsNotifications => "通知", L10nKey::SettingsNotifyOnCommandFinish => "コマンド終了時に通知", L10nKey::SettingsNotifyOnCommandFinishDesc => { @@ -1469,6 +1471,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdResetFontSize => "フォントサイズをリセット", L10nKey::CmdEnterFullScreen => "全画面表示", L10nKey::CmdToggleDiffViewMode => "統合 / 左右分割の差分表示を切り替え", + L10nKey::CmdDocumentDock => "ドキュメント: ターミナルの隣にドック", + L10nKey::CmdDocumentFill => "ドキュメント: ウィンドウ全体", + L10nKey::CmdToggleDocumentFill => "ドキュメントのフィル / ドックを切り替え", + L10nKey::CmdDocumentWidthThird => "ドキュメント: 幅3分の1", + L10nKey::CmdDocumentWidthHalf => "ドキュメント: 幅半分", + L10nKey::CmdDocumentWidthTwoThirds => "ドキュメント: 幅3分の2", L10nKey::CmdGitCommit => "Git: コミット", L10nKey::CmdGitStageAll => "Git: すべての変更をステージ", L10nKey::CmdGitUnstageAll => "Git: すべてのステージを取り消す", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 61872198..95a68dd8 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -464,6 +464,8 @@ l10n_keys! { SettingsSidebarGroupingDesc, SettingsDiffPreviewFromCounts, SettingsDiffPreviewFromCountsDesc, + DocumentDock, + DocumentFill, SettingsNotifications, SettingsWindow, SettingsNotifyOnCommandFinish, @@ -1162,6 +1164,12 @@ l10n_keys! { CmdResetFontSize, CmdEnterFullScreen, CmdToggleDiffViewMode, + CmdDocumentDock, + CmdDocumentFill, + CmdToggleDocumentFill, + CmdDocumentWidthThird, + CmdDocumentWidthHalf, + CmdDocumentWidthTwoThirds, CmdGitCommit, CmdGitStageAll, CmdGitUnstageAll, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index da4ed249..6309e7a0 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -488,6 +488,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsDiffPreviewFromCountsDesc => { "点击行上的 +N −N 在浮层中打开 worktree diff。关闭后计数仍显示,只是不可点击。" } + L10nKey::DocumentDock => "停靠在终端旁", + L10nKey::DocumentFill => "铺满窗口", L10nKey::SettingsNotifications => "通知", L10nKey::SettingsNotifyOnCommandFinish => "命令完成时通知", L10nKey::SettingsNotifyOnCommandFinishDesc => "较长的前台命令完成后发出桌面提醒。", @@ -1333,6 +1335,12 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdEnterFullScreen => "进入全屏", L10nKey::CmdClearScrollback => "清除回滚内容", L10nKey::CmdToggleDiffViewMode => "切换统一 / 并排差异视图", + L10nKey::CmdDocumentDock => "文档:停靠在终端旁", + L10nKey::CmdDocumentFill => "文档:铺满窗口", + L10nKey::CmdToggleDocumentFill => "切换文档铺满 / 停靠", + L10nKey::CmdDocumentWidthThird => "文档:三分之一宽", + L10nKey::CmdDocumentWidthHalf => "文档:一半宽", + L10nKey::CmdDocumentWidthTwoThirds => "文档:三分之二宽", L10nKey::CmdGitCommit => "Git:提交", L10nKey::CmdGitStageAll => "Git:暂存全部更改", L10nKey::CmdGitUnstageAll => "Git:取消暂存全部更改", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index ccc07265..5d996f24 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -383,6 +383,13 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ("ToggleSftp", ""), ("ShowSshForwards", ""), ("ToggleCodePanel", "secondary-shift-e"), + // Deliberately unbound. Docking is the default and Esc already gets the + // terminal back, so a default chord here would only be one more thing + // competing for a two-key combination nobody asked for. + ("ToggleDocumentFill", ""), + ("DocumentWidthThird", ""), + ("DocumentWidthHalf", ""), + ("DocumentWidthTwoThirds", ""), // Implemented, dispatchable, and until now unbindable: `set_binding` // only fills slots that exist here, so `"ShowRightPanelInfo": "ctrl-1"` // in config.json was dropped without a word, and the Keybindings page — @@ -568,6 +575,22 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { t(L10nKey::AppMenuRightPanel).to_string(), ), "ToggleCodePanel" => (CommandGroup::View, t(L10nKey::AppMenuCodePanel).to_string()), + "ToggleDocumentFill" => ( + CommandGroup::View, + t(L10nKey::CmdToggleDocumentFill).to_string(), + ), + "DocumentWidthThird" => ( + CommandGroup::View, + t(L10nKey::CmdDocumentWidthThird).to_string(), + ), + "DocumentWidthHalf" => ( + CommandGroup::View, + t(L10nKey::CmdDocumentWidthHalf).to_string(), + ), + "DocumentWidthTwoThirds" => ( + CommandGroup::View, + t(L10nKey::CmdDocumentWidthTwoThirds).to_string(), + ), "ShowRightPanelInfo" => ( CommandGroup::View, t(L10nKey::CmdRightPanelInfo).to_string(), @@ -1030,6 +1053,10 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "ToggleSftp" => KeyBinding::new(keystroke, ToggleSftp, None), "ShowSshForwards" => KeyBinding::new(keystroke, ShowSshForwards, None), "ToggleCodePanel" => KeyBinding::new(keystroke, ToggleCodePanel, None), + "ToggleDocumentFill" => KeyBinding::new(keystroke, ToggleDocumentFill, None), + "DocumentWidthThird" => KeyBinding::new(keystroke, DocumentWidthThird, None), + "DocumentWidthHalf" => KeyBinding::new(keystroke, DocumentWidthHalf, None), + "DocumentWidthTwoThirds" => KeyBinding::new(keystroke, DocumentWidthTwoThirds, None), "EditorSave" => KeyBinding::new(keystroke, EditorSave, None), "OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None), "RestartSshSession" => KeyBinding::new(keystroke, RestartSshSession, None), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index df5799f4..31f360c8 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,6 +3,7 @@ pub mod assets; pub mod code_editor; pub mod diff_overlay; pub mod diff_rows; +pub mod document_column; pub mod file_copy; pub mod file_tree; pub mod forwards; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 84c7de8b..9014ec82 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -75,6 +75,10 @@ pub enum CommandKind { ToggleSftp, ShowSshForwards, ToggleCodePanel, + ToggleDocumentFill, + DocumentWidthThird, + DocumentWidthHalf, + DocumentWidthTwoThirds, RestartSshSession, ScmCommit, ScmStageAll, @@ -182,6 +186,10 @@ impl CommandKind { ToggleSftp => "ssh-remote-files", ShowSshForwards => "ssh-port-forwarding", ToggleCodePanel => "code-panel", + ToggleDocumentFill => "document-fill", + DocumentWidthThird => "document-width-third", + DocumentWidthHalf => "document-width-half", + DocumentWidthTwoThirds => "document-width-two-thirds", RestartSshSession => "ssh-reconnect", ScmCommit => "git-commit", ScmStageAll => "git-stage-all", @@ -281,6 +289,10 @@ impl CommandKind { ToggleSftp => "ToggleSftp", ShowSshForwards => "ShowSshForwards", ToggleCodePanel => "ToggleCodePanel", + ToggleDocumentFill => "ToggleDocumentFill", + DocumentWidthThird => "DocumentWidthThird", + DocumentWidthHalf => "DocumentWidthHalf", + DocumentWidthTwoThirds => "DocumentWidthTwoThirds", RestartSshSession => "RestartSshSession", OpenSshProfiles => "OpenSshProfiles", ScmCommit => "ScmCommit", @@ -410,6 +422,7 @@ impl Command { let tab_bar_left = cfg.tab_bar_position == TabBarPosition::Left; let sidebar_hidden = chrome.rail_collapsed || !tab_bar_left; let right_panel_open = chrome.right_panel_visible; + let document_filled = cfg.document_layout == crate::core::config::DocumentLayout::Fill; let tabs = [ Command::localized(L10nKey::CmdNewTab, NewTab), @@ -473,6 +486,17 @@ impl Command { ToggleRightPanel, ), Command::localized(L10nKey::CmdShowCodePanel, ToggleCodePanel), + Command::localized( + if document_filled { + L10nKey::CmdDocumentDock + } else { + L10nKey::CmdDocumentFill + }, + ToggleDocumentFill, + ), + Command::localized(L10nKey::CmdDocumentWidthThird, DocumentWidthThird), + Command::localized(L10nKey::CmdDocumentWidthHalf, DocumentWidthHalf), + Command::localized(L10nKey::CmdDocumentWidthTwoThirds, DocumentWidthTwoThirds), Command::localized( if tab_bar_left { L10nKey::CmdTabBarMoveToTop diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 2ae44ca1..ddb0a0b6 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -306,7 +306,7 @@ impl Tty7App { crate::ui::app::side_panel_max( window.viewport_size().width.as_f32(), MIN_WIDTH, - self.sidebar_floor(cx), + self.sidebar_floor(cx) + self.document_floor(cx), ) } @@ -406,7 +406,7 @@ impl Tty7App { // below only ever sees a `Window`, and the cap it clamps against has to // be the same one the layout applies or the panel springs back from // wherever it was dropped. - let sidebar_floor = self.sidebar_floor(cx); + let others_floor = self.sidebar_floor(cx) + self.document_floor(cx); let backing = canvas( { let container = container.clone(); @@ -433,7 +433,7 @@ impl Tty7App { let max = crate::ui::app::side_panel_max( window.viewport_size().width.as_f32(), MIN_WIDTH, - sidebar_floor, + others_floor, ); width_cell.set(raw.clamp(MIN_WIDTH, max)); window.refresh(); diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 449523ff..728d4407 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -117,10 +117,20 @@ impl Tty7App { crate::ui::app::side_panel_max( window.viewport_size().width.as_f32(), MIN_SIDEBAR_WIDTH, - self.right_panel_floor(cx), + self.right_panel_floor(cx) + self.document_floor(cx), ) } + /// How wide the sidebar is drawn, given the live cell and the cap the rest + /// of the window leaves it. Read here rather than clamped at each caller so + /// the document column's budget and the sidebar itself can never disagree + /// about how much width is already spoken for. + pub(crate) fn sidebar_px(&self, window: &Window, cx: &gpui::App) -> f32 { + self.sidebar_width + .get() + .clamp(MIN_SIDEBAR_WIDTH, self.sidebar_max_px(window, cx)) + } + pub(crate) fn tab_sidebar( &self, window: &mut Window, @@ -129,8 +139,7 @@ impl Tty7App { let active = self.active; let sf = cx.global::().sidebar; let show_badges = self.mod_hint_badges; - let max_width = self.sidebar_max_px(window, cx); - let width = self.sidebar_width.get().clamp(MIN_SIDEBAR_WIDTH, max_width); + let width = self.sidebar_px(window, cx); let query = self.sidebar_search.read(cx).value().trim().to_lowercase(); // Blanked here, written again from paint: a row filtered out by the // search — or hidden with its collapsed group — must leave no rectangle @@ -1040,7 +1049,7 @@ impl Tty7App { // below only ever sees a `Window`, and the cap it clamps against has to // be the same one the layout applies or the sidebar springs back from // wherever it was dropped. - let panel_floor = self.right_panel_floor(cx); + let others_floor = self.right_panel_floor(cx) + self.document_floor(cx); let backing = canvas( { let container = container.clone(); @@ -1066,7 +1075,7 @@ impl Tty7App { let max = crate::ui::app::side_panel_max( window.viewport_size().width.as_f32(), MIN_SIDEBAR_WIDTH, - panel_floor, + others_floor, ); width_cell.set(raw.clamp(MIN_SIDEBAR_WIDTH, max)); window.refresh(); diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 93fdfa33..25a78fe6 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -488,6 +488,27 @@ pub(crate) fn chrome_tile(button: Button, selected: bool, cx: &gpui::App) -> But chrome_tile_sized(button, TILE_SIZE, TILE_GLYPH, selected, cx) } +/// How wide the two chrome tiles at the trailing end of the title bar are, with +/// the padding around them. +pub(crate) fn trailing_chrome_tiles_w() -> f32 { + let trailing_pad = if cfg!(target_os = "macos") { + tile_trailing_inset() + } else { + 4. + }; + trailing_pad + crate::ui::app::TILE_SIZE + 2. + crate::ui::app::TILE_SIZE +} + +/// The whole trailing cluster: those tiles and the OS window buttons beyond +/// them. +/// +/// Anything else drawn into that end of the title bar has to stop short of it — +/// which for the hoisted document header means the case where the detail panel +/// is closed and the document column runs to the window's right edge. +pub(crate) fn trailing_chrome_w() -> f32 { + trailing_chrome_tiles_w() + crate::ui::app::WINDOW_CONTROLS_W +} + pub(crate) fn chrome_tile_sized( button: Button, tile: f32, @@ -1532,14 +1553,7 @@ impl Tty7App { let corner_w = if panel_w > 0. { 0. } else { - chrome_band_w.unwrap_or_else(|| { - let trailing_pad = if cfg!(target_os = "macos") { - tile_trailing_inset() - } else { - 4. - }; - trailing_pad + crate::ui::app::TILE_SIZE + 2. + crate::ui::app::TILE_SIZE - }) + chrome_band_w.unwrap_or_else(trailing_chrome_tiles_w) }; let fixed_w = 3. * CHIP_GAP + crate::ui::app::TILE_SIZE + corner_w; let chips_avail = (strip_w - px(fixed_w + GRAB_HANDLE_W)).max(px(80.));