From 1b1e52284bb0bebfa2f7b2852c05056c9cfc0537 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:10:52 +0800 Subject: [PATCH] feat(window): dock the code panel and the diff overlay beside the terminal (#625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a file covered the workspace. The terminal underneath kept running and was neither visible nor typeable, so reading a file while an agent talked was a toggle loop: open it, close it to read the reply, open it again. The Files tree already docks; the two surfaces you go to *from* it did not. They dock now, as a flex sibling of the terminal column rather than a narrower overlay — that distinction is the feature. `set_grid_size` is driven by the terminal element's laid-out bounds, so a column takes width away from the grid and the PTY reflows into what is left; a card painted over half the workspace would have left the grid full width with half of it hidden. `overlay_top` stops ordering a pair and starts choosing between them: a column has one child, and two `flex_1` siblings would split it and fight. Fill mode keeps the old vector, the old opaque paint and the old platform hoist untouched, so nothing about today's overlay changes for anyone who picks it. - Half the terminal column by default; drag the divider, double-click it to cycle a third / half / two thirds, or use the palette commands. Two thirds deliberately runs past the half-window cap the side panels obey — only the terminal's floor binds it. - `DOCUMENT_MIN_W` joins the width budget: both side panels reserve it the way they already reserve each other, and the column is derived from the *live* sidebar and panel widths rather than their floors, so a panel someone dragged wider is width the terminal keeps. - A window too narrow to seat both fills for that frame. The fallback is derived at render time and never stored, so widening re-docks on the next frame with nothing to undo. - Fill or dock is per tab, on the header's context menu. Reading a long file over the whole window in one tab while an agent keeps half of another is the normal case, and one global switch made each of those flip the other. A tab that has not been told reads `document_layout` from the config, which is what a fresh tab starts as — and which the menu therefore does not write, since every untold tab is reading it. - Everywhere but macOS the title bar spans the workspace, which left a bar's height of nothing above the column. The header is drawn into it, and behaves like the title bar it now sits in. With the detail panel closed the column reaches the window's right edge, so the header stops short of the trailing chrome through a width the tab strip's own reservation shares. - The docked headers drop the traffic-light inset they never had to clear, and the diff header's branch name becomes the thing that yields so the view toggle and the close tile survive a column's width. New in `config.json`: `document_ratio`, and `document_layout` for what a fresh tab starts as. Four new actions, bindable and unbound by default. --- CHANGELOG.md | 15 + crates/tty7-core/src/core/config.rs | 49 ++ docs/git/diffs.mdx | 5 +- docs/reference/configuration.mdx | 2 + docs/window/side-panel.mdx | 24 + src/core/actions.rs | 4 + src/ui/app.rs | 292 +++++++++-- src/ui/code_editor.rs | 59 ++- src/ui/diff_overlay.rs | 102 +++- src/ui/document_column.rs | 768 ++++++++++++++++++++++++++++ src/ui/i18n/en.rs | 8 + src/ui/i18n/ja.rs | 8 + src/ui/i18n/mod.rs | 8 + src/ui/i18n/zh.rs | 8 + src/ui/keymap.rs | 27 + src/ui/mod.rs | 1 + src/ui/palette.rs | 24 + src/ui/right_panel.rs | 6 +- src/ui/tab_sidebar.rs | 19 +- src/ui/tab_strip.rs | 30 +- 20 files changed, 1368 insertions(+), 91 deletions(-) create mode 100644 src/ui/document_column.rs 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.));