mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Merge pull request #442 from l0ng-ai/fix/issues-426-430
fix: small-window Settings layout, vim caret on a raw pty, wheel zoom, theme preview and cross-locale palette search
This commit is contained in:
@@ -106,6 +106,7 @@ Keys are shown in macOS notation — on Windows and Linux, read <kbd>⌘</kbd> a
|
||||
| <kbd>⌘ F</kbd> | search the scrollback |
|
||||
| <kbd>⌃ R</kbd> | fuzzy-search shell history |
|
||||
| <kbd>⌘ +</kbd> · <kbd>⌘ −</kbd> · <kbd>⌘ 0</kbd> | font size up · down · reset |
|
||||
| <kbd>⌘</kbd> + wheel | zoom the font by scrolling over a terminal |
|
||||
|
||||
**Settings → Keybindings** (<kbd>⌘ ,</kbd>) lists every shortcut. Click one,
|
||||
press the new keys (<kbd>Esc</kbd> cancels, <kbd>Backspace</kbd> resets to
|
||||
|
||||
@@ -103,6 +103,7 @@ Aider、Amp、OpenCode 等共 18 个)并在其外围加功能 —— 绝不包
|
||||
| <kbd>⌘ F</kbd> | 搜索 scrollback |
|
||||
| <kbd>⌃ R</kbd> | 模糊搜索 shell 历史 |
|
||||
| <kbd>⌘ +</kbd> · <kbd>⌘ −</kbd> · <kbd>⌘ 0</kbd> | 字号增大 · 减小 · 重置 |
|
||||
| <kbd>⌘</kbd> + 滚轮 | 在终端上滚动缩放字号,演示时随手放大 |
|
||||
|
||||
**设置 → 按键绑定**(<kbd>⌘ ,</kbd>)列出全部快捷键。点一行、按下新键即可
|
||||
(<kbd>Esc</kbd> 取消,<kbd>Backspace</kbd> 恢复默认),改完立即生效。窗格缩放与
|
||||
|
||||
@@ -10,13 +10,19 @@
|
||||
//! on every batch of pty output) draws that stray cursor for a frame, and a
|
||||
//! TUI that repaints on a spinner — Codex while it works — produces one every
|
||||
//! spinner tick, which reads as a second cursor blinking in the wrong place.
|
||||
//! macOS never shows it: no ConPTY sits in between, and the TUI itself always
|
||||
//! moves the cursor before it shows it.
|
||||
//!
|
||||
//! [`ParkedCursorScanner`] finds those hide/show pairs in the byte stream and
|
||||
//! [`ParkedCursorRepair`] restores the cell the cursor stood on when it went
|
||||
//! invisible, which is the cell the correcting frame would have moved it back
|
||||
//! to anyway.
|
||||
//!
|
||||
//! Only conhost parks a cursor, so the reader runs this on Windows alone
|
||||
//! (`RemoteTerminal::REPAIR_PARKED_CURSOR`). Off it the pty is raw and the
|
||||
//! application's cursor is the real one: a TUI is free to end a repaint on the
|
||||
//! text it just wrote and then echo the next keystroke straight after it, with
|
||||
//! no positioning of its own — vim opens its `:` command line exactly that way,
|
||||
//! which a repair on a raw pty turns into `wq!` landing on the row being edited
|
||||
//! (#430).
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
||||
+96
-8
@@ -553,6 +553,17 @@ impl RemoteTerminal {
|
||||
term.set_options(terminal_config_from_user(user_config));
|
||||
}
|
||||
|
||||
/// Whether this build puts back the cursor a repaint parked — see
|
||||
/// [`crate::terminal::parked_cursor`].
|
||||
///
|
||||
/// Only conhost parks one, so like `conpty_resize` the repair is Windows'
|
||||
/// alone. On a raw pty the application owns the cursor and is free to end a
|
||||
/// repaint on the text it just wrote and then echo the next keystroke
|
||||
/// straight after it, with no positioning of its own: vim opens its command
|
||||
/// line that way, and putting the cursor back on the cell the repaint hid it
|
||||
/// on drops the `wq!` typed next onto the row being edited (#430).
|
||||
const REPAIR_PARKED_CURSOR: bool = cfg!(windows);
|
||||
|
||||
fn spawn_reader(
|
||||
term: Arc<FairMutex<Term<EventProxy>>>,
|
||||
proxy: EventProxy,
|
||||
@@ -637,7 +648,9 @@ impl RemoteTerminal {
|
||||
// emulator to the cut, act on the state that
|
||||
// sequence left behind, carry on.
|
||||
let mut cuts: Vec<(usize, CursorCut)> = Vec::new();
|
||||
cursor_scan.feed(&out_batch, |off, c| cuts.push((off, c)));
|
||||
if Self::REPAIR_PARKED_CURSOR {
|
||||
cursor_scan.feed(&out_batch, |off, c| cuts.push((off, c)));
|
||||
}
|
||||
{
|
||||
let t0 = trace.then(std::time::Instant::now);
|
||||
let mut term = term.lock();
|
||||
@@ -3067,6 +3080,7 @@ mod tests {
|
||||
/// Feeds one conhost-shaped repaint and reports the cell the cursor ends on,
|
||||
/// waiting for the `X` the frame paints so the reader is known to be done.
|
||||
fn cursor_after_conpty_frame(frame: &[u8]) -> (i32, usize) {
|
||||
crate::core::config::pin_test_config_dir();
|
||||
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
|
||||
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
|
||||
|
||||
@@ -3097,14 +3111,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_conpty_frame_that_shows_the_cursor_over_an_erase_keeps_the_cell_it_hid_on() {
|
||||
assert_eq!(
|
||||
cursor_after_conpty_frame(
|
||||
b"\x1b[?25l\x1b[20;2HX\x1b[K\x1b[m\x1b[22;42H\x1b[K\x1b[?25h"
|
||||
),
|
||||
(5, 3),
|
||||
"conhost parked the cursor on the cell it erased last; the cursor \
|
||||
belongs where it was when the repaint hid it"
|
||||
let got = cursor_after_conpty_frame(
|
||||
b"\x1b[?25l\x1b[20;2HX\x1b[K\x1b[m\x1b[22;42H\x1b[K\x1b[?25h",
|
||||
);
|
||||
if RemoteTerminal::REPAIR_PARKED_CURSOR {
|
||||
assert_eq!(
|
||||
got,
|
||||
(5, 3),
|
||||
"conhost parked the cursor on the cell it erased last; the cursor \
|
||||
belongs where it was when the repaint hid it"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
got,
|
||||
(21, 41),
|
||||
"with no conhost in between the stream is the application's own, \
|
||||
and the cell it left the cursor on is the cell it meant"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3118,6 +3142,70 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #430. Vim opens its command line with exactly the shape the parked
|
||||
/// -cursor scanner calls parked — hide, move around to paint, end on the `:`
|
||||
/// it wrote — and then echoes every following keystroke as a bare byte at
|
||||
/// wherever that left the cursor. Putting the cursor back on a raw pty
|
||||
/// therefore does not straighten out a stray caret, it drops `wq!` onto the
|
||||
/// row vim was editing. Bytes below are a capture of vim 9 on a 20x11 pty.
|
||||
#[test]
|
||||
fn a_raw_pty_repaint_keeps_the_cursor_the_frame_left_so_the_echo_lands_on_it() {
|
||||
crate::core::config::pin_test_config_dir();
|
||||
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
|
||||
let term = RemoteTerminal::from_stream(client_side, TermSize::new(20, 11)).unwrap();
|
||||
|
||||
let mut stream: Vec<u8> = Vec::new();
|
||||
// `vim test.md`: the alternate screen, the file, cursor home.
|
||||
stream.extend_from_slice(b"\x1b[?1049h\x1b[H\x1b[2J\x1b[1;1H123456789\x1b[1;1H");
|
||||
// Esc, then `:` — two bracketed repaints, the second ending on the `:`
|
||||
// vim wrote at the head of the command line.
|
||||
stream.extend_from_slice(b"\x1b[?25l\x1b[m\x1b[11;10H^[\x1b[1;1H\x1b[?25h");
|
||||
stream.extend_from_slice(b"\x1b[?25l\x1b[11;10H \x1b[1;1H\x07\x1b[?25h");
|
||||
stream.extend_from_slice(
|
||||
b"\x1b[?25l\x1b[11;10H:\x1b[1;1H\x1b[11;1H\x1b[K\x1b[11;1H:\x1b[?25h",
|
||||
);
|
||||
// `w`, `q`, `!`: vim echoes them with no positioning of their own.
|
||||
stream.extend_from_slice(b"wq!");
|
||||
DaemonMsg::Output(stream).encode(&mut daemon_side).unwrap();
|
||||
daemon_side.flush().unwrap();
|
||||
|
||||
let row = |t: &Term<EventProxy>, line: i32| -> String {
|
||||
(0..20)
|
||||
.map(|col| {
|
||||
t.grid()[alacritty_terminal::index::Line(line)]
|
||||
[alacritty_terminal::index::Column(col)]
|
||||
.c
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
// The whole batch is applied under one lock, so the `:` landing on the
|
||||
// command line means every byte after it landed too.
|
||||
let mut command_line = String::new();
|
||||
let mut edited = String::new();
|
||||
for _ in 0..600 {
|
||||
{
|
||||
let t = term.term.lock();
|
||||
command_line = row(&t, 10);
|
||||
edited = row(&t, 0);
|
||||
}
|
||||
if command_line.starts_with(':') {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(
|
||||
command_line, ":wq!",
|
||||
"the keystrokes belong after the `:` the repaint ended on"
|
||||
);
|
||||
assert_eq!(
|
||||
edited, "123456789",
|
||||
"and nothing of them belongs on the row vim was editing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_resize_reasserts_geometry_after_a_late_size_frame() {
|
||||
use alacritty_terminal::grid::Dimensions as _;
|
||||
|
||||
+113
-2
@@ -23,8 +23,9 @@ use super::reverse_search::{self, ReverseSearch};
|
||||
use super::search::{LinkTarget, SearchState};
|
||||
use super::typeahead::{RawInput, Typeahead};
|
||||
use crate::core::actions::{
|
||||
CloseActiveTab, ForkAgentSessionDown, ForkAgentSessionLeft, ForkAgentSessionRight,
|
||||
ForkAgentSessionUp, NewTab, SendBackTab, SendTab, SplitDown, SplitRight, ToggleMaximizePane,
|
||||
CloseActiveTab, DecreaseFontSize, ForkAgentSessionDown, ForkAgentSessionLeft,
|
||||
ForkAgentSessionRight, ForkAgentSessionUp, IncreaseFontSize, NewTab, SendBackTab, SendTab,
|
||||
SplitDown, SplitRight, ToggleMaximizePane,
|
||||
};
|
||||
use crate::core::config::{BellMode, Config, NotifyMode};
|
||||
use crate::daemon::protocol::{RemoteContext, ShellSpec};
|
||||
@@ -113,6 +114,10 @@ const SCROLL_ANIM_MIN_JUMP: f32 = 1.0;
|
||||
/// to bridge the gaps in a momentum tail, short enough that reaching for the
|
||||
/// wheel right after a swipe is not mistaken for more of the swipe.
|
||||
const SCROLL_GESTURE_IDLE: std::time::Duration = std::time::Duration::from_millis(150);
|
||||
/// How far a trackpad has to travel, in lines, to earn one font-size step while
|
||||
/// the platform modifier is held. A wheel detent is one step on its own, so
|
||||
/// this only ever applies to the continuous stream a gesture produces.
|
||||
const ZOOM_SCROLL_LINES: f32 = 3.0;
|
||||
|
||||
fn cwd_is_on_host(pane_runs_remotely: bool, host_is_local: bool) -> bool {
|
||||
match pane_runs_remotely {
|
||||
@@ -160,6 +165,11 @@ pub struct TerminalView {
|
||||
/// modifiers the user actually held.
|
||||
context_menu_allowed: bool,
|
||||
scroll_debt: f32,
|
||||
/// Lines travelled under the zoom modifier that have not yet added up to a
|
||||
/// font-size step. Kept apart from [`scroll_debt`](Self::scroll_debt) so
|
||||
/// letting go of the modifier mid-gesture cannot hand the leftovers of one
|
||||
/// to the other.
|
||||
zoom_debt: f32,
|
||||
pub(super) scroll_frac: f32,
|
||||
pub search: Option<SearchState>,
|
||||
pub cursor_visible: bool,
|
||||
@@ -1095,6 +1105,7 @@ impl TerminalView {
|
||||
link_modifier_down: false,
|
||||
context_menu_allowed: true,
|
||||
scroll_debt: 0.,
|
||||
zoom_debt: 0.,
|
||||
scroll_frac: 0.,
|
||||
search: None,
|
||||
cursor_visible: true,
|
||||
@@ -4259,6 +4270,14 @@ impl TerminalView {
|
||||
}
|
||||
|
||||
fn on_scroll(&mut self, ev: &ScrollWheelEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// The platform modifier alone turns the wheel into a zoom, the way it
|
||||
// does in a browser. Any other modifier alongside it is somebody else's
|
||||
// gesture — shift in particular is the escape hatch that scrolls the
|
||||
// scrollback out from under a mouse-reporting program.
|
||||
if ev.modifiers.secondary() && ev.modifiers.number_of_modifiers() == 1 {
|
||||
self.zoom_scroll(ev, window, cx);
|
||||
return;
|
||||
}
|
||||
let mult = cx.global::<Config>().mouse_scroll_multiplier;
|
||||
let raw = match ev.delta {
|
||||
ScrollDelta::Lines(p) => p.y,
|
||||
@@ -4293,6 +4312,34 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize the terminal font by whole steps under the platform modifier.
|
||||
///
|
||||
/// The event never reaches the buffer, and never reaches the program
|
||||
/// running in it either: zooming is chrome, and showing a pane to someone
|
||||
/// standing behind you has to work the same whether or not what is running
|
||||
/// asked for the wheel. Steps go out as the same actions the keyboard and
|
||||
/// the View menu send, so the min/max clamp and the saved setting live in
|
||||
/// one place — [`Tty7App::change_font_size`](crate::ui::app::Tty7App).
|
||||
fn zoom_scroll(&mut self, ev: &ScrollWheelEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Whatever the scrollback still had in flight is dropped: it was
|
||||
// travelling in lines of a font that is about to change size.
|
||||
self.cancel_scroll_anim();
|
||||
let lines = match ev.delta {
|
||||
ScrollDelta::Lines(p) => p.y,
|
||||
ScrollDelta::Pixels(p) => p.y.as_f32() / self.line_height.as_f32(),
|
||||
};
|
||||
let gesturing = self.track_scroll_gesture(ev.touch_phase);
|
||||
let (steps, debt) = zoom_scroll_steps(lines, self.zoom_debt, gesturing);
|
||||
self.zoom_debt = debt;
|
||||
for _ in 0..steps.unsigned_abs() {
|
||||
if steps > 0 {
|
||||
window.dispatch_action(Box::new(IncreaseFontSize), cx);
|
||||
} else {
|
||||
window.dispatch_action(Box::new(DecreaseFontSize), cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Track whether the pointing device is mid-gesture, which is what tells a
|
||||
/// trackpad apart from a wheel.
|
||||
///
|
||||
@@ -5871,6 +5918,30 @@ fn wrapped_click_index(
|
||||
}
|
||||
}
|
||||
|
||||
/// How many font-size steps a zoom event is worth, and what is left over for
|
||||
/// the next one.
|
||||
///
|
||||
/// A wheel detent is a discrete click of intent, so it is one step whatever the
|
||||
/// platform says it covers — macOS calls a single notch five lines, and five
|
||||
/// points of font per notch would be unusable. A trackpad has no detents and
|
||||
/// spends a flick over dozens of events, so those accumulate and only pay out
|
||||
/// once the fingers have travelled [`ZOOM_SCROLL_LINES`].
|
||||
fn zoom_scroll_steps(lines: f32, debt: f32, gesturing: bool) -> (i32, f32) {
|
||||
if !gesturing {
|
||||
let step = if lines > 0. {
|
||||
1
|
||||
} else if lines < 0. {
|
||||
-1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
return (step, 0.);
|
||||
}
|
||||
let total = debt + lines;
|
||||
let steps = (total / ZOOM_SCROLL_LINES).trunc();
|
||||
(steps as i32, total - steps * ZOOM_SCROLL_LINES)
|
||||
}
|
||||
|
||||
fn smooth_scroll_step(offset: usize, frac: f32, delta: f32, max: usize) -> (i32, f32) {
|
||||
let pos = (offset as f32 + frac + delta).clamp(0., max as f32);
|
||||
let new_offset = pos.floor();
|
||||
@@ -9003,6 +9074,46 @@ mod gpui_tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Zooming has to take the wheel away from the buffer entirely, or the
|
||||
/// grid would slide under the pointer while the font changed size.
|
||||
#[gpui::test]
|
||||
fn the_zoom_modifier_takes_the_wheel_off_the_scrollback(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, w, cx| {
|
||||
scroll_into_history(view, 10);
|
||||
let mut ev = notch(view, -4.9);
|
||||
ev.modifiers = Modifiers::secondary_key();
|
||||
view.on_scroll(&ev, w, cx);
|
||||
assert_eq!(display_offset(view), 10, "the wheel reached the grid");
|
||||
assert!(view.scroll_anim.is_none(), "and queued more of it");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A detent is one step however many lines the platform bills it as —
|
||||
/// macOS calls a single notch five.
|
||||
#[test]
|
||||
fn a_wheel_detent_zooms_by_exactly_one_step() {
|
||||
assert_eq!(zoom_scroll_steps(4.9, 0., false), (1, 0.));
|
||||
assert_eq!(zoom_scroll_steps(-4.9, 0., false), (-1, 0.));
|
||||
assert_eq!(zoom_scroll_steps(0., 0., false), (0, 0.));
|
||||
}
|
||||
|
||||
/// A trackpad has no detents, so a flick arrives as a stream of slivers.
|
||||
/// Paying out a step per sliver would run the font from end to end.
|
||||
#[test]
|
||||
fn a_trackpad_flick_adds_up_to_whole_steps() {
|
||||
let (mut debt, mut steps) = (0., 0);
|
||||
for _ in 0..20 {
|
||||
let (s, d) = zoom_scroll_steps(1. / 3., debt, true);
|
||||
steps += s;
|
||||
debt = d;
|
||||
}
|
||||
assert_eq!(steps, 2, "twenty thirds of a line is two steps, not twenty");
|
||||
assert!(debt > 0., "and the remainder was dropped instead of kept");
|
||||
}
|
||||
|
||||
/// The one thing typing at a prompt must never do is land where the
|
||||
/// person typing cannot see it.
|
||||
#[gpui::test]
|
||||
|
||||
+65
-4
@@ -413,6 +413,10 @@ pub struct Tty7App {
|
||||
_appearance_watch: Subscription,
|
||||
palette: Option<Entity<PaletteView>>,
|
||||
palette_sub: Option<Subscription>,
|
||||
/// Preset that was live when the palette's theme picker started previewing.
|
||||
/// `Some` means the theme on screen is a preview that was never written to
|
||||
/// disk, and closing the palette without confirming puts this one back.
|
||||
theme_preview_restore: Option<String>,
|
||||
pub(crate) closed: Vec<SessionTab>,
|
||||
pub(crate) renaming: Option<Renaming>,
|
||||
pub(crate) worktree_prompt: Option<crate::ui::worktree_prompt::WorktreePrompt>,
|
||||
@@ -436,9 +440,13 @@ pub struct Tty7App {
|
||||
pub(crate) sidebar_dragging: Rc<Cell<bool>>,
|
||||
/// How much width a settings row will actually get, measured once per
|
||||
/// render. `settings_row` is called from page builders that never see the
|
||||
/// window, and the answer differs per page — the SSH page spends 500px on
|
||||
/// its two lists first.
|
||||
/// window, and the answer differs per page — the SSH page spends a host
|
||||
/// list on top of the nav before the row gets anything.
|
||||
pub(crate) settings_row_width: Cell<f32>,
|
||||
/// The window width the settings chrome sized itself against, measured in
|
||||
/// the same pass. The pages that render their own chrome — the SSH host
|
||||
/// list, the theme panel — are as blind to the window as `settings_row` is.
|
||||
pub(crate) settings_viewport_w: Cell<f32>,
|
||||
/// Cleared at the top of every settings render, then set by the first row
|
||||
/// the live search matched, so exactly one row per page carries the anchor
|
||||
/// the page scrolls to.
|
||||
@@ -936,6 +944,7 @@ impl Tty7App {
|
||||
_appearance_watch: appearance_watch,
|
||||
palette: None,
|
||||
palette_sub: None,
|
||||
theme_preview_restore: None,
|
||||
closed: Vec::new(),
|
||||
renaming: None,
|
||||
worktree_prompt: None,
|
||||
@@ -966,6 +975,7 @@ impl Tty7App {
|
||||
sidebar_width: Rc::new(Cell::new(sidebar_width)),
|
||||
sidebar_dragging: Rc::new(Cell::new(false)),
|
||||
settings_row_width: Cell::new(f32::MAX),
|
||||
settings_viewport_w: Cell::new(f32::MAX),
|
||||
settings_hit_anchored: Cell::new(false),
|
||||
right_panel_width: Rc::new(Cell::new(right_panel_width)),
|
||||
right_panel_dragging: Rc::new(Cell::new(false)),
|
||||
@@ -1533,6 +1543,14 @@ impl Tty7App {
|
||||
}
|
||||
|
||||
pub(crate) fn set_preset(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// A confirmed pick ends any preview: there is nothing left to roll back.
|
||||
self.theme_preview_restore = None;
|
||||
self.write_preset(id, cx);
|
||||
self.after_theme_change(window, cx);
|
||||
}
|
||||
|
||||
/// Points whichever preset slot is live at `id`, in memory only.
|
||||
fn write_preset(&mut self, id: &str, cx: &mut Context<Self>) {
|
||||
let dark_now = crate::ui::theme::system_dark(cx);
|
||||
let cfg = cx.global_mut::<Config>();
|
||||
if !cfg.theme_follow_system {
|
||||
@@ -1542,7 +1560,27 @@ impl Tty7App {
|
||||
} else {
|
||||
cfg.theme_preset_light = id.to_string();
|
||||
}
|
||||
self.after_theme_change(window, cx);
|
||||
}
|
||||
|
||||
/// Shows a preset for as long as the palette's theme picker is open, so
|
||||
/// arrowing through the list is how you find out what a theme looks like.
|
||||
/// Nothing is written to `config.json` until the pick is confirmed.
|
||||
pub(crate) fn preview_preset(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.theme_preview_restore.is_none() {
|
||||
self.theme_preview_restore = Some(crate::ui::theme::effective_preset_id(cx));
|
||||
}
|
||||
self.write_preset(id, cx);
|
||||
self.apply_theme_change(false, window, cx);
|
||||
}
|
||||
|
||||
/// Puts back the preset that was live before the preview started. A no-op
|
||||
/// when nothing is being previewed.
|
||||
pub(crate) fn cancel_preset_preview(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(id) = self.theme_preview_restore.take() else {
|
||||
return;
|
||||
};
|
||||
self.write_preset(&id, cx);
|
||||
self.apply_theme_change(false, window, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn set_slot_preset(
|
||||
@@ -1609,9 +1647,18 @@ impl Tty7App {
|
||||
}
|
||||
|
||||
fn after_theme_change(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.apply_theme_change(true, window, cx);
|
||||
}
|
||||
|
||||
/// Repaints everything a theme change touches. `persist` is false for a
|
||||
/// palette preview, which repaints on every arrow key and must not turn
|
||||
/// each of those keystrokes into a `config.json` write.
|
||||
fn apply_theme_change(&mut self, persist: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
apply_theme(Some(window), cx);
|
||||
set_menus(cx);
|
||||
cx.global::<Config>().save();
|
||||
if persist {
|
||||
cx.global::<Config>().save();
|
||||
}
|
||||
self.rebuild_theme_editor(window, cx);
|
||||
self.sync_window_opacity_slider(window, cx);
|
||||
cx.notify();
|
||||
@@ -3749,16 +3796,30 @@ impl Tty7App {
|
||||
match ev {
|
||||
PaletteEvent::Confirm(kind) => {
|
||||
let kind = kind.clone();
|
||||
// The picker is already showing this theme; keep it through the
|
||||
// close instead of reverting and re-applying it.
|
||||
if matches!(kind, CommandKind::SetTheme(_)) {
|
||||
self.theme_preview_restore = None;
|
||||
}
|
||||
self.close_palette(window, cx);
|
||||
self.run_command(kind, window, cx);
|
||||
}
|
||||
PaletteEvent::Dismiss => self.close_palette(window, cx),
|
||||
PaletteEvent::PreviewTheme(i) => {
|
||||
if let Some(id) = crate::ui::presets::all(cx).get(*i).map(|t| t.id.clone()) {
|
||||
self.preview_preset(&id, window, cx);
|
||||
}
|
||||
}
|
||||
PaletteEvent::CancelThemePreview => self.cancel_preset_preview(window, cx),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn close_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.palette = None;
|
||||
self.palette_sub = None;
|
||||
// A previewed theme was never persisted: closing the palette any way
|
||||
// other than confirming the pick puts the old one back.
|
||||
self.cancel_preset_preview(window, cx);
|
||||
self.focus_active(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -1127,6 +1127,25 @@ pub fn t_fmt(key: L10nKey, args: &[(&str, &str)]) -> String {
|
||||
apply_template(t(key), args, None)
|
||||
}
|
||||
|
||||
/// The same key as every *other* locale words it.
|
||||
///
|
||||
/// Fingers learn a command's name once. Someone running the UI in Chinese who
|
||||
/// picked the habit up in English still types `theme`, and until this existed
|
||||
/// the palette answered "no matching commands" — the label it was matching
|
||||
/// against was the only one that existed. Callers keep these as hidden search
|
||||
/// aliases; nothing renders them, so the wording on screen never changes.
|
||||
pub fn alias_translations(key: L10nKey) -> Vec<&'static str> {
|
||||
let shown = t(key);
|
||||
let mut out = Vec::with_capacity(SUPPORTED_LANGUAGES.len() - 1);
|
||||
for idx in 0..SUPPORTED_LANGUAGES.len() {
|
||||
let text = translate(idx, key);
|
||||
if text != shown && !out.contains(&text) {
|
||||
out.push(text);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PluralCategory {
|
||||
Zero,
|
||||
|
||||
+225
-86
@@ -12,7 +12,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::core::config::{Config, RightPanelTab, TabBarPosition};
|
||||
use crate::core::ssh_profile::parse_quick_connect;
|
||||
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
||||
use crate::ui::i18n::{L10nKey, alias_translations, t, t_fmt};
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum CommandKind {
|
||||
@@ -317,6 +317,10 @@ pub struct ChromeState {
|
||||
pub struct Command {
|
||||
pub title: String,
|
||||
pub subtitle: Option<String>,
|
||||
/// Text the query may match but the row never shows: the same label as
|
||||
/// every other locale words it, plus the stable command id. Built once,
|
||||
/// with the entry — the filter runs on every keystroke.
|
||||
pub aliases: Vec<&'static str>,
|
||||
pub kind: CommandKind,
|
||||
pub group: CommandGroup,
|
||||
}
|
||||
@@ -326,11 +330,22 @@ impl Command {
|
||||
Self {
|
||||
title: title.into(),
|
||||
subtitle: None,
|
||||
// The id is English and hyphenated ("change-theme"), which is close
|
||||
// enough to what someone reaching past a translated label types.
|
||||
aliases: kind.id().into_iter().collect(),
|
||||
kind,
|
||||
group: CommandGroup::Application,
|
||||
}
|
||||
}
|
||||
|
||||
/// A command whose label comes out of the locale table, and which can
|
||||
/// therefore also be found by the wording any other locale would show.
|
||||
pub fn localized(key: L10nKey, kind: CommandKind) -> Self {
|
||||
let mut cmd = Self::new(t(key), kind);
|
||||
cmd.aliases.extend(alias_translations(key));
|
||||
cmd
|
||||
}
|
||||
|
||||
pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
|
||||
self.subtitle = Some(subtitle.into());
|
||||
self
|
||||
@@ -349,129 +364,129 @@ impl Command {
|
||||
let right_panel_open = chrome.right_panel_visible;
|
||||
|
||||
let tabs = [
|
||||
Command::new(t(L10nKey::CmdNewTab), NewTab),
|
||||
Command::new(t(L10nKey::CmdNewWorktreeTab), NewWorktreeTab)
|
||||
Command::localized(L10nKey::CmdNewTab, NewTab),
|
||||
Command::localized(L10nKey::CmdNewWorktreeTab, NewWorktreeTab)
|
||||
.with_subtitle(t(L10nKey::CmdNewWorktreeTabSubtitle)),
|
||||
Command::new(t(L10nKey::CmdRenameTab), RenameTab),
|
||||
Command::new(t(L10nKey::CmdSplitRight), SplitRight),
|
||||
Command::new(t(L10nKey::CmdSplitDown), SplitDown),
|
||||
Command::new(t(L10nKey::CmdZoomPane), ToggleMaximizePane),
|
||||
Command::new(t(L10nKey::CmdNextPane), NextPane),
|
||||
Command::new(t(L10nKey::CmdPreviousPane), PrevPane),
|
||||
Command::new(t(L10nKey::CmdFocusPaneLeft), FocusPaneLeft),
|
||||
Command::new(t(L10nKey::CmdFocusPaneRight), FocusPaneRight),
|
||||
Command::new(t(L10nKey::CmdFocusPaneUp), FocusPaneUp),
|
||||
Command::new(t(L10nKey::CmdFocusPaneDown), FocusPaneDown),
|
||||
Command::new(t(L10nKey::CmdResizePaneLeft), ResizePaneLeft),
|
||||
Command::new(t(L10nKey::CmdResizePaneRight), ResizePaneRight),
|
||||
Command::new(t(L10nKey::CmdResizePaneUp), ResizePaneUp),
|
||||
Command::new(t(L10nKey::CmdResizePaneDown), ResizePaneDown),
|
||||
Command::new(t(L10nKey::CmdSwapPaneNext), SwapPaneNext),
|
||||
Command::new(t(L10nKey::CmdSwapPanePrevious), SwapPanePrev),
|
||||
Command::new(t(L10nKey::CmdNextTab), NextTab),
|
||||
Command::new(t(L10nKey::CmdPreviousTab), PrevTab),
|
||||
Command::new(t(L10nKey::CmdCopyWorkingDirectory), CopyWorkingDirectory),
|
||||
Command::new(t(L10nKey::CmdCopySessionId), CopyAgentSessionId)
|
||||
Command::localized(L10nKey::CmdRenameTab, RenameTab),
|
||||
Command::localized(L10nKey::CmdSplitRight, SplitRight),
|
||||
Command::localized(L10nKey::CmdSplitDown, SplitDown),
|
||||
Command::localized(L10nKey::CmdZoomPane, ToggleMaximizePane),
|
||||
Command::localized(L10nKey::CmdNextPane, NextPane),
|
||||
Command::localized(L10nKey::CmdPreviousPane, PrevPane),
|
||||
Command::localized(L10nKey::CmdFocusPaneLeft, FocusPaneLeft),
|
||||
Command::localized(L10nKey::CmdFocusPaneRight, FocusPaneRight),
|
||||
Command::localized(L10nKey::CmdFocusPaneUp, FocusPaneUp),
|
||||
Command::localized(L10nKey::CmdFocusPaneDown, FocusPaneDown),
|
||||
Command::localized(L10nKey::CmdResizePaneLeft, ResizePaneLeft),
|
||||
Command::localized(L10nKey::CmdResizePaneRight, ResizePaneRight),
|
||||
Command::localized(L10nKey::CmdResizePaneUp, ResizePaneUp),
|
||||
Command::localized(L10nKey::CmdResizePaneDown, ResizePaneDown),
|
||||
Command::localized(L10nKey::CmdSwapPaneNext, SwapPaneNext),
|
||||
Command::localized(L10nKey::CmdSwapPanePrevious, SwapPanePrev),
|
||||
Command::localized(L10nKey::CmdNextTab, NextTab),
|
||||
Command::localized(L10nKey::CmdPreviousTab, PrevTab),
|
||||
Command::localized(L10nKey::CmdCopyWorkingDirectory, CopyWorkingDirectory),
|
||||
Command::localized(L10nKey::CmdCopySessionId, CopyAgentSessionId)
|
||||
.with_subtitle(t(L10nKey::CmdCopySessionIdSubtitle)),
|
||||
Command::new(t(L10nKey::CmdForkSession), ForkAgentSession)
|
||||
Command::localized(L10nKey::CmdForkSession, ForkAgentSession)
|
||||
.with_subtitle(t(L10nKey::CmdForkSessionSubtitle)),
|
||||
Command::new(t(L10nKey::CmdMarkTabAsUnread), MarkTabUnread),
|
||||
Command::new(t(L10nKey::CmdClosePaneTab), ClosePane),
|
||||
Command::new(t(L10nKey::CmdCloseOtherTabs), CloseOtherTabs),
|
||||
Command::new(t(L10nKey::CmdCloseTabsToTheRight), CloseTabsToTheRight),
|
||||
Command::new(t(L10nKey::CmdReopenClosedTab), ReopenClosedTab),
|
||||
Command::localized(L10nKey::CmdMarkTabAsUnread, MarkTabUnread),
|
||||
Command::localized(L10nKey::CmdClosePaneTab, ClosePane),
|
||||
Command::localized(L10nKey::CmdCloseOtherTabs, CloseOtherTabs),
|
||||
Command::localized(L10nKey::CmdCloseTabsToTheRight, CloseTabsToTheRight),
|
||||
Command::localized(L10nKey::CmdReopenClosedTab, ReopenClosedTab),
|
||||
];
|
||||
|
||||
let workspaces = [
|
||||
Command::new(t(L10nKey::CmdNewWorkspace), NewWorkspace),
|
||||
Command::new(t(L10nKey::CmdSwitchWorkspace), OpenWorkspacePicker),
|
||||
Command::new(t(L10nKey::CmdRenameWorkspace), RenameWorkspace),
|
||||
Command::new(t(L10nKey::CmdStopWorkspace), StopWorkspace)
|
||||
Command::localized(L10nKey::CmdNewWorkspace, NewWorkspace),
|
||||
Command::localized(L10nKey::CmdSwitchWorkspace, OpenWorkspacePicker),
|
||||
Command::localized(L10nKey::CmdRenameWorkspace, RenameWorkspace),
|
||||
Command::localized(L10nKey::CmdStopWorkspace, StopWorkspace)
|
||||
.with_subtitle(t(L10nKey::CmdStopWorkspaceSubtitle)),
|
||||
Command::new(t(L10nKey::CmdDeleteWorkspace), DeleteWorkspace)
|
||||
Command::localized(L10nKey::CmdDeleteWorkspace, DeleteWorkspace)
|
||||
.with_subtitle(t(L10nKey::CmdDeleteWorkspaceSubtitle)),
|
||||
];
|
||||
|
||||
let view = [
|
||||
Command::new(
|
||||
Command::localized(
|
||||
if sidebar_hidden {
|
||||
t(L10nKey::CmdShowLeftSidebar)
|
||||
L10nKey::CmdShowLeftSidebar
|
||||
} else {
|
||||
t(L10nKey::CmdHideLeftSidebar)
|
||||
L10nKey::CmdHideLeftSidebar
|
||||
},
|
||||
ToggleLeftPanel,
|
||||
),
|
||||
Command::new(
|
||||
Command::localized(
|
||||
if right_panel_open {
|
||||
t(L10nKey::CmdHideRightPanel)
|
||||
L10nKey::CmdHideRightPanel
|
||||
} else {
|
||||
t(L10nKey::CmdShowRightPanel)
|
||||
L10nKey::CmdShowRightPanel
|
||||
},
|
||||
ToggleRightPanel,
|
||||
),
|
||||
Command::new(t(L10nKey::CmdShowCodePanel), ToggleCodePanel),
|
||||
Command::new(
|
||||
Command::localized(L10nKey::CmdShowCodePanel, ToggleCodePanel),
|
||||
Command::localized(
|
||||
if tab_bar_left {
|
||||
t(L10nKey::CmdTabBarMoveToTop)
|
||||
L10nKey::CmdTabBarMoveToTop
|
||||
} else {
|
||||
t(L10nKey::CmdTabBarMoveToLeftSidebar)
|
||||
L10nKey::CmdTabBarMoveToLeftSidebar
|
||||
},
|
||||
ToggleTabSidebar,
|
||||
),
|
||||
Command::new(
|
||||
t(L10nKey::CmdRightPanelInfo),
|
||||
Command::localized(
|
||||
L10nKey::CmdRightPanelInfo,
|
||||
ShowRightPanel(RightPanelTab::Info),
|
||||
),
|
||||
Command::new(
|
||||
t(L10nKey::CmdRightPanelChanges),
|
||||
Command::localized(
|
||||
L10nKey::CmdRightPanelChanges,
|
||||
ShowRightPanel(RightPanelTab::Changes),
|
||||
),
|
||||
Command::new(
|
||||
t(L10nKey::CmdRightPanelFiles),
|
||||
Command::localized(
|
||||
L10nKey::CmdRightPanelFiles,
|
||||
ShowRightPanel(RightPanelTab::Files),
|
||||
),
|
||||
Command::new(t(L10nKey::CmdChangeTheme), OpenThemePicker),
|
||||
Command::new(t(L10nKey::CmdResetFontSize), ResetFontSize),
|
||||
Command::new(t(L10nKey::CmdEnterFullScreen), ToggleFullscreen),
|
||||
Command::localized(L10nKey::CmdChangeTheme, OpenThemePicker),
|
||||
Command::localized(L10nKey::CmdResetFontSize, ResetFontSize),
|
||||
Command::localized(L10nKey::CmdEnterFullScreen, ToggleFullscreen),
|
||||
];
|
||||
|
||||
let terminal = [
|
||||
Command::new(t(L10nKey::CmdClearScrollback), ClearTerminal),
|
||||
Command::new(t(L10nKey::CmdFindInTerminal), FindInTerminal),
|
||||
Command::new(t(L10nKey::CmdFindNext), FindNext),
|
||||
Command::new(t(L10nKey::CmdFindPrevious), FindPrevious),
|
||||
Command::new(t(L10nKey::CmdCopy), CopyText),
|
||||
Command::new(t(L10nKey::CmdCut), CutText),
|
||||
Command::new(t(L10nKey::CmdPaste), PasteText),
|
||||
Command::new(t(L10nKey::CmdSelectAll), SelectAllText),
|
||||
Command::localized(L10nKey::CmdClearScrollback, ClearTerminal),
|
||||
Command::localized(L10nKey::CmdFindInTerminal, FindInTerminal),
|
||||
Command::localized(L10nKey::CmdFindNext, FindNext),
|
||||
Command::localized(L10nKey::CmdFindPrevious, FindPrevious),
|
||||
Command::localized(L10nKey::CmdCopy, CopyText),
|
||||
Command::localized(L10nKey::CmdCut, CutText),
|
||||
Command::localized(L10nKey::CmdPaste, PasteText),
|
||||
Command::localized(L10nKey::CmdSelectAll, SelectAllText),
|
||||
];
|
||||
|
||||
let ssh = [
|
||||
Command::new(t(L10nKey::CmdSshAddConnection), OpenSshConnectInput),
|
||||
Command::new(t(L10nKey::CmdSshManageProfiles), OpenSshProfiles),
|
||||
Command::new(t(L10nKey::CmdSshReconnect), RestartSshSession),
|
||||
Command::new(t(L10nKey::CmdSshRemoteFiles), ToggleSftp),
|
||||
Command::new(t(L10nKey::CmdSshPortForwarding), ShowSshForwards),
|
||||
Command::localized(L10nKey::CmdSshAddConnection, OpenSshConnectInput),
|
||||
Command::localized(L10nKey::CmdSshManageProfiles, OpenSshProfiles),
|
||||
Command::localized(L10nKey::CmdSshReconnect, RestartSshSession),
|
||||
Command::localized(L10nKey::CmdSshRemoteFiles, ToggleSftp),
|
||||
Command::localized(L10nKey::CmdSshPortForwarding, ShowSshForwards),
|
||||
];
|
||||
|
||||
let agents = [
|
||||
Command::new(t(L10nKey::CmdAgentSendSelection), SendSelectionToAgent)
|
||||
Command::localized(L10nKey::CmdAgentSendSelection, SendSelectionToAgent)
|
||||
.with_subtitle(t(L10nKey::CmdAgentSendSelectionSubtitle)),
|
||||
Command::new(t(L10nKey::CmdAgentSendGitDiffForReview), SendGitDiffToAgent)
|
||||
Command::localized(L10nKey::CmdAgentSendGitDiffForReview, SendGitDiffToAgent)
|
||||
.with_subtitle(t(L10nKey::CmdAgentSendGitDiffSubtitle)),
|
||||
];
|
||||
|
||||
let application = [
|
||||
Command::new(t(L10nKey::CmdSettings), OpenSettings),
|
||||
Command::new(t(L10nKey::CmdKeyboardShortcuts), ShowKeyboardShortcuts),
|
||||
Command::new(t(L10nKey::CmdAboutTty7), About),
|
||||
Command::new(t(L10nKey::CmdCheckForUpdates), CheckForUpdates),
|
||||
Command::new(t(L10nKey::CmdDocumentation), OpenDocumentation),
|
||||
Command::new(t(L10nKey::CmdJoinDiscord), OpenDiscord),
|
||||
Command::new(t(L10nKey::CmdReportIssue), ReportIssue),
|
||||
Command::new(t(L10nKey::CmdRestartServer), RestartDaemon)
|
||||
Command::localized(L10nKey::CmdSettings, OpenSettings),
|
||||
Command::localized(L10nKey::CmdKeyboardShortcuts, ShowKeyboardShortcuts),
|
||||
Command::localized(L10nKey::CmdAboutTty7, About),
|
||||
Command::localized(L10nKey::CmdCheckForUpdates, CheckForUpdates),
|
||||
Command::localized(L10nKey::CmdDocumentation, OpenDocumentation),
|
||||
Command::localized(L10nKey::CmdJoinDiscord, OpenDiscord),
|
||||
Command::localized(L10nKey::CmdReportIssue, ReportIssue),
|
||||
Command::localized(L10nKey::CmdRestartServer, RestartDaemon)
|
||||
.with_subtitle(t(L10nKey::CmdRestartServerSubtitle)),
|
||||
Command::new(t(L10nKey::CmdQuitTty7), Quit)
|
||||
Command::localized(L10nKey::CmdQuitTty7, Quit)
|
||||
.with_subtitle(t(L10nKey::CmdQuitTty7Subtitle)),
|
||||
];
|
||||
|
||||
@@ -505,6 +520,15 @@ impl Command {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Row of the preset already in use, so the theme picker can open on it
|
||||
/// instead of previewing something else the moment it is opened.
|
||||
pub fn active_theme_index(cx: &App) -> Option<usize> {
|
||||
let active = crate::ui::theme::effective_preset_id(cx);
|
||||
crate::ui::presets::all(cx)
|
||||
.iter()
|
||||
.position(|p| p.id == active)
|
||||
}
|
||||
|
||||
fn ssh_connect_command(input: &str) -> Command {
|
||||
let trimmed = input.trim();
|
||||
let title = if trimmed.is_empty() {
|
||||
@@ -573,6 +597,11 @@ pub fn fuzzy_score(query: &str, text: &str) -> Option<i32> {
|
||||
Some(score)
|
||||
}
|
||||
|
||||
/// How far behind the visible label an alias hit lands. An alias *is* the
|
||||
/// command's own name, only in another language, so the gap is small — but two
|
||||
/// commands that both match must still be ordered by the text on screen.
|
||||
const ALIAS_PENALTY: i32 = 10;
|
||||
|
||||
fn command_score(query: &str, cmd: &Command) -> Option<i32> {
|
||||
let title = fuzzy_score(query, &cmd.title);
|
||||
let subtitle = cmd
|
||||
@@ -580,10 +609,16 @@ fn command_score(query: &str, cmd: &Command) -> Option<i32> {
|
||||
.as_deref()
|
||||
.and_then(|s| fuzzy_score(query, s))
|
||||
.map(|s| s / 2 - 25);
|
||||
match (title, subtitle) {
|
||||
(Some(a), Some(b)) => Some(a.max(b)),
|
||||
(a, b) => a.or(b),
|
||||
}
|
||||
// Aliases only ever decide whether a row is in the list and where it sits.
|
||||
// The row keeps rendering `title` untouched, so a hit on wording the user
|
||||
// cannot see can never end up underlined against the wrong characters.
|
||||
let alias = cmd
|
||||
.aliases
|
||||
.iter()
|
||||
.filter_map(|a| fuzzy_score(query, a))
|
||||
.max()
|
||||
.map(|s| s - ALIAS_PENALTY);
|
||||
[title, subtitle, alias].into_iter().flatten().max()
|
||||
}
|
||||
|
||||
/// A bounded nudge, not a re-ranking. Two commands that match the query about
|
||||
@@ -756,7 +791,7 @@ impl ListDelegate for PaletteDelegate {
|
||||
fn perform_search(
|
||||
&mut self,
|
||||
query: &str,
|
||||
_window: &mut Window,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> Task<()> {
|
||||
if let Some(PaletteInput::SshConnect) = self.input {
|
||||
@@ -805,7 +840,12 @@ impl ListDelegate for PaletteDelegate {
|
||||
commands,
|
||||
}];
|
||||
}
|
||||
self.selected = self.first_row();
|
||||
// Through `set_selected_index`, not by hand: the row index may not have
|
||||
// moved, but the command under it has, and the theme picker previews
|
||||
// the command — not the index.
|
||||
self.selected = None;
|
||||
let first = self.first_row();
|
||||
self.set_selected_index(first, window, cx);
|
||||
Task::ready(())
|
||||
}
|
||||
|
||||
@@ -923,7 +963,14 @@ impl ListDelegate for PaletteDelegate {
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) {
|
||||
let moved = self.selected != ix;
|
||||
self.selected = ix;
|
||||
// The list only emits `Select` for the arrow keys; a query that re-arms
|
||||
// the first row moves the highlight silently. The theme picker previews
|
||||
// whatever is highlighted, so it has to hear about both.
|
||||
if moved && let Some(ix) = ix {
|
||||
cx.emit(ListEvent::Select(ix));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -931,6 +978,11 @@ impl ListDelegate for PaletteDelegate {
|
||||
pub enum PaletteEvent {
|
||||
Confirm(CommandKind),
|
||||
Dismiss,
|
||||
/// Show the theme at this preset index without persisting it: the theme
|
||||
/// picker previews the highlighted row while it stays open.
|
||||
PreviewTheme(usize),
|
||||
/// Put back the theme that was live before the preview started.
|
||||
CancelThemePreview,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
@@ -944,6 +996,9 @@ pub struct PaletteView {
|
||||
list: Entity<ListState<PaletteDelegate>>,
|
||||
root: Vec<Command>,
|
||||
menu: PaletteMenu,
|
||||
/// Preset index the theme picker is currently previewing, so the same
|
||||
/// theme is not re-applied on every redundant selection event.
|
||||
previewing: Option<usize>,
|
||||
_sub: Subscription,
|
||||
}
|
||||
|
||||
@@ -955,6 +1010,7 @@ impl PaletteView {
|
||||
list,
|
||||
root: commands,
|
||||
menu: PaletteMenu::Root,
|
||||
previewing: None,
|
||||
_sub,
|
||||
}
|
||||
}
|
||||
@@ -995,8 +1051,20 @@ impl PaletteView {
|
||||
list
|
||||
}
|
||||
|
||||
fn show(&mut self, commands: Vec<Command>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn show(
|
||||
&mut self,
|
||||
commands: Vec<Command>,
|
||||
selected: Option<usize>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let list = Self::build_list(commands, window, cx);
|
||||
if let Some(row) = selected {
|
||||
list.update(cx, |state, cx| {
|
||||
state.set_selected_index(Some(IndexPath::new(row)), window, cx);
|
||||
state.scroll_to_selected_item(window, cx);
|
||||
});
|
||||
}
|
||||
self._sub = cx.subscribe_in(&list, window, Self::on_list_event);
|
||||
self.list = list;
|
||||
cx.notify();
|
||||
@@ -1039,7 +1107,11 @@ impl PaletteView {
|
||||
Some(CommandKind::OpenThemePicker) => {
|
||||
self.menu = PaletteMenu::Theme;
|
||||
let themes = Command::theme_commands(cx);
|
||||
self.show(themes, window, cx);
|
||||
// Open on the theme already in use: the picker previews
|
||||
// the highlighted row, and merely opening it must not
|
||||
// change what the window looks like.
|
||||
self.previewing = Command::active_theme_index(cx);
|
||||
self.show(themes, self.previewing, window, cx);
|
||||
}
|
||||
Some(CommandKind::OpenSshConnectInput) => {
|
||||
self.menu = PaletteMenu::SshConnect;
|
||||
@@ -1055,6 +1127,12 @@ impl PaletteView {
|
||||
}
|
||||
ListEvent::Cancel => {
|
||||
if self.menu != PaletteMenu::Root {
|
||||
if self.menu == PaletteMenu::Theme {
|
||||
// Backing out of the picker is not a choice: whatever
|
||||
// was previewed goes back to what it was.
|
||||
self.previewing = None;
|
||||
cx.emit(PaletteEvent::CancelThemePreview);
|
||||
}
|
||||
self.menu = PaletteMenu::Root;
|
||||
let root = self.root.clone();
|
||||
let list = Self::build_root_list(root, window, cx);
|
||||
@@ -1065,7 +1143,15 @@ impl PaletteView {
|
||||
cx.emit(PaletteEvent::Dismiss);
|
||||
}
|
||||
}
|
||||
ListEvent::Select(_) => {}
|
||||
ListEvent::Select(ix) => {
|
||||
if self.menu == PaletteMenu::Theme
|
||||
&& let Some(CommandKind::SetTheme(i)) = list.read(cx).delegate().command_at(*ix)
|
||||
&& self.previewing != Some(i)
|
||||
{
|
||||
self.previewing = Some(i);
|
||||
cx.emit(PaletteEvent::PreviewTheme(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1259,6 +1345,35 @@ mod tests {
|
||||
assert!(title_hit > subtitle_hit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_translated_command_is_still_found_by_its_english_name() {
|
||||
crate::ui::i18n::set_locale("zh-CN");
|
||||
let cmd = Command::localized(L10nKey::CmdChangeTheme, CommandKind::OpenThemePicker);
|
||||
assert!(
|
||||
!cmd.title.to_lowercase().contains("theme"),
|
||||
"the row shows the Chinese label: {:?}",
|
||||
cmd.title
|
||||
);
|
||||
assert!(
|
||||
command_score("theme", &cmd).is_some(),
|
||||
"an English query must find the Chinese-labelled row"
|
||||
);
|
||||
assert!(
|
||||
command_score("テーマ", &cmd).is_some(),
|
||||
"so must the Japanese one"
|
||||
);
|
||||
// The displayed label still works, and still wins: the same query
|
||||
// against the locale that shows it scores higher.
|
||||
let zh = command_score("更改主题", &cmd).expect("the shown label matches");
|
||||
crate::ui::i18n::set_locale("en");
|
||||
let en = Command::localized(L10nKey::CmdChangeTheme, CommandKind::OpenThemePicker);
|
||||
assert!(zh > 0 && en.title.contains("Theme"));
|
||||
assert!(
|
||||
command_score("theme", &en).unwrap() > command_score("theme", &cmd).unwrap(),
|
||||
"a hit on the visible label must outrank the same hit on an alias"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_ids_are_unique() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
@@ -1285,6 +1400,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The picker previews whatever row is highlighted, so it has to open on
|
||||
/// the row of the theme already in use — the one carrying the check mark.
|
||||
#[gpui::test]
|
||||
fn the_theme_picker_opens_on_the_theme_in_use(cx: &mut gpui::TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let themes = crate::ui::presets::all(cx);
|
||||
let last = themes.last().expect("built-in themes").id.clone();
|
||||
cx.set_global(Config(crate::core::config::CoreConfig {
|
||||
theme_follow_system: false,
|
||||
theme_preset: last,
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
let ix = Command::active_theme_index(cx).expect("the live preset is listed");
|
||||
assert_eq!(ix, themes.len() - 1);
|
||||
let rows = Command::theme_commands(cx);
|
||||
assert!(
|
||||
rows[ix].title.ends_with('✓'),
|
||||
"row {ix} ({:?}) should be the checked one",
|
||||
rows[ix].title
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_commands_have_no_id() {
|
||||
assert!(CommandKind::ActivateTab(2).id().is_none());
|
||||
|
||||
+575
-115
@@ -22,8 +22,8 @@ use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::config::{
|
||||
BellMode, Config, CursorStyle, NewTabPosition, NotifyMode, TabBarPosition, UpdateChannel,
|
||||
WindowBackdrop,
|
||||
BellMode, Config, CursorStyle, NewTabPosition, NotifyMode, TabBarPosition,
|
||||
UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop,
|
||||
};
|
||||
use crate::core::keychain::CredentialRef;
|
||||
use crate::core::ssh_profile::{
|
||||
@@ -39,29 +39,192 @@ use crate::ui::presets;
|
||||
use crate::ui::rounding;
|
||||
use crate::ui::rounding::RoundedCorners as _;
|
||||
|
||||
/// The settings nav, the SSH host list, and the padding each page sets — the
|
||||
/// chrome a row has to share the window with.
|
||||
/// The settings nav, the SSH host list, the theme panel, and the padding each
|
||||
/// page sets — the chrome a row has to share the window with.
|
||||
const NAV_W: f32 = 220.;
|
||||
const SSH_LIST_W: f32 = 280.;
|
||||
const THEME_PANEL_W: f32 = 300.;
|
||||
const SSH_DETAIL_PAD: f32 = 64.;
|
||||
const PAGE_PAD: f32 = 80.;
|
||||
|
||||
/// The narrowest window these numbers have to hold for.
|
||||
///
|
||||
/// `ui::windows::MIN_SIZE` declares 720, but the settings window in the report
|
||||
/// this file was fixed for measured 641pt: `window_min_size` governs dragging,
|
||||
/// not the bounds a window opens with, so a remembered bound walked straight
|
||||
/// under it. `ui::windows::at_least_min_size` now closes that path, and this
|
||||
/// stays below it deliberately — a declared minimum is a claim about the code,
|
||||
/// and this file would rather be laid out for the window that turns up.
|
||||
const NARROWEST_WINDOW: f32 = 640.;
|
||||
|
||||
/// The narrowest each list is still itself: a nav item that still shows a label
|
||||
/// beside its icon (40 of icon, gap and padding, then the longest label), a
|
||||
/// host row that still shows a name, a theme card that is still a recognisable
|
||||
/// picture of a theme.
|
||||
///
|
||||
/// The nav floor is sized for the longest nav label in *any* locale, not the
|
||||
/// one the developer happens to be reading. `SidebarMenuItem` clips its label
|
||||
/// rather than eliding it, so a floor that fits English cuts a glyph in half in
|
||||
/// Chinese and Japanese: at 140 the zh-CN "窗口与标签页" lost the right half of
|
||||
/// its last character. The widest is ja-JP "ウィンドウとタブ" — 8 full-width
|
||||
/// kana beside the icon, which is 36 more than the 6-glyph Chinese label needs.
|
||||
const NAV_W_MIN: f32 = 176.;
|
||||
const SSH_LIST_W_MIN: f32 = 180.;
|
||||
const THEME_PANEL_W_MIN: f32 = 240.;
|
||||
|
||||
/// The reading column every scrolled page caps itself at. Wider than this and
|
||||
/// a description stops being a paragraph and becomes a line to scan across.
|
||||
const READING_COLUMN: f32 = 640.;
|
||||
|
||||
/// What the page gets before any list does, and the floor it may be pushed to
|
||||
/// when even that cannot be had — the numbers this file did not have.
|
||||
///
|
||||
/// Every list beside the page was a fixed width that never gave anything back,
|
||||
/// so the page absorbed the entire shortfall. On a half-width window with the
|
||||
/// theme panel open that ran all the way down: a Chinese description came out
|
||||
/// one character per line, twenty-five lines tall, and the theme cards under it
|
||||
/// were slivers clipped by the window edge.
|
||||
///
|
||||
/// `CONTENT_W` holds a stacked row's widest control — the 260px text fields —
|
||||
/// with a description beside it that still reads as a paragraph. `CONTENT_MIN_W`
|
||||
/// is not chosen at all: it is what the narrowest window in the wild leaves the
|
||||
/// SSH page, the one that spends a second list, once both lists are standing on
|
||||
/// their own floors. A control wider than it has to be able to shrink, which is
|
||||
/// what `max_w_full` on the wrappers below is for.
|
||||
const CONTENT_W: f32 = 420.;
|
||||
const CONTENT_MIN_W: f32 = NARROWEST_WINDOW - NAV_W_MIN - SSH_LIST_W_MIN - SSH_DETAIL_PAD;
|
||||
|
||||
/// What the settings page is made of at a given window width.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
struct SettingsColumns {
|
||||
nav: f32,
|
||||
/// Zero off the SSH page.
|
||||
ssh_list: f32,
|
||||
/// The width to *draw* the theme panel at, whether it is taking a column or
|
||||
/// covering one — zero only when it is closed.
|
||||
theme_panel: f32,
|
||||
/// The panel no longer fits beside the page, so it lays itself over the
|
||||
/// page instead of taking width from it. The panel is a temporary layer
|
||||
/// over one choice; the page underneath is what the window is for.
|
||||
panel_overlays: bool,
|
||||
}
|
||||
|
||||
/// Hand every list its full width, then take the shortfall back from all of
|
||||
/// them at once — each in proportion to what it has to spare — until the page
|
||||
/// between them reaches `CONTENT_W`. Once every list is standing on its own
|
||||
/// floor the page takes whatever is left, which from `NARROWEST_WINDOW` up is
|
||||
/// never less than `CONTENT_MIN_W`. The theme panel leaves the row altogether
|
||||
/// rather than let it come to that.
|
||||
///
|
||||
/// Every width here is one this row can actually have, so the columns always
|
||||
/// add up to the window. That is deliberate: the fix for a page squeezed to
|
||||
/// nothing is not a `min_w` the row cannot honour — a floor a flex row cannot
|
||||
/// meet does not push back, it overflows, and overflow here means content
|
||||
/// painted off the edge of the window, which is the other half of this bug.
|
||||
fn settings_columns(
|
||||
section: SettingsSection,
|
||||
theme_panel_open: bool,
|
||||
viewport: f32,
|
||||
) -> SettingsColumns {
|
||||
let ssh = matches!(section, SettingsSection::Ssh);
|
||||
// The panel belongs to Appearance; a stale open flag on any other page is
|
||||
// not a column, the same way `render_settings` does not draw one.
|
||||
let theme_panel_open = theme_panel_open && matches!(section, SettingsSection::Appearance);
|
||||
let pad = if ssh { SSH_DETAIL_PAD } else { PAGE_PAD };
|
||||
let page = CONTENT_W + pad;
|
||||
|
||||
// Even with the nav and the panel both at their floors there has to be a
|
||||
// readable page left between them. Below that width the panel stops being a
|
||||
// column — this is the one place the *floor* is the test, because the panel
|
||||
// leaving the row is what buys the page its preferred width back.
|
||||
let panel_overlays =
|
||||
theme_panel_open && viewport - NAV_W_MIN - THEME_PANEL_W_MIN - PAGE_PAD < CONTENT_MIN_W;
|
||||
let beside = theme_panel_open && !panel_overlays;
|
||||
|
||||
let mut nav = NAV_W;
|
||||
let mut ssh_list = only_when(ssh, SSH_LIST_W);
|
||||
let mut theme_panel = only_when(beside, THEME_PANEL_W);
|
||||
let (nav_slack, list_slack, panel_slack) = (
|
||||
NAV_W - NAV_W_MIN,
|
||||
only_when(ssh, SSH_LIST_W - SSH_LIST_W_MIN),
|
||||
only_when(beside, THEME_PANEL_W - THEME_PANEL_W_MIN),
|
||||
);
|
||||
let slack = nav_slack + list_slack + panel_slack;
|
||||
let short = (nav + ssh_list + theme_panel + page - viewport).max(0.);
|
||||
if short > 0. && slack > 0. {
|
||||
let give = (short / slack).min(1.);
|
||||
nav -= nav_slack * give;
|
||||
ssh_list -= list_slack * give;
|
||||
theme_panel -= panel_slack * give;
|
||||
}
|
||||
if panel_overlays {
|
||||
// Covering the page, not replacing it: leave a strip of the page in
|
||||
// view so the panel reads as something laid on top and dismissible.
|
||||
theme_panel = THEME_PANEL_W
|
||||
.min(viewport - nav - CONTENT_MIN_W / 2.)
|
||||
.max(THEME_PANEL_W_MIN);
|
||||
}
|
||||
SettingsColumns {
|
||||
nav: nav.round(),
|
||||
ssh_list: ssh_list.round(),
|
||||
theme_panel: theme_panel.round(),
|
||||
panel_overlays,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a row on this page really has to lay out in.
|
||||
///
|
||||
/// The nav is always in front of it; the SSH page puts its host list there too,
|
||||
/// and only the scrolled pages cap the reading column at 640.
|
||||
fn settings_row_width(section: SettingsSection, viewport: f32) -> f32 {
|
||||
/// the theme panel takes another slice of Appearance for as long as it is open
|
||||
/// *and* still fits beside it, and only the scrolled pages cap the reading
|
||||
/// column.
|
||||
fn settings_row_width(
|
||||
section: SettingsSection,
|
||||
theme_panel_open: bool,
|
||||
viewport: f32,
|
||||
ui_scale: f32,
|
||||
) -> f32 {
|
||||
let cols = settings_columns(section, theme_panel_open, viewport);
|
||||
let panel = only_when(!cols.panel_overlays, cols.theme_panel);
|
||||
match section {
|
||||
SettingsSection::Ssh => (viewport - NAV_W - SSH_LIST_W - SSH_DETAIL_PAD).max(0.),
|
||||
_ => (viewport - NAV_W - PAGE_PAD).clamp(0., 640.),
|
||||
SettingsSection::Ssh => (viewport - cols.nav - cols.ssh_list - SSH_DETAIL_PAD).max(0.),
|
||||
_ => (viewport - cols.nav - panel - PAGE_PAD).clamp(0., READING_COLUMN * ui_scale),
|
||||
}
|
||||
}
|
||||
|
||||
fn only_when(on: bool, w: f32) -> f32 {
|
||||
if on { w } else { 0. }
|
||||
}
|
||||
|
||||
/// How much wider every piece of text on this page is than the px thresholds
|
||||
/// below assume.
|
||||
///
|
||||
/// Those thresholds are widths a *label* needs, measured at the default
|
||||
/// interface font. The interface has a font size of its own and it goes up to
|
||||
/// 24 — half as wide again — while a slider or a text field beside that label
|
||||
/// stays the px width it was built at. Without this the row that has to stack
|
||||
/// first is the one that never does.
|
||||
fn ui_scale(cx: &App) -> f32 {
|
||||
cx.global::<Config>().ui_font_size / UI_FONT_SIZE_DEFAULT
|
||||
}
|
||||
|
||||
/// Width a settings row needs before its label and its control fit side by
|
||||
/// side: a 260px control, the `gap_8` between them, and enough left for a
|
||||
/// description to read as prose rather than as a column of words.
|
||||
const STACK_ROW_BELOW: f32 = 500.;
|
||||
|
||||
/// Width a port-forwarding rule needs before its kind switch, its two host:port
|
||||
/// pairs, its description and its remove button all fit on one line: about 580
|
||||
/// with every field at its floor, rounded up. Past this the rule takes two
|
||||
/// lines instead of running off the page.
|
||||
const SPLIT_FORWARD_ROW_BELOW: f32 = 620.;
|
||||
|
||||
/// And the width below which even `bind → target` is more than one line holds:
|
||||
/// two host fields at their narrow floor, two ports and the arrow come to about
|
||||
/// 310, which is more than `CONTENT_MIN_W`. The SSH page reaches this on the
|
||||
/// window the report came from, so the two ends take a line each.
|
||||
const STACK_FORWARD_ENDS_BELOW: f32 = 340.;
|
||||
|
||||
fn settings_row_id(label: &str, _desc: &str) -> SharedString {
|
||||
SharedString::from(format!("settings-row-{label}"))
|
||||
}
|
||||
@@ -873,9 +1036,15 @@ impl Tty7App {
|
||||
let query = search.read(cx).value().trim().to_lowercase();
|
||||
let show_theme_panel = theme_panel_open && section == SettingsSection::Appearance;
|
||||
|
||||
let viewport_w = window.viewport_size().width.as_f32();
|
||||
let ui_scale = ui_scale(cx);
|
||||
self.settings_viewport_w.set(viewport_w);
|
||||
let cols = settings_columns(section, show_theme_panel, viewport_w);
|
||||
self.settings_row_width.set(settings_row_width(
|
||||
section,
|
||||
window.viewport_size().width.as_f32(),
|
||||
show_theme_panel,
|
||||
viewport_w,
|
||||
ui_scale,
|
||||
));
|
||||
self.settings_hit_anchored.set(false);
|
||||
|
||||
@@ -971,7 +1140,7 @@ impl Tty7App {
|
||||
|
||||
let sidebar = Sidebar::new("settings-sidebar")
|
||||
.collapsible(SidebarCollapsible::None)
|
||||
.w(px(220.))
|
||||
.w(px(cols.nav))
|
||||
.header(
|
||||
v_flex()
|
||||
.w_full()
|
||||
@@ -1040,6 +1209,12 @@ impl Tty7App {
|
||||
// No fill of its own: the root already paints the opaque surface and
|
||||
// the background image behind it, and repainting here would hide the
|
||||
// image again in the one pane that fills most of the panel.
|
||||
//
|
||||
// `min_w_0` and not a `CONTENT_MIN_W` floor: `settings_columns` sized
|
||||
// the chrome so this pane clears it, and a floor a flex row cannot
|
||||
// honour does not push the nav back — it overflows, and overflow here
|
||||
// means content painted off the edge of the window, which is the other
|
||||
// half of the bug this file is fixing.
|
||||
let content_pane = if section == SettingsSection::Ssh {
|
||||
v_flex()
|
||||
.id("settings-content")
|
||||
@@ -1067,7 +1242,7 @@ impl Tty7App {
|
||||
div().w_full().px_10().py_8().child(
|
||||
div()
|
||||
.w_full()
|
||||
.max_w(px(640.))
|
||||
.max_w(px(READING_COLUMN * ui_scale))
|
||||
.children(no_match_note)
|
||||
.child(content),
|
||||
),
|
||||
@@ -1129,7 +1304,25 @@ impl Tty7App {
|
||||
)
|
||||
.on_double_click(|_, window, _| window.titlebar_double_click()),
|
||||
)
|
||||
.when(show_theme_panel, |r| r.child(self.render_theme_panel(cx)))
|
||||
// Beside the page while there is room for both, over it when there
|
||||
// is not. As a column it was taking its 300px from the page and
|
||||
// from nothing else, which is how a half-width window ended up
|
||||
// rendering a description one character wide.
|
||||
.when(show_theme_panel && !cols.panel_overlays, |r| {
|
||||
r.child(self.render_theme_panel(cx))
|
||||
})
|
||||
.when(show_theme_panel && cols.panel_overlays, |r| {
|
||||
r.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.occlude()
|
||||
.shadow_lg()
|
||||
.child(self.render_theme_panel(cx)),
|
||||
)
|
||||
})
|
||||
.when(!show_theme_panel, |r| {
|
||||
r.child(
|
||||
div()
|
||||
@@ -1161,6 +1354,27 @@ impl Tty7App {
|
||||
root
|
||||
}
|
||||
|
||||
/// The column widths this render settled on. `settings_columns` is pure and
|
||||
/// cheap, so the two pages that draw chrome of their own work them out
|
||||
/// again rather than have the answer threaded through every builder.
|
||||
fn settings_columns_now(&self) -> SettingsColumns {
|
||||
let (section, panel_open) = match self.active_settings() {
|
||||
Some(s) => (
|
||||
s.section,
|
||||
s.theme_panel_open && s.section == SettingsSection::Appearance,
|
||||
),
|
||||
None => (SettingsSection::Appearance, false),
|
||||
};
|
||||
settings_columns(section, panel_open, self.settings_viewport_w.get())
|
||||
}
|
||||
|
||||
/// Whether the row measured this render came out narrower than a threshold
|
||||
/// quoted at the default interface font — the only way those px thresholds
|
||||
/// mean anything to a reader who scaled the interface up.
|
||||
fn settings_row_under(&self, at_default_font: f32, cx: &App) -> bool {
|
||||
self.settings_row_width.get() < at_default_font * ui_scale(cx)
|
||||
}
|
||||
|
||||
fn header_text(&self, title: &str, cx: &Context<Self>) -> Div {
|
||||
div()
|
||||
.text_base()
|
||||
@@ -1270,7 +1484,7 @@ impl Tty7App {
|
||||
// where both still fit, put the control on its own line instead.
|
||||
// Measured, not `flex_wrap`: wrapping made the label column size to its
|
||||
// description, which then ran out past the row on every wide page.
|
||||
let stacked = self.settings_row_width.get() < STACK_ROW_BELOW;
|
||||
let stacked = self.settings_row_under(STACK_ROW_BELOW, cx);
|
||||
let labels = v_flex()
|
||||
.gap_0p5()
|
||||
.min_w_0()
|
||||
@@ -1309,7 +1523,16 @@ impl Tty7App {
|
||||
.hover(|h| h.bg(gpui::rgb(cx.global::<presets::Surfaces>().window.hover)))
|
||||
.on_hover(cx.listener(|_this, _hovered, _window, cx| cx.notify()))
|
||||
.child(labels)
|
||||
.child(h_flex().flex_shrink_0().child(control))
|
||||
// Stacked, the control column takes the row: that is what gives a
|
||||
// `max_w_full` control a definite width to shrink against, and on
|
||||
// the SSH page the widest of them is 260 in a column that can be
|
||||
// `CONTENT_MIN_W`.
|
||||
.child(
|
||||
h_flex()
|
||||
.when(stacked, |c| c.w_full())
|
||||
.when(!stacked, |c| c.flex_shrink_0())
|
||||
.child(control),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn segmented(
|
||||
@@ -1643,6 +1866,7 @@ impl Tty7App {
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.w(px(240.))
|
||||
.max_w_full()
|
||||
.child(div().flex_1().child(Slider::new(&slider)))
|
||||
.child(
|
||||
div()
|
||||
@@ -1951,7 +2175,7 @@ impl Tty7App {
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_shrink_0()
|
||||
.w(px(280.))
|
||||
.w(px(self.settings_columns_now().ssh_list))
|
||||
.h_full()
|
||||
.border_r_1()
|
||||
.border_color(border)
|
||||
@@ -2455,7 +2679,12 @@ impl Tty7App {
|
||||
h_flex()
|
||||
.mt_3()
|
||||
.gap_2()
|
||||
.child(div().w(px(320.)).child(Input::new(&input).small()))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.max_w(px(320.))
|
||||
.child(Input::new(&input).small()),
|
||||
)
|
||||
.child(
|
||||
Button::new("ssh-quick-connect")
|
||||
.label(t(L10nKey::Connect))
|
||||
@@ -3172,6 +3401,7 @@ impl Tty7App {
|
||||
t(L10nKey::SettingsNameDesc),
|
||||
div()
|
||||
.w(px(260.))
|
||||
.max_w_full()
|
||||
.child(Input::new(&form.name).small())
|
||||
.into_any_element(),
|
||||
cx,
|
||||
@@ -3183,8 +3413,19 @@ impl Tty7App {
|
||||
t(L10nKey::SettingsHostDesc),
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(div().w(px(172.)).child(Input::new(&form.host).small()))
|
||||
.child(div().w(px(80.)).child(Input::new(&form.port).small()))
|
||||
.max_w_full()
|
||||
.child(
|
||||
div()
|
||||
.w(px(172.))
|
||||
.min_w_0()
|
||||
.child(Input::new(&form.host).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(80.))
|
||||
.flex_shrink_0()
|
||||
.child(Input::new(&form.port).small()),
|
||||
)
|
||||
.into_any_element(),
|
||||
cx,
|
||||
),
|
||||
@@ -3195,6 +3436,7 @@ impl Tty7App {
|
||||
t(L10nKey::SettingsUserDesc),
|
||||
div()
|
||||
.w(px(260.))
|
||||
.max_w_full()
|
||||
.child(Input::new(&form.user).small())
|
||||
.into_any_element(),
|
||||
cx,
|
||||
@@ -3314,6 +3556,7 @@ impl Tty7App {
|
||||
t(L10nKey::SettingsJumpHostDesc),
|
||||
div()
|
||||
.w(px(260.))
|
||||
.max_w_full()
|
||||
.child(Input::new(&form.jump).small())
|
||||
.into_any_element(),
|
||||
cx,
|
||||
@@ -3373,6 +3616,7 @@ impl Tty7App {
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_wrap()
|
||||
.gap_3()
|
||||
.pt_1()
|
||||
.text_xs()
|
||||
@@ -3400,6 +3644,14 @@ impl Tty7App {
|
||||
};
|
||||
let incomplete = row.collect(cx).is_none() && !row.is_blank(cx);
|
||||
|
||||
// Below `SPLIT_FORWARD_ROW_BELOW` the five controls stop fitting on one
|
||||
// line. The kind switch, the description and the remove button keep the
|
||||
// first line; the mapping the rule is actually about takes the second,
|
||||
// where two host fields, two ports and an arrow are what has to fit
|
||||
// inside `CONTENT_MIN_W` — hence the lower floor on the host field.
|
||||
let split = self.settings_row_under(SPLIT_FORWARD_ROW_BELOW, cx);
|
||||
let stack_ends = self.settings_row_under(STACK_FORWARD_ENDS_BELOW, cx);
|
||||
let host_min = if split { 80. } else { 104. };
|
||||
let endpoint = |host: &Entity<InputState>, port: &Entity<InputState>| {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
@@ -3411,74 +3663,89 @@ impl Tty7App {
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w(px(104.))
|
||||
.min_w(px(host_min))
|
||||
.child(Input::new(host).xsmall()),
|
||||
)
|
||||
.child(div().text_xs().text_color(muted).child(":"))
|
||||
.child(div().w(px(58.)).child(Input::new(port).xsmall()))
|
||||
};
|
||||
let mapping = |line: Div| {
|
||||
line.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.when(stack_ends, |end| end.w_full())
|
||||
.child(endpoint(&row.bind_host, &row.bind_port)),
|
||||
)
|
||||
.child(div().flex_shrink_0().text_xs().text_color(muted).child("→"))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.opacity(if needs_target {
|
||||
1.0
|
||||
} else {
|
||||
crate::ui::forwards::NO_TARGET_FADE
|
||||
})
|
||||
.when(stack_ends, |end| end.w_full())
|
||||
.child(endpoint(&row.target_host, &row.target_port)),
|
||||
)
|
||||
};
|
||||
|
||||
let kind_switch = div().flex_shrink_0().child(self.segmented(
|
||||
format!("ssh-fwd-kind-{idx}"),
|
||||
&["L", "R", "D"],
|
||||
kind_idx,
|
||||
cx,
|
||||
move |this, ix, _w, cx| {
|
||||
let kind = match ix {
|
||||
1 => ForwardKind::Remote,
|
||||
2 => ForwardKind::Dynamic,
|
||||
_ => ForwardKind::Local,
|
||||
};
|
||||
if let Some(f) = this.ssh_form_mut()
|
||||
&& let Some(r) = f.forwards.get_mut(idx)
|
||||
{
|
||||
r.kind = kind;
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
));
|
||||
let description = div()
|
||||
.flex_1()
|
||||
.min_w(px(80.))
|
||||
.child(Input::new(&row.description).xsmall());
|
||||
let remove = crate::ui::tab_strip::hit_target(
|
||||
Button::new(("ssh-fwd-remove", idx))
|
||||
.icon(Icon::new(IconName::Close))
|
||||
.ghost()
|
||||
.xsmall(),
|
||||
)
|
||||
.tooltip(t(L10nKey::SettingsRemoveRule))
|
||||
.on_click(cx.listener(move |this, _, _w, cx| this.remove_forward_rule(idx, cx)));
|
||||
|
||||
let rule = match split {
|
||||
true => v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(kind_switch)
|
||||
.child(description)
|
||||
.child(remove),
|
||||
)
|
||||
.child(match stack_ends {
|
||||
true => mapping(v_flex().gap_1().items_start()),
|
||||
false => mapping(h_flex().gap_2().items_center()),
|
||||
}),
|
||||
false => mapping(h_flex().gap_2().items_center().child(kind_switch))
|
||||
.child(description)
|
||||
.child(remove),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.py_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(self.segmented(
|
||||
format!("ssh-fwd-kind-{idx}"),
|
||||
&["L", "R", "D"],
|
||||
kind_idx,
|
||||
cx,
|
||||
move |this, ix, _w, cx| {
|
||||
let kind = match ix {
|
||||
1 => ForwardKind::Remote,
|
||||
2 => ForwardKind::Dynamic,
|
||||
_ => ForwardKind::Local,
|
||||
};
|
||||
if let Some(f) = this.ssh_form_mut()
|
||||
&& let Some(r) = f.forwards.get_mut(idx)
|
||||
{
|
||||
r.kind = kind;
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.child(endpoint(&row.bind_host, &row.bind_port)),
|
||||
)
|
||||
.child(div().flex_shrink_0().text_xs().text_color(muted).child("→"))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.opacity(if needs_target {
|
||||
1.0
|
||||
} else {
|
||||
crate::ui::forwards::NO_TARGET_FADE
|
||||
})
|
||||
.child(endpoint(&row.target_host, &row.target_port)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w(px(80.))
|
||||
.child(Input::new(&row.description).xsmall()),
|
||||
)
|
||||
.child(
|
||||
crate::ui::tab_strip::hit_target(
|
||||
Button::new(("ssh-fwd-remove", idx))
|
||||
.icon(Icon::new(IconName::Close))
|
||||
.ghost()
|
||||
.xsmall(),
|
||||
)
|
||||
.tooltip(t(L10nKey::SettingsRemoveRule))
|
||||
.on_click(
|
||||
cx.listener(move |this, _, _w, cx| this.remove_forward_rule(idx, cx)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(rule)
|
||||
.when(incomplete, |col| {
|
||||
col.child(div().text_xs().text_color(danger).child(if needs_target {
|
||||
t(L10nKey::SettingsFwdNeedsBoth)
|
||||
@@ -3550,6 +3817,7 @@ impl Tty7App {
|
||||
desc.to_string(),
|
||||
div()
|
||||
.w(px(260.))
|
||||
.max_w_full()
|
||||
.child(Input::new(input).small())
|
||||
.into_any_element(),
|
||||
cx,
|
||||
@@ -3874,10 +4142,12 @@ impl Tty7App {
|
||||
});
|
||||
let program_control = div()
|
||||
.w(px(260.))
|
||||
.max_w_full()
|
||||
.child(Input::new(&program_input).small().suffix(program_picker))
|
||||
.into_any_element();
|
||||
let args_control = div()
|
||||
.w(px(260.))
|
||||
.max_w_full()
|
||||
.child(Input::new(&args_input).small())
|
||||
.into_any_element();
|
||||
|
||||
@@ -3908,6 +4178,7 @@ impl Tty7App {
|
||||
let wd_path_control = if wd_strategy == WdStrategy::Custom {
|
||||
div()
|
||||
.w(px(260.))
|
||||
.max_w_full()
|
||||
.child(Input::new(&wd_path_input).small())
|
||||
.into_any_element()
|
||||
} else {
|
||||
@@ -3997,6 +4268,7 @@ impl Tty7App {
|
||||
.into_any_element();
|
||||
let link_file_command_control = div()
|
||||
.w(px(300.))
|
||||
.max_w_full()
|
||||
.child(Input::new(&link_file_command_input).small())
|
||||
.into_any_element();
|
||||
let scrollback_radio = self.segmented(
|
||||
@@ -4059,6 +4331,7 @@ impl Tty7App {
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.w(px(240.))
|
||||
.max_w_full()
|
||||
.child(div().flex_1().child(Slider::new(&scroll_slider)))
|
||||
.child(
|
||||
div()
|
||||
@@ -4264,6 +4537,7 @@ impl Tty7App {
|
||||
),
|
||||
None => (AgentHooksView::Loading, None, HostId::LOCAL),
|
||||
};
|
||||
let stacked = self.settings_row_under(STACK_ROW_BELOW, cx);
|
||||
let mut page = v_flex().child(self.section_intro(
|
||||
t(L10nKey::SettingsAgentsIntro),
|
||||
t(L10nKey::SettingsAgentsIntroDesc),
|
||||
@@ -4308,9 +4582,13 @@ impl Tty7App {
|
||||
.filter(|(for_agent, _)| *for_agent == agent)
|
||||
.map(|(_, text)| text.clone());
|
||||
|
||||
// Right-aligned beside its label, left-aligned under it —
|
||||
// `settings_row` gives the control column the whole row
|
||||
// once it stacks, and buttons flush to the far edge of a
|
||||
// row whose label starts at the near one read as unrelated.
|
||||
let control = v_flex()
|
||||
.gap_2()
|
||||
.items_end()
|
||||
.when(!stacked, |c| c.items_end())
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
@@ -4344,8 +4622,9 @@ impl Tty7App {
|
||||
col.child(
|
||||
div()
|
||||
.max_w_80()
|
||||
.max_w_full()
|
||||
.text_xs()
|
||||
.text_right()
|
||||
.when(!stacked, |note| note.text_right())
|
||||
.text_color(muted_fg)
|
||||
.child(text),
|
||||
)
|
||||
@@ -4797,6 +5076,9 @@ impl Tty7App {
|
||||
let open = self
|
||||
.active_settings()
|
||||
.is_some_and(|s| s.theme_panel_open && s.theme_panel_slot == slot);
|
||||
// The same width a row stacks at: the card is a row too, just one whose
|
||||
// control happens to be a whole preview.
|
||||
let narrow = self.settings_row_under(STACK_ROW_BELOW, cx);
|
||||
|
||||
div()
|
||||
.id(card_id)
|
||||
@@ -4822,9 +5104,17 @@ impl Tty7App {
|
||||
})
|
||||
.bg(surface)
|
||||
.hover(|h| h.bg(hover_bg))
|
||||
.child(div().w(px(150.)).flex_shrink_0().child(preview))
|
||||
// The preview is the first thing to go: it is a picture of a
|
||||
// choice the two lines beside it already name, and at the
|
||||
// width where it stops fitting it was pushing the "change
|
||||
// theme" affordance off the card.
|
||||
.when(!narrow, |card| {
|
||||
card.child(div().w(px(150.)).flex_shrink_0().child(preview))
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.child(div().text_xs().text_color(muted_fg).child(caption))
|
||||
.child(
|
||||
@@ -4836,9 +5126,9 @@ impl Tty7App {
|
||||
)
|
||||
.child(swatches),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
@@ -4922,7 +5212,7 @@ impl Tty7App {
|
||||
});
|
||||
|
||||
let search_box = div().px_4().pb_3().child(
|
||||
div().w(px(268.)).child(
|
||||
div().w_full().child(
|
||||
Input::new(&search).small().prefix(
|
||||
Icon::empty()
|
||||
.path("stock/icons/search.svg")
|
||||
@@ -4986,7 +5276,7 @@ impl Tty7App {
|
||||
.cursor_pointer()
|
||||
.child(
|
||||
div()
|
||||
.w(px(268.))
|
||||
.w_full()
|
||||
.rounded(rounding::TRACK_RADIUS)
|
||||
.overflow_hidden()
|
||||
.border_1()
|
||||
@@ -5005,8 +5295,10 @@ impl Tty7App {
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
.font_weight(if is_active {
|
||||
FontWeight::SEMIBOLD
|
||||
@@ -5017,7 +5309,12 @@ impl Tty7App {
|
||||
.child(p.name.clone()),
|
||||
)
|
||||
.when(is_active, |s| {
|
||||
s.child(Icon::new(IconName::Check).small().text_color(foreground))
|
||||
s.child(
|
||||
Icon::new(IconName::Check)
|
||||
.small()
|
||||
.flex_shrink_0()
|
||||
.text_color(foreground),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, window, cx| match slot {
|
||||
@@ -5036,7 +5333,7 @@ impl Tty7App {
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.w(px(300.))
|
||||
.w(px(self.settings_columns_now().theme_panel))
|
||||
.h_full()
|
||||
.flex_shrink_0()
|
||||
.bg(bg)
|
||||
@@ -5134,13 +5431,18 @@ impl Tty7App {
|
||||
// The label column has to be allowed to shrink, or its description sets
|
||||
// the row's width and the control it belongs to is pushed off the page.
|
||||
// `settings_row` does this for every other row in Settings; these two
|
||||
// are hand-rolled and were missing it.
|
||||
let preset_row = h_flex()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_8()
|
||||
.py_2()
|
||||
// are hand-rolled and were missing it. They take its breakpoint too:
|
||||
// the segmented controls beside them are the widest on the page.
|
||||
let stacked = self.settings_row_under(STACK_ROW_BELOW, cx);
|
||||
let hand_rolled_row = |row: Div| {
|
||||
row.w_full()
|
||||
.flex()
|
||||
.when(stacked, |r| r.flex_col().items_start().gap_2())
|
||||
.when(!stacked, |r| {
|
||||
r.flex_row().items_center().justify_between().gap_8()
|
||||
})
|
||||
};
|
||||
let preset_row = hand_rolled_row(div().py_2())
|
||||
.child(
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
@@ -5161,12 +5463,7 @@ impl Tty7App {
|
||||
)
|
||||
.child(h_flex().flex_shrink_0().child(preset_control));
|
||||
|
||||
let prefix_row = h_flex()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_8()
|
||||
.py_2()
|
||||
let prefix_row = hand_rolled_row(div().py_2())
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
@@ -5221,8 +5518,11 @@ impl Tty7App {
|
||||
let is_recording = recording.as_ref().is_some_and(|(a, _)| a == &action);
|
||||
let is_overridden = overridden.contains(&action);
|
||||
|
||||
// Wrapping, because a four-chord binding is wider than the column a
|
||||
// narrow window leaves for it, and the alternative to a second line
|
||||
// is a first one that runs off the page.
|
||||
let keycaps = |spec: &str| {
|
||||
h_flex().gap_2().children(
|
||||
h_flex().flex_wrap().gap_2().children(
|
||||
crate::ui::keymap::key_chords(spec)
|
||||
.into_iter()
|
||||
.map(|chord| h_flex().gap_1().children(chord.into_iter().map(&keycap))),
|
||||
@@ -5332,13 +5632,16 @@ impl Tty7App {
|
||||
}
|
||||
let last_in_group = heading_at.contains_key(&(i + 1)) || i + 1 == count;
|
||||
list = list.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.py_1p5()
|
||||
hand_rolled_row(div().py_1p5())
|
||||
.when(!last_in_group, |s| s.border_b_1().border_color(border))
|
||||
.child(div().text_sm().text_color(foreground).child(label))
|
||||
.child(right),
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_color(foreground)
|
||||
.child(label),
|
||||
)
|
||||
.child(right.flex_shrink_0()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5454,6 +5757,7 @@ impl Tty7App {
|
||||
let http_proxy_control = v_flex()
|
||||
.gap_1()
|
||||
.w(px(260.))
|
||||
.max_w_full()
|
||||
.child(Input::new(&http_proxy_input).small())
|
||||
.when(http_proxy_invalid, |this| {
|
||||
this.child(
|
||||
@@ -5809,21 +6113,177 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The row keeps its side-by-side shape while both halves fit, and stacks
|
||||
/// once they do not. The SSH page reaches that point first — it spends 500px
|
||||
/// on two lists before the row gets any.
|
||||
/// once they do not. The SSH page reaches that point first — it spends its
|
||||
/// host list before the row gets anything.
|
||||
#[test]
|
||||
fn a_row_stacks_once_its_label_and_control_stop_fitting() {
|
||||
use SettingsSection::*;
|
||||
assert!(settings_row_width(Terminal, 1440.) >= STACK_ROW_BELOW);
|
||||
assert!(settings_row_width(Terminal, 900.) >= STACK_ROW_BELOW);
|
||||
assert!(settings_row_width(Terminal, 700.) < STACK_ROW_BELOW);
|
||||
assert!(settings_row_width(Terminal, false, 1440., 1.) >= STACK_ROW_BELOW);
|
||||
assert!(settings_row_width(Terminal, false, 900., 1.) >= STACK_ROW_BELOW);
|
||||
// At the narrowest window that turns up the page is 420 wide, which is
|
||||
// under the width where a label and a control still share a line.
|
||||
assert!(settings_row_width(Terminal, false, NARROWEST_WINDOW, 1.) < STACK_ROW_BELOW);
|
||||
// Capped at the reading column, so a wider window never widens the row.
|
||||
assert_eq!(settings_row_width(Terminal, 4000.), 640.);
|
||||
// SSH crosses over while the window is still wide.
|
||||
assert!(settings_row_width(Ssh, 1440.) >= STACK_ROW_BELOW);
|
||||
assert!(settings_row_width(Ssh, 1000.) < STACK_ROW_BELOW);
|
||||
assert_eq!(
|
||||
settings_row_width(Terminal, false, 4000., 1.),
|
||||
READING_COLUMN
|
||||
);
|
||||
// SSH crosses over while the window is still wide — it is the page that
|
||||
// spends a host list before its rows get anything.
|
||||
assert!(settings_row_width(Ssh, false, 1440., 1.) >= STACK_ROW_BELOW);
|
||||
assert!(settings_row_width(Ssh, false, 900., 1.) < STACK_ROW_BELOW);
|
||||
// And never goes negative on a window narrower than its own chrome.
|
||||
assert_eq!(settings_row_width(Ssh, 100.), 0.);
|
||||
assert_eq!(settings_row_width(Ssh, false, 100., 1.), 0.);
|
||||
}
|
||||
|
||||
/// Every list gives width back before the page does, and no combination of
|
||||
/// page and panel leaves the page below the width it is derived to keep.
|
||||
/// 641pt is the window the report came from — under the declared 720 —- so
|
||||
/// that is the case that has to hold, not the one the manifest promises.
|
||||
#[test]
|
||||
fn the_page_keeps_a_readable_width_at_every_window_that_turns_up() {
|
||||
use SettingsSection::*;
|
||||
for section in SettingsSection::ALL {
|
||||
for panel_open in [false, true] {
|
||||
for viewport in [NARROWEST_WINDOW, 641., 720., 900., 1100., 1440., 2560.] {
|
||||
let cols = settings_columns(section, panel_open, viewport);
|
||||
let pad = match section {
|
||||
Ssh => SSH_DETAIL_PAD,
|
||||
_ => PAGE_PAD,
|
||||
};
|
||||
let panel = only_when(!cols.panel_overlays, cols.theme_panel);
|
||||
let page = viewport - cols.nav - cols.ssh_list - panel - pad;
|
||||
assert!(
|
||||
page >= CONTENT_MIN_W,
|
||||
"{viewport}px, {panel_open}: page got {page}, floor is {CONTENT_MIN_W}"
|
||||
);
|
||||
// And the columns add up to the window rather than past it,
|
||||
// which is what keeps the rightmost one on screen.
|
||||
assert!(cols.nav + cols.ssh_list + panel + pad + page <= viewport + 1.);
|
||||
assert!(cols.nav >= NAV_W_MIN && cols.nav <= NAV_W);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The three screenshots in the report, by the numbers measured off them.
|
||||
/// Every one of them is the same 641pt window.
|
||||
#[test]
|
||||
fn the_reported_window_lays_out_without_running_off_the_screen() {
|
||||
use SettingsSection::*;
|
||||
const REPORTED: f32 = 641.;
|
||||
|
||||
// Shot 1 — SSH. Nav 220 and host list 280 left the detail 141pt and its
|
||||
// empty state painted ~270 past the window edge. Both lists now stand
|
||||
// on their floors and the detail keeps the rest.
|
||||
let ssh = settings_columns(Ssh, false, REPORTED);
|
||||
assert_eq!((ssh.nav, ssh.ssh_list), (NAV_W_MIN, SSH_LIST_W_MIN));
|
||||
let detail = settings_row_width(Ssh, false, REPORTED, 1.);
|
||||
assert!(detail >= CONTENT_MIN_W, "SSH detail got {detail}");
|
||||
|
||||
// Shot 2 — Appearance, panel closed. The opacity row measured a 78pt
|
||||
// label beside a 240pt slider; at this width the row has to stack.
|
||||
// The page does not reach its preferred `CONTENT_W` here: the nav floor
|
||||
// is sized to show a Japanese nav label whole, and at 641 that costs the
|
||||
// page the difference. Readable and stacked is the property that matters.
|
||||
let page = settings_row_width(Appearance, false, REPORTED, 1.);
|
||||
assert_eq!(page, REPORTED - NAV_W_MIN - PAGE_PAD);
|
||||
assert!(page >= CONTENT_MIN_W, "the page got {page}");
|
||||
assert!(
|
||||
page < STACK_ROW_BELOW,
|
||||
"the opacity row has to stack at 641"
|
||||
);
|
||||
|
||||
// Shot 3 — Appearance with the theme panel. 220 + 300 of chrome left
|
||||
// the page ~125pt, one Chinese character per line. The panel now lifts
|
||||
// off the row entirely and the page is back to shot 2's width.
|
||||
assert!(settings_columns(Appearance, true, REPORTED).panel_overlays);
|
||||
assert_eq!(settings_row_width(Appearance, true, REPORTED, 1.), page);
|
||||
}
|
||||
|
||||
/// The list that has to give the most is the one the window has the least
|
||||
/// room for, and no list is ever asked for more than it has to spare.
|
||||
#[test]
|
||||
fn the_lists_shrink_together_and_stop_at_their_floors() {
|
||||
use SettingsSection::*;
|
||||
// Wide enough for everyone: nothing moves.
|
||||
let wide = settings_columns(Ssh, false, 1440.);
|
||||
assert_eq!((wide.nav, wide.ssh_list), (NAV_W, SSH_LIST_W));
|
||||
// The reported window — half of a 1440pt screen, three columns on SSH.
|
||||
// Both lists give, neither past its floor, and the detail comes out at
|
||||
// its preferred width instead of the 336 it used to be left with.
|
||||
let half = settings_columns(Ssh, false, 900.);
|
||||
assert!(half.nav < NAV_W && half.ssh_list < SSH_LIST_W);
|
||||
assert!(half.nav >= NAV_W_MIN && half.ssh_list >= SSH_LIST_W_MIN);
|
||||
assert_eq!(
|
||||
settings_row_width(Ssh, false, 900., 1.).round(),
|
||||
CONTENT_W,
|
||||
"the SSH detail should get its preferred width at 900pt"
|
||||
);
|
||||
// The narrowest window that turns up: both at the floor, page readable.
|
||||
let tiny = settings_columns(Ssh, false, NARROWEST_WINDOW);
|
||||
assert_eq!((tiny.nav, tiny.ssh_list), (NAV_W_MIN, SSH_LIST_W_MIN));
|
||||
assert_eq!(
|
||||
settings_row_width(Ssh, false, NARROWEST_WINDOW, 1.),
|
||||
CONTENT_MIN_W
|
||||
);
|
||||
}
|
||||
|
||||
/// The thresholds are widths a *label* needs, and a reader who scaled the
|
||||
/// interface up scaled every label with it while the slider beside it kept
|
||||
/// the px width it was built at. A window that reads fine at the default
|
||||
/// font is a starved label column at the largest one.
|
||||
#[test]
|
||||
fn the_stacking_width_follows_the_interface_font() {
|
||||
use crate::core::config::UI_FONT_SIZE_MAX;
|
||||
use SettingsSection::*;
|
||||
let large = UI_FONT_SIZE_MAX / UI_FONT_SIZE_DEFAULT;
|
||||
// Side by side at the default font...
|
||||
assert!(settings_row_width(Appearance, false, 900., 1.) >= STACK_ROW_BELOW);
|
||||
// ...and stacked at the largest, where the same row holds half as much.
|
||||
assert!(
|
||||
settings_row_width(Appearance, false, 900., large) < STACK_ROW_BELOW * large,
|
||||
"a 900pt window at the largest interface font has to stack"
|
||||
);
|
||||
// The reading column grows with the font, so a wide window does not.
|
||||
assert!(settings_row_width(Appearance, false, 1600., large) >= STACK_ROW_BELOW * large);
|
||||
}
|
||||
|
||||
/// The theme panel took its 300px from the page and from nothing else, so
|
||||
/// a half-width window with it open rendered a description one character
|
||||
/// wide. It now shrinks with everything else, and stops being a column at
|
||||
/// all once even that is not enough.
|
||||
#[test]
|
||||
fn the_theme_panel_yields_before_the_page_does() {
|
||||
use SettingsSection::*;
|
||||
assert!(settings_row_width(Appearance, true, 900., 1.) < STACK_ROW_BELOW);
|
||||
assert!(
|
||||
settings_row_width(Appearance, true, 900., 1.)
|
||||
< settings_row_width(Appearance, false, 900., 1.)
|
||||
);
|
||||
// Beside the page while both fit — which, with the panel and the nav
|
||||
// both allowed down to their floors, still holds at 720.
|
||||
assert!(!settings_columns(Appearance, true, 900.).panel_overlays);
|
||||
assert!(!settings_columns(Appearance, true, 720.).panel_overlays);
|
||||
// ...and over it once they do not, at which point the page is back to
|
||||
// the width it has with the panel closed.
|
||||
assert!(settings_columns(Appearance, true, 641.).panel_overlays);
|
||||
assert_eq!(
|
||||
settings_row_width(Appearance, true, 641., 1.),
|
||||
settings_row_width(Appearance, false, 641., 1.)
|
||||
);
|
||||
// The panel is the only chrome that can leave, so it has to leave in
|
||||
// time: at 641 there is no arrangement in which it and a readable page
|
||||
// both fit in a row.
|
||||
assert!(
|
||||
NARROWEST_WINDOW - NAV_W_MIN - THEME_PANEL_W_MIN - PAGE_PAD < CONTENT_MIN_W,
|
||||
"the overlay threshold has to fire at the narrowest window"
|
||||
);
|
||||
// Wide enough and the cap is the reading column either way.
|
||||
assert_eq!(
|
||||
settings_row_width(Appearance, true, 1600., 1.),
|
||||
READING_COLUMN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+49
-1
@@ -549,7 +549,7 @@ fn window_options(cx: &mut App, workspace: Option<WorkspaceId>) -> WindowOptions
|
||||
let existing = WindowRegistry::count(cx);
|
||||
let bounds = match remembered {
|
||||
Some(state) => {
|
||||
let bounds = state.bounds();
|
||||
let bounds = at_least_min_size(state.bounds());
|
||||
if cx.displays().iter().any(|d| d.bounds().intersects(&bounds)) {
|
||||
bounds
|
||||
} else {
|
||||
@@ -582,6 +582,23 @@ fn window_options(cx: &mut App, workspace: Option<WorkspaceId>) -> WindowOptions
|
||||
}
|
||||
}
|
||||
|
||||
/// Grow a remembered bound back up to `MIN_SIZE`.
|
||||
///
|
||||
/// `window_min_size` only governs what a *drag* may do to a window; the bounds
|
||||
/// we open with are taken as given. A window that got under the minimum — an
|
||||
/// older build that had no minimum, a hand-edited `views.json`, a display that
|
||||
/// went away — was reopened at whatever it had been saved at, and the settings
|
||||
/// page it opened onto had never been laid out for that width.
|
||||
fn at_least_min_size(bounds: Bounds<gpui::Pixels>) -> Bounds<gpui::Pixels> {
|
||||
Bounds {
|
||||
origin: bounds.origin,
|
||||
size: size(
|
||||
bounds.size.width.max(px(MIN_SIZE.0)),
|
||||
bounds.size.height.max(px(MIN_SIZE.1)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn cascade(bounds: Bounds<gpui::Pixels>, existing: usize) -> Bounds<gpui::Pixels> {
|
||||
if existing == 0 {
|
||||
return bounds;
|
||||
@@ -626,6 +643,37 @@ mod tests {
|
||||
assert_eq!(cascade(b, 3).size, b.size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remembered_bound_under_the_minimum_is_grown_back_to_it() {
|
||||
// The window in the report this was fixed for: 641x830, saved and
|
||||
// reopened at a width no settings page is laid out for.
|
||||
let undersized = Bounds {
|
||||
origin: point(px(700.), px(60.)),
|
||||
size: size(px(641.), px(830.)),
|
||||
};
|
||||
let grown = at_least_min_size(undersized);
|
||||
assert_eq!(grown.size.width, px(MIN_SIZE.0));
|
||||
assert_eq!(grown.size.height, px(830.), "a tall enough height is kept");
|
||||
assert_eq!(grown.origin, undersized.origin, "the corner does not move");
|
||||
|
||||
let short = at_least_min_size(Bounds {
|
||||
origin: point(px(0.), px(0.)),
|
||||
size: size(px(1200.), px(300.)),
|
||||
});
|
||||
assert_eq!(short.size, size(px(1200.), px(MIN_SIZE.1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remembered_bound_at_or_over_the_minimum_is_left_alone() {
|
||||
let b = bounds_at(100., 100.);
|
||||
assert_eq!(at_least_min_size(b).size, b.size);
|
||||
let exact = Bounds {
|
||||
origin: point(px(0.), px(0.)),
|
||||
size: size(px(MIN_SIZE.0), px(MIN_SIZE.1)),
|
||||
};
|
||||
assert_eq!(at_least_min_size(exact).size, exact.size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_wraps_so_windows_never_march_off_screen() {
|
||||
let b = bounds_at(100., 100.);
|
||||
|
||||
Reference in New Issue
Block a user