, shape: PowerlineShape) -> gpui::Path Option {
+ style.draws_on_blanks().then_some(' ')
+}
+
/// The width `paint_glyphs` clips a segment's paint to.
///
/// A batched `Run`/`Wide` segment clips to its exact column span (`cells`
@@ -834,25 +916,71 @@ fn paint_glyphs(
// for a single glyph — it paints at the run origin regardless.
RowSeg::Solo { col } => {
let cell = &buf[row_base + col];
- if let Some(shape) = PowerlineShape::of(cell.c) {
- let cell_bounds = Bounds::new(
- point(geom.origin.x + geom.cell_width * (col as f32), y),
- size(geom.cell_width, geom.line_height),
- );
+ let cell_bounds = Bounds::new(
+ point(geom.origin.x + geom.cell_width * (col as f32), y),
+ size(geom.cell_width, geom.line_height),
+ );
+ // Two families paint as native geometry rather than as a
+ // font glyph: Powerline separators, and the box-drawing /
+ // block characters (`boxdraw`) — a font glyph only covers
+ // the font's own line height, which broke every vertical
+ // run of `│`/`╭`/`╰` into dashes at line_height > 1.0.
+ // Either way the cell may still owe an underline, so this
+ // records whether the ink is already down rather than
+ // returning outright.
+ let native = if let Some(shape) = PowerlineShape::of(cell.c) {
let path = powerline_path(cell_bounds, shape);
window.paint_path(path, GlyphStyle::of(cell).fg);
- continue;
+ true
+ } else if let Some(ink) =
+ super::boxdraw::glyph(cell.c, cell_bounds, window.scale_factor())
+ {
+ let fg = GlyphStyle::of(cell).fg;
+ for piece in ink {
+ match piece {
+ super::boxdraw::Ink::Rect(r) => window.paint_quad(fill(r, fg)),
+ super::boxdraw::Ink::Shade(r, alpha) => {
+ let mut c = fg;
+ c.a *= alpha;
+ window.paint_quad(fill(r, c));
+ }
+ super::boxdraw::Ink::Path(p) => window.paint_path(p, fg),
+ }
+ }
+ true
+ } else {
+ false
+ };
+ if !native {
+ (col, 1, char_string(cell.c), None, true)
+ } else {
+ match native_cell_residue(&GlyphStyle::of(cell)) {
+ None => continue,
+ // `solo: false` clips the space to its own single
+ // column so the underline can't spill sideways.
+ Some(c) => (col, 1, char_string(c), None, false),
+ }
}
- (col, 1, char_string(cell.c), None, true)
}
// Same pinning as the batched runs, just for one base: two
// columns get `force_width` so a fallback emoji face can't
// drift, one column paints at the origin like `Solo`.
- RowSeg::Cluster { col, cells, text } => (
+ // Two columns pin per *base glyph*, and which that is depends
+ // on why the cluster is two cells wide: a wide base is one
+ // glyph spanning both, an absorbed SARA AM is two glyphs of one
+ // column each. `force_width` classifies by advance, so the
+ // marks ride their base under either. One column paints at the
+ // origin like `Solo`.
+ RowSeg::Cluster {
+ col,
+ cells,
+ text,
+ wide_base,
+ } => (
col,
cells,
SharedString::from(text),
- (cells == 2).then(|| geom.cell_width * 2.),
+ (cells == 2).then(|| geom.cell_width * if wide_base { 2. } else { 1. }),
cells == 1,
),
};
@@ -2070,6 +2198,51 @@ mod tests {
);
}
+ /// A natively-drawn cell keeps its underline.
+ ///
+ /// Underlines ride on the `TextRun`, so the Solo arm's early return for
+ /// Powerline separators and box-drawing characters used to drop them: an
+ /// `ESC[4m` span or a hovered URL containing `─`, `│` or `` showed a
+ /// one-column hole where the line should have run through. The residue is
+ /// what closes it — a space shaped in the cell's own style, carrying the
+ /// underline and no glyph ink.
+ #[test]
+ fn natively_drawn_cells_still_carry_their_underline() {
+ let plain = GlyphStyle::of(&cell('│'));
+ assert_eq!(
+ native_cell_residue(&plain),
+ None,
+ "an unstyled box character has nothing left to shape"
+ );
+
+ for kind in [
+ UnderlineKind::Single,
+ UnderlineKind::Double,
+ UnderlineKind::Curly,
+ ] {
+ let mut c = cell('│');
+ c.underline = kind;
+ assert_eq!(
+ native_cell_residue(&GlyphStyle::of(&c)),
+ Some(' '),
+ "{kind:?} underline dropped on a box-drawing cell"
+ );
+ }
+
+ // A hovered link underlines even without an emulator underline, and
+ // the characters it spans may well be box drawing or a separator.
+ for ch in ['│', '─', '╭', '█', '\u{e0b0}'] {
+ let mut c = cell(ch);
+ c.link_hover = true;
+ assert_eq!(
+ native_cell_residue(&GlyphStyle::of(&c)),
+ Some(' '),
+ "hovered-link underline dropped on U+{:04X}",
+ ch as u32
+ );
+ }
+ }
+
#[test]
fn segment_row_keeps_powerline_separators_solo() {
// The native-draw intercept lives in the Solo arm of `paint_glyphs`;
@@ -2127,6 +2300,16 @@ mod tests {
col,
cells,
text: text.to_string(),
+ wide_base: false,
+ }
+ }
+
+ fn wide_cluster(col: usize, cells: usize, text: &str) -> RowSeg {
+ RowSeg::Cluster {
+ col,
+ cells,
+ text: text.to_string(),
+ wide_base: true,
}
}
@@ -2145,7 +2328,7 @@ mod tests {
// spacer too (❤ + U+FE0F).
let mut row = wide_cells("\u{2764}");
row[0].marks = Some(Box::from(['\u{FE0F}']));
- assert_eq!(segment_row(&row), [cluster(0, 2, "\u{2764}\u{FE0F}")]);
+ assert_eq!(segment_row(&row), [wide_cluster(0, 2, "\u{2764}\u{FE0F}")]);
// Several marks on one base: an above-base vowel and a tone mark both
// sit on the consonant (ที่ = ท U+0E17 + ◌ี U+0E35 + ◌่ U+0E48).
@@ -2157,6 +2340,58 @@ mod tests {
);
}
+ /// SARA AM (U+0E33) is the awkward Thai vowel: `Lo`, width 1, so the grid
+ /// gives it its own column — but the shaper decomposes it into NIKHAHIT +
+ /// SARA AA and reorders the nikhahit backwards onto the base consonant.
+ /// Shaped in its own run it has no base to reorder onto and comes out as a
+ /// dotted circle, so it has to join the preceding cell's cluster.
+ #[test]
+ fn segment_row_absorbs_sara_am_into_its_base() {
+ // น + ้ (tone) + ำ — the base already carries a mark.
+ let mut row = vec![cell('\u{0E19}'), cell('\u{0E33}'), cell('a')];
+ row[0].marks = Some(Box::from(['\u{0E49}']));
+ assert_eq!(
+ segment_row(&row),
+ [cluster(0, 2, "\u{0E19}\u{0E49}\u{0E33}"), run(2, 1, "a")]
+ );
+
+ // ก + ำ — an unmarked base still has to shape with it.
+ let row = vec![cell('\u{0E01}'), cell('\u{0E33}')];
+ assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]);
+
+ // Lao SARA AM (U+0EB3) takes the same shaper path.
+ let row = vec![cell('\u{0E81}'), cell('\u{0EB3}')];
+ assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E81}\u{0EB3}")]);
+
+ // A style change does not break the cluster, unlike a `Run` or `Wide`
+ // batch: split off, the vowel has no base and paints a dotted circle,
+ // so it takes the base's style instead.
+ let mut row = vec![cell('\u{0E01}'), cell('\u{0E33}')];
+ row[1].fg = gpui::red();
+ assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]);
+ }
+
+ /// With nothing to attach to, SARA AM paints alone — a dotted circle is the
+ /// shaper's honest answer for an orphaned mark, and inventing a base would
+ /// be worse.
+ #[test]
+ fn segment_row_leaves_a_baseless_sara_am_alone() {
+ let row = vec![cell('\u{0E33}'), cell('a')];
+ assert_eq!(segment_row(&row), [RowSeg::Solo { col: 0 }, run(1, 1, "a")]);
+
+ // A blank before it is not a base either.
+ let row = vec![cell(' '), cell('\u{0E33}')];
+ assert_eq!(segment_row(&row), [RowSeg::Solo { col: 1 }]);
+
+ // Nor is another SARA AM: absorbing would pin the second one's glyphs
+ // past the cluster's two-cell clip and swallow it entirely.
+ let row = vec![cell('\u{0E33}'), cell('\u{0E33}')];
+ assert_eq!(
+ segment_row(&row),
+ [RowSeg::Solo { col: 0 }, RowSeg::Solo { col: 1 }]
+ );
+ }
+
/// A marked cell never joins a batch: marks add characters without adding
/// columns, which would desync `force_width`'s glyph-per-column pinning.
#[test]
@@ -2176,7 +2411,7 @@ mod tests {
segment_row(&row),
[
wide(0, 2, "你"),
- cluster(2, 2, "好\u{FE0F}"),
+ wide_cluster(2, 2, "好\u{FE0F}"),
wide(4, 2, "世"),
]
);
diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs
index 94bda0fe..d7791529 100644
--- a/src/terminal/mod.rs
+++ b/src/terminal/mod.rs
@@ -15,6 +15,7 @@
//! `TermSize` / `RemoteTerminal` are re-exported here so the rest of the crate
//! can refer to `terminal::RemoteTerminal` without reaching into submodules.
+mod boxdraw;
mod cmd_editor;
mod completion;
pub mod element;
diff --git a/src/terminal/view.rs b/src/terminal/view.rs
index 7fa9d42c..ed5d8f2c 100644
--- a/src/terminal/view.rs
+++ b/src/terminal/view.rs
@@ -340,6 +340,12 @@ pub struct TerminalView {
/// The in-progress line saved when history navigation starts, so pressing ↓
/// past the newest entry restores what the user was typing.
history_stash: String,
+ /// Position of a run of ⌥. presses (readline's `yank-last-arg`): which
+ /// `history` entry the last press took its word from, and the char span it
+ /// left in the line — the next press replaces that span with the word from
+ /// the entry before it. Any other key clears this, so the following ⌥.
+ /// starts a fresh walk at the newest entry.
+ last_word_nav: Option,
/// A submitted command whose history-file record is deferred until the
/// shell reports back at its prompt, so the record can carry the command's
/// exit code (see [`PendingHistory`]).
@@ -448,6 +454,23 @@ struct PendingHistory {
seq: u64,
}
+/// Where a run of ⌥. presses has walked to (see
+/// [`TerminalView::last_word_nav`]).
+struct LastWordWalk {
+ /// Index into `history` the last press took its word from.
+ entry: usize,
+ /// Char offset of the word it inserted — the next press swaps that span
+ /// for the word from an older entry.
+ at: usize,
+ /// The word itself: both the span's length and a fingerprint. Edits that
+ /// bypass `handle_editor_key` (IME-committed text, a paste, a completion
+ /// pick, ⌘Z) can't clear `last_word_nav`, so before resuming, the walk
+ /// checks the line still holds this word at `at` with the caret at its
+ /// end — anything else means an edit intervened and the walk starts over
+ /// rather than eating it.
+ word: String,
+}
+
/// Seconds since the unix epoch — the timestamp history records carry.
fn unix_now() -> u64 {
std::time::SystemTime::now()
@@ -1172,6 +1195,7 @@ impl TerminalView {
ranked_cwd: None,
history_nav: None,
history_stash: String::new(),
+ last_word_nav: None,
pending_history: None,
completion: None,
completion_generation: 0,
@@ -1655,11 +1679,7 @@ impl TerminalView {
// Keep the cursor solid while typing (resets the blink phase).
self.cursor_visible = true;
// Typing clears the selection and jumps to the prompt.
- let mut term = self.terminal.term.lock();
- term.selection = None;
- term.scroll_display(Scroll::Bottom);
- self.scroll_frac = 0.;
- drop(term);
+ self.jump_to_prompt();
cx.notify();
// Consume so the key isn't also re-sent through the IME path.
cx.stop_propagation();
@@ -1776,11 +1796,43 @@ impl TerminalView {
let m = &ks.modifiers;
let key = ks.key.as_str();
self.cursor_visible = true;
+ // The raw key path does this per keystroke; the editor owns the keyboard
+ // at the prompt and every arm below returns early, so it has to happen
+ // once here instead. Without it a key pressed while scrolled up edits a
+ // line the viewport isn't showing (#208).
+ self.jump_to_prompt();
+
+ // ⌃P / ⌃N are readline's spelling of ↑ / ↓ (0x10 / 0x0e on the wire, and
+ // what the shell's own keymap answers when the editor isn't holding the
+ // line). Rewrite them into the arrow keys here rather than giving them
+ // arms of their own, so the two spellings can't drift apart — history
+ // recall, multi-line steps, the completion picker and the reverse-search
+ // menu all treat them identically from this point down.
+ let aliased;
+ let ks = if m.control && !m.platform && !m.alt && matches!(key, "p" | "n") {
+ aliased = gpui::Keystroke {
+ modifiers: gpui::Modifiers::default(),
+ key: if key == "p" { "up" } else { "down" }.to_string(),
+ key_char: None,
+ };
+ &aliased
+ } else {
+ ks
+ };
+ let m = &ks.modifiers;
+ let key = ks.key.as_str();
+
// Any key other than a vertical step drops the sticky goal column, so the
// next ↑/↓ takes its column from wherever the caret ends up.
if key != "up" && key != "down" {
self.editor_goal_col = None;
}
+ // Likewise, only a repeat of ⌥. continues an insert-last-word walk —
+ // anything else and the next press starts fresh at the newest entry
+ // rather than swallowing whatever now sits left of the caret.
+ if !(m.alt && key == ".") {
+ self.last_word_nav = None;
+ }
// A reverse search, when active, owns the keyboard.
if self.reverse_search.is_some() {
@@ -1845,8 +1897,9 @@ impl TerminalView {
self.close_completion();
// Readline-style control combinations, delegated so this dispatcher stays
- // scannable. Every Ctrl chord is swallowed at the prompt (recognized or
- // not), so this always notifies and returns.
+ // scannable. A chord the editor answers is consumed here; one it doesn't
+ // goes on to the shell rather than dying at the prompt. Either way this
+ // branch returns.
if m.control && !m.platform && !m.alt {
// Off macOS, word navigation and deletion live on Ctrl (the Windows /
// Linux convention): Ctrl+←/→ move by word (Shift extends the
@@ -1901,19 +1954,33 @@ impl TerminalView {
self.handoff_line_to_shell(&[0x12], cx);
return;
}
- self.apply_readline_ctrl(key);
- cx.notify();
+ if self.apply_readline_ctrl(key) {
+ cx.notify();
+ } else if let Some(bytes) = super::input::keystroke_to_bytes(ks, self.kitty_flags()) {
+ // No local widget answers this chord. Swallowing it is the one
+ // thing we mustn't do — the key worked before shell integration
+ // engaged, and zle's keymap (⌃T transpose, a `bindkey` widget,
+ // an fzf binding…) still knows what to do with it.
+ self.handoff_line_to_shell(&bytes, cx);
+ } else {
+ cx.notify();
+ }
return;
}
- // Readline-style Meta word chords on the edited line: M-b / M-f motions
- // and M-d delete-word, mirroring the Alt+←/→/Delete handling below. On
- // macOS these are reachable only with `macos_option_as_alt` on — with it
- // off the chord composes a character upstream and arrives here altless,
- // through the printable-text arm. Other Alt+letter chords stay swallowed
- // no-ops as before (the local editor can't mirror every zle widget).
+ // Readline-style Meta chords on the edited line: M-b / M-f motions,
+ // M-d delete-word (mirroring the Alt+←/→/Delete handling below) and
+ // M-. insert-last-word. On macOS these are reachable only with
+ // `macos_option_as_alt` on — with it off the chord composes a character
+ // upstream and arrives here altless, through the printable-text arm.
+ // Meta chords with no arm here reach the shell instead of dying (see
+ // the fallthrough at the bottom of the dispatcher).
if m.alt && !m.platform && !m.control {
match key {
+ "." => {
+ self.insert_last_word(cx);
+ return;
+ }
"b" => {
self.editor_move_h(false, m.shift, true);
cx.notify();
@@ -2038,7 +2105,7 @@ impl TerminalView {
// events carrying `key_char`; feed them through the same commit path
// the IME would use so the local editor sees the text. Skip control /
// Cmd chords and any non-printable char (function keys have no
- // `key_char`; Alt combos stay editor no-ops as before).
+ // `key_char`).
_ => {
if !m.control && !m.platform && !m.alt {
if let Some(ch) = ks.key_char.as_deref() {
@@ -2048,6 +2115,29 @@ impl TerminalView {
}
}
}
+ // A Meta chord with nothing local behind it (M-t transpose-word,
+ // M-u/M-l/M-c case widgets, whatever the user bound) goes to the
+ // shell rather than dying here — same reasoning as the Ctrl side
+ // above. The shared encoder goes first (it knows the shifted
+ // character and the Kitty form when `key_char` is there to
+ // consult), but the platforms that deliver Alt chords at all
+ // don't reliably carry one — then fall back to ESC + the key
+ // name, uppercased under Shift, as a raw terminal would send.
+ if m.alt && !m.control && !m.platform && key.chars().count() == 1 {
+ let bytes = super::input::keystroke_to_bytes(ks, self.kitty_flags())
+ .unwrap_or_else(|| {
+ let name = if m.shift {
+ key.to_uppercase()
+ } else {
+ key.to_string()
+ };
+ let mut b = vec![0x1b];
+ b.extend_from_slice(name.as_bytes());
+ b
+ });
+ self.handoff_line_to_shell(&bytes, cx);
+ return;
+ }
}
}
cx.notify();
@@ -2055,14 +2145,18 @@ impl TerminalView {
/// Apply a readline-style Ctrl chord to the command editor: Ctrl-A/E/B/F
/// motions (Ctrl-F also accepts the autosuggestion), Ctrl-W/U/K/H deletions
- /// (each removing the selection first if there is one), Ctrl-L clear-screen,
- /// Ctrl-R reverse search, Ctrl-C interrupt, and Ctrl-D EOF/forward-delete.
- /// Unrecognized chords are no-ops (the caller swallows every Ctrl combo at
- /// the prompt regardless).
+ /// (each removing the selection first if there is one), Ctrl-Y yanking the
+ /// last kill back, Ctrl-L clear-screen, Ctrl-R reverse search, Ctrl-C
+ /// interrupt, and Ctrl-D EOF/forward-delete.
///
- /// The caller resolves Ctrl-J / Ctrl-M (accept-line) and, when the history
- /// menu is switched off, Ctrl-R before this point — neither reaches here.
- fn apply_readline_ctrl(&mut self, key: &str) {
+ /// Returns whether the chord was recognized: the caller hands the ones that
+ /// weren't to the shell, so a widget tty7 has no answer for still reaches
+ /// the keymap that does.
+ ///
+ /// The caller resolves Ctrl-J / Ctrl-M (accept-line), Ctrl-P / Ctrl-N (the
+ /// arrow keys by another name) and, when the history menu is switched off,
+ /// Ctrl-R before this point — none of them reach here.
+ fn apply_readline_ctrl(&mut self, key: &str) -> bool {
match key {
"r" => self.start_reverse_search(),
"a" => {
@@ -2103,6 +2197,10 @@ impl TerminalView {
}
}
"h" => self.cmd.backspace(),
+ // Yank: the other half of ⌃W / ⌃U / ⌃K. Answered locally rather
+ // than handed to the shell — zle keeps its own kill ring, and
+ // yanking from it would paste text this editor never cut.
+ "y" => self.cmd.yank(),
"l" => {
// Clear screen belongs to the shell/readline layer: send the
// same form-feed byte the raw terminal path emits for Ctrl+L.
@@ -2133,8 +2231,9 @@ impl TerminalView {
self.cmd.delete();
}
}
- _ => {}
+ _ => return false,
}
+ true
}
/// Horizontal caret motion in the editor with selection semantics: Shift
@@ -2272,6 +2371,19 @@ impl TerminalView {
super::input::tab_bytes(shift, self.kitty_flags())
}
+ /// The housekeeping every input path shares: drop the selection the key
+ /// invalidated and bring the viewport back to the live prompt, whole lines
+ /// (`display_offset`) and sub-line remainder (`scroll_frac`) alike. Acting
+ /// on a line the user can't see is the thing to avoid — so this runs for
+ /// keys handled locally too, not only for bytes that reach the PTY.
+ fn jump_to_prompt(&mut self) {
+ let mut term = self.terminal.term.lock();
+ term.selection = None;
+ term.scroll_display(Scroll::Bottom);
+ drop(term);
+ self.scroll_frac = 0.;
+ }
+
/// Write a fixed byte sequence to the PTY (for keystrokes delivered as
/// actions rather than through `on_key_down`, e.g. Tab / Shift-Tab), applying
/// the same cursor / selection / scroll housekeeping as normal typing.
@@ -2281,11 +2393,7 @@ impl TerminalView {
}
self.terminal.write(bytes.to_vec());
self.cursor_visible = true;
- let mut term = self.terminal.term.lock();
- term.selection = None;
- term.scroll_display(Scroll::Bottom);
- self.scroll_frac = 0.;
- drop(term);
+ self.jump_to_prompt();
cx.notify();
}
@@ -3500,11 +3608,72 @@ impl TerminalView {
self.terminal.write(submit_bytes(&line, bracketed));
self.cmd.clear();
self.cursor_visible = true;
- let mut term = self.terminal.term.lock();
- term.selection = None;
- term.scroll_display(Scroll::Bottom);
- self.scroll_frac = 0.;
- drop(term);
+ self.jump_to_prompt();
+ cx.notify();
+ }
+
+ /// Readline's `yank-last-arg` (⌥.): drop the last word of the previous
+ /// command at the caret. Repeating the chord walks further back through the
+ /// history, each press swapping out the word the one before it inserted, so
+ /// a run of presses leaves exactly one word behind. Entries with no words
+ /// are stepped over rather than inserting nothing.
+ fn insert_last_word(&mut self, cx: &mut Context) {
+ // Only trust the recorded walk while the line still shows it: its word
+ // sitting at `at`, caret at the word's end, nothing selected. The keys
+ // this dispatcher sees reset `last_word_nav` themselves, but edits that
+ // bypass it (IME-committed text, a paste, a completion pick, ⌘Z) don't
+ // — resuming over those would delete text the walk never inserted.
+ let resumed = self.last_word_nav.take().filter(|walk| {
+ let len = walk.word.chars().count();
+ self.cmd.cursor() == walk.at + len
+ && self.cmd.selection().is_none()
+ && self
+ .cmd
+ .text()
+ .chars()
+ .skip(walk.at)
+ .take(len)
+ .eq(walk.word.chars())
+ });
+ // A repeat resumes one entry older than the last press; a fresh walk
+ // starts at the newest entry.
+ let start = match &resumed {
+ Some(walk) => walk.entry.checked_sub(1),
+ None => self.history.len().checked_sub(1),
+ };
+ let Some(mut entry) = start else {
+ // Nothing older to reach (or no history at all) — leave the line as
+ // it stands, the word the previous press inserted included.
+ self.last_word_nav = resumed;
+ return;
+ };
+ let word = loop {
+ if let Some(w) = self.history[entry].split_whitespace().next_back() {
+ break w.to_string();
+ }
+ let Some(older) = entry.checked_sub(1) else {
+ self.last_word_nav = resumed;
+ return;
+ };
+ entry = older;
+ };
+
+ // Take back what the previous press left, so the walk swaps words in
+ // place rather than piling them up.
+ if let Some(walk) = resumed {
+ self.cmd.clear_selection();
+ self.cmd.set_cursor(walk.at);
+ self.cmd.extend_to(walk.at + walk.word.chars().count());
+ self.cmd.delete_selection();
+ }
+ self.cmd.insert_str(&word);
+ // `insert_str` replaces a live selection first, which moves the caret
+ // to the selection's start — so the word's position is wherever the
+ // caret landed minus the word, not the pre-insert cursor.
+ let at = self.cmd.cursor() - word.chars().count();
+ self.last_word_nav = Some(LastWordWalk { entry, at, word });
+ // The line is now the user's own edit, not a recalled entry.
+ self.history_nav = None;
cx.notify();
}
@@ -4228,6 +4397,9 @@ impl TerminalView {
self.cmd.insert_str(text);
self.history_nav = None;
self.editor_goal_col = None;
+ // Typed text ends an ⌥. run: IME-committed text bypasses
+ // `handle_editor_key`'s reset, so it has to happen here too.
+ self.last_word_nav = None;
self.completion_refilter();
self.cursor_visible = true;
cx.notify();
@@ -4239,11 +4411,7 @@ impl TerminalView {
self.write_gap_text(text, text.as_bytes().to_vec(), cx);
// Keep the cursor solid while committing input (resets the blink phase).
self.cursor_visible = true;
- let mut term = self.terminal.term.lock();
- term.selection = None;
- term.scroll_display(Scroll::Bottom);
- self.scroll_frac = 0.;
- drop(term);
+ self.jump_to_prompt();
cx.notify();
}
@@ -7926,10 +8094,365 @@ mod gpui_tests {
assert_eq!(view.cmd.cursor(), 0);
view.handle_editor_key(&meta("f"), cx);
assert_eq!(view.cmd.cursor(), 4);
- // Other Meta letters stay swallowed no-ops (line untouched).
+ // Other Meta letters have no local widget, so they hand the line
+ // to the shell rather than dying here — see
+ // `an_unknown_meta_chord_goes_to_the_shell_with_the_line`.
view.handle_editor_key(&meta("z"), cx);
- assert_eq!(view.cmd.text(), "echo ");
- assert_eq!(view.cmd.cursor(), 4);
+ assert_eq!(view.cmd.text(), "");
+ })
+ .unwrap();
+ }
+
+ /// Fill the scrollback and park the viewport `offset` lines up inside it,
+ /// so a test can watch a keystroke snap it back to the live prompt.
+ fn scroll_into_history(view: &TerminalView, offset: usize) {
+ let mut parser: alacritty_terminal::vte::ansi::Processor = Default::default();
+ let mut term = view.terminal.term.lock();
+ parser.advance(&mut *term, &b"line\r\n".repeat(60));
+ term.scroll_display(Scroll::Delta(offset as i32));
+ assert_eq!(
+ term.grid().display_offset(),
+ offset,
+ "the viewport starts parked in the scrollback"
+ );
+ }
+
+ fn display_offset(view: &TerminalView) -> usize {
+ view.terminal.term.lock().grid().display_offset()
+ }
+
+ /// Scrolled up into the scrollback, recalling history with ↑ must bring the
+ /// viewport back to the live prompt (#208). The local editor owns ↑ and
+ /// returns early, so it never reached the "typing jumps to the prompt"
+ /// housekeeping on the raw key path — leaving the user editing a line they
+ /// cannot see.
+ #[gpui::test]
+ fn history_recall_snaps_the_viewport_back_to_the_prompt(cx: &mut TestAppContext) {
+ let (window, _daemon) = harness(cx);
+ window
+ .update(cx, |view, _, cx| {
+ view.history = vec!["echo hello".to_string()];
+ scroll_into_history(view, 10);
+ view.scroll_frac = 0.5;
+
+ view.handle_editor_key(&key("up"), cx);
+
+ assert_eq!(view.cmd.text(), "echo hello", "↑ recalled the entry");
+ assert_eq!(display_offset(view), 0, "and the viewport followed it down");
+ assert_eq!(view.scroll_frac, 0., "the sub-line remainder reset too");
+ })
+ .unwrap();
+ }
+
+ /// ⌃P / ⌃N are readline's history motions, and a raw terminal passes them
+ /// to the shell as 0x10 / 0x0e. The local editor swallows every Ctrl chord
+ /// at the prompt, so without arms of their own they went from "works" to
+ /// "does nothing" the moment shell integration engaged.
+ #[gpui::test]
+ fn ctrl_p_and_ctrl_n_walk_the_history(cx: &mut TestAppContext) {
+ let (window, _daemon) = harness(cx);
+ window
+ .update(cx, |view, _, cx| {
+ view.history = ["git status", "cargo build", "echo hello"]
+ .into_iter()
+ .map(String::from)
+ .collect();
+
+ // ⌃P walks back from the newest entry.
+ view.handle_editor_key(&key("ctrl-p"), cx);
+ assert_eq!(view.cmd.text(), "echo hello");
+ view.handle_editor_key(&key("ctrl-p"), cx);
+ assert_eq!(view.cmd.text(), "cargo build");
+ // ⌃N walks forward again.
+ view.handle_editor_key(&key("ctrl-n"), cx);
+ assert_eq!(view.cmd.text(), "echo hello");
+ // Past the newest entry the in-progress line comes back.
+ view.handle_editor_key(&key("ctrl-n"), cx);
+ assert_eq!(view.cmd.text(), "");
+ })
+ .unwrap();
+ }
+
+ /// A Ctrl chord the local editor has no widget for used to be swallowed, so
+ /// engaging shell integration *removed* ⌃T, ⌥T, ⌥U and every `bindkey`
+ /// widget the user had bound. Hand the line to zle instead and let its
+ /// keymap answer — the same escape hatch ⌃R already uses.
+ #[gpui::test]
+ fn an_unknown_ctrl_chord_goes_to_the_shell_with_the_line(cx: &mut TestAppContext) {
+ let (window, mut daemon) = harness(cx);
+ window
+ .update(cx, |view, _, cx| {
+ view.cmd.set("echo hi");
+ // ⌃T is readline's transpose-chars; tty7 has no widget for it.
+ view.handle_editor_key(&key("ctrl-t"), cx);
+ assert_eq!(
+ view.cmd.text(),
+ "",
+ "the line left for the shell, so the local buffer is empty"
+ );
+ assert!(
+ view.editor_handoff.is_some(),
+ "the local editor stands down for the rest of the line"
+ );
+ })
+ .unwrap();
+ assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
+ assert_eq!(next_input(&mut daemon), vec![0x14], "⌃T reached the shell");
+ }
+
+ /// The Meta half of the same gap: ⌥U (upcase-word) and friends were dead at
+ /// the prompt. Unrecognized Meta chords ship the line and the ESC-prefixed
+ /// key, the way a raw terminal would have.
+ #[gpui::test]
+ fn an_unknown_meta_chord_goes_to_the_shell_with_the_line(cx: &mut TestAppContext) {
+ let (window, mut daemon) = harness(cx);
+ window
+ .update(cx, |view, _, cx| {
+ view.cmd.set("echo hi");
+ view.handle_editor_key(
+ &gpui::Keystroke {
+ modifiers: gpui::Modifiers {
+ alt: true,
+ ..Default::default()
+ },
+ key: "u".to_string(),
+ key_char: None,
+ },
+ cx,
+ );
+ assert_eq!(view.cmd.text(), "");
+ })
+ .unwrap();
+ assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
+ assert_eq!(next_input(&mut daemon), b"\x1bu".to_vec());
+ }
+
+ /// ⌃W / ⌃U / ⌃K are *kills*, and ⌃Y is what puts a kill back — without it
+ /// the pair was half-implemented: the editor cut text with nowhere to paste
+ /// it from. ⌃Y has to stay local rather than reaching the shell, because
+ /// zle's kill ring is a different buffer and would yank unrelated text.
+ #[gpui::test]
+ fn ctrl_y_yanks_back_what_the_kill_chords_cut(cx: &mut TestAppContext) {
+ let (window, _daemon) = harness(cx);
+ window
+ .update(cx, |view, _, cx| {
+ view.cmd.set("echo hello world");
+ view.handle_editor_key(&key("ctrl-w"), cx);
+ assert_eq!(view.cmd.text(), "echo hello ");
+ view.handle_editor_key(&key("ctrl-y"), cx);
+ assert_eq!(view.cmd.text(), "echo hello world");
+ assert!(
+ view.editor_handoff.is_none(),
+ "the line never left for the shell"
+ );
+ })
+ .unwrap();
+ }
+
+ /// ⌥. is readline's `yank-last-arg`: it pulls the last word of the previous
+ /// command into the line, and repeating it walks further back through the
+ /// history, replacing what the last press inserted. Frequent enough that
+ /// paying the handoff cost (ghost text and completion gone for the rest of
+ /// the line) on every press would be the wrong trade — tty7 holds the same
+ /// history, so it answers locally.
+ #[gpui::test]
+ fn meta_dot_walks_back_through_the_last_words(cx: &mut TestAppContext) {
+ let (window, _daemon) = harness(cx);
+ window
+ .update(cx, |view, _, cx| {
+ let meta_dot = gpui::Keystroke {
+ modifiers: gpui::Modifiers {
+ alt: true,
+ ..Default::default()
+ },
+ key: ".".to_string(),
+ key_char: None,
+ };
+ view.history = ["git status", "cargo build --release", "echo hello world"]
+ .into_iter()
+ .map(String::from)
+ .collect();
+ view.cmd.set("ls ");
+
+ view.handle_editor_key(&meta_dot, cx);
+ assert_eq!(view.cmd.text(), "ls world", "newest entry's last word");
+ view.handle_editor_key(&meta_dot, cx);
+ assert_eq!(view.cmd.text(), "ls --release", "repeat steps one back");
+ view.handle_editor_key(&meta_dot, cx);
+ assert_eq!(view.cmd.text(), "ls status");
+ // Nothing older to reach: the line holds what it had.
+ view.handle_editor_key(&meta_dot, cx);
+ assert_eq!(view.cmd.text(), "ls status");
+ // The caret sits after the inserted word, ready to keep typing.
+ assert_eq!(view.cmd.cursor(), "ls status".chars().count());
+ })
+ .unwrap();
+ }
+
+ /// The walk is only a walk while ⌥. repeats. Once another key edits the
+ /// line, the next ⌥. starts over from the newest entry instead of eating
+ /// whatever happens to sit left of the caret.
+ #[gpui::test]
+ fn an_intervening_key_restarts_the_last_word_walk(cx: &mut TestAppContext) {
+ let (window, _daemon) = harness(cx);
+ window
+ .update(cx, |view, _, cx| {
+ let meta_dot = gpui::Keystroke {
+ modifiers: gpui::Modifiers {
+ alt: true,
+ ..Default::default()
+ },
+ key: ".".to_string(),
+ key_char: None,
+ };
+ view.history = ["cargo build --release", "echo hello world"]
+ .into_iter()
+ .map(String::from)
+ .collect();
+
+ view.handle_editor_key(&meta_dot, cx);
+ assert_eq!(view.cmd.text(), "world");
+ view.handle_editor_key(&key("left"), cx);
+ view.handle_editor_key(&key("end"), cx);
+ view.handle_editor_key(&meta_dot, cx);
+ assert_eq!(
+ view.cmd.text(),
+ "worldworld",
+ "a fresh walk appends rather than replacing the earlier word"
+ );
+ })
+ .unwrap();
+ }
+
+ /// Edits that bypass `handle_editor_key` — IME-committed text is the
+ /// everyday one (it's how all typing arrives on macOS and Windows) — must
+ /// end the walk too. Without that, the next ⌥. deletes the span the walk
+ /// recorded even though the user's typing now sits inside it.
+ #[gpui::test]
+ fn an_intervening_ime_commit_restarts_the_last_word_walk(cx: &mut TestAppContext) {
+ let (window, mut daemon) = harness(cx);
+ // `commit_text` edits the local line only while the editor is engaged
+ // at a shell prompt; anywhere else it writes gap text to the PTY.
+ DaemonMsg::Prompt {
+ active: true,
+ at_prompt: true,
+ last_exit: None,
+ }
+ .encode(&mut daemon)
+ .unwrap();
+ wait_for_input_active(&window, cx);
+ window
+ .update(cx, |view, _, cx| {
+ let meta_dot = gpui::Keystroke {
+ modifiers: gpui::Modifiers {
+ alt: true,
+ ..Default::default()
+ },
+ key: ".".to_string(),
+ key_char: None,
+ };
+ view.history = ["cargo build --release", "echo hello world"]
+ .into_iter()
+ .map(String::from)
+ .collect();
+
+ view.handle_editor_key(&meta_dot, cx);
+ assert_eq!(view.cmd.text(), "world");
+ view.commit_text("x", cx);
+ view.handle_editor_key(&meta_dot, cx);
+ assert_eq!(
+ view.cmd.text(),
+ "worldxworld",
+ "the typed char survives; the walk starts over after it"
+ );
+ })
+ .unwrap();
+ }
+
+ /// ⌥. with a selection active: the word replaces the selection (insertion
+ /// replaces selections everywhere in this editor), and the walk records
+ /// where the word actually landed — the caret the selection collapsed to,
+ /// not where the caret stood before the insert — so a repeat swaps the
+ /// word cleanly.
+ #[gpui::test]
+ fn meta_dot_over_a_selection_records_where_the_word_landed(cx: &mut TestAppContext) {
+ let (window, _daemon) = harness(cx);
+ window
+ .update(cx, |view, _, cx| {
+ let meta_dot = gpui::Keystroke {
+ modifiers: gpui::Modifiers {
+ alt: true,
+ ..Default::default()
+ },
+ key: ".".to_string(),
+ key_char: None,
+ };
+ view.history = ["cargo build --release", "echo hello world"]
+ .into_iter()
+ .map(String::from)
+ .collect();
+ view.cmd.set("ls foo");
+ // Select "foo" with the caret at the selection's far end.
+ view.cmd.set_cursor(3);
+ view.cmd.extend_to(6);
+
+ view.handle_editor_key(&meta_dot, cx);
+ assert_eq!(
+ view.cmd.text(),
+ "ls world",
+ "the word replaced the selection"
+ );
+ view.handle_editor_key(&meta_dot, cx);
+ assert_eq!(
+ view.cmd.text(),
+ "ls --release",
+ "the repeat swapped the word, not some other span"
+ );
+ })
+ .unwrap();
+ }
+
+ /// A shifted Meta chord must ship the shifted character: ⌥⇧U is `ESC U`
+ /// on the wire (upcase-region in zsh's keymap), not the `ESC u` of plain
+ /// ⌥U — gpui reports the key name unshifted, so the handoff has to apply
+ /// Shift itself when no `key_char` is there to consult.
+ #[gpui::test]
+ fn a_shifted_meta_chord_hands_off_the_shifted_character(cx: &mut TestAppContext) {
+ let (window, mut daemon) = harness(cx);
+ window
+ .update(cx, |view, _, cx| {
+ view.cmd.set("echo hi");
+ view.handle_editor_key(
+ &gpui::Keystroke {
+ modifiers: gpui::Modifiers {
+ alt: true,
+ shift: true,
+ ..Default::default()
+ },
+ key: "u".to_string(),
+ key_char: None,
+ },
+ cx,
+ );
+ assert_eq!(view.cmd.text(), "");
+ })
+ .unwrap();
+ assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
+ assert_eq!(next_input(&mut daemon), b"\x1bU".to_vec());
+ }
+
+ /// Chords the editor *does* answer stay local — handing off would forfeit
+ /// ghost text and completion for the rest of the line, and ⌃A/⌃E/⌃W are
+ /// exactly the keys pressed most often mid-edit.
+ #[gpui::test]
+ fn a_known_ctrl_chord_stays_in_the_local_editor(cx: &mut TestAppContext) {
+ let (window, _daemon) = harness(cx);
+ window
+ .update(cx, |view, _, cx| {
+ view.cmd.set("echo hi");
+ view.handle_editor_key(&key("ctrl-w"), cx);
+ assert_eq!(view.cmd.text(), "echo ", "⌃W cut the word locally");
+ assert!(view.editor_handoff.is_none());
})
.unwrap();
}
diff --git a/src/ui/app.rs b/src/ui/app.rs
index d5b06207..26c1925f 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -2506,6 +2506,12 @@ impl Tty7App {
self.update_config(cx, |cfg| cfg.check_for_updates = on);
}
+ /// Toggle inactive-pane dimming. Applies on the next render — `update_config`
+ /// notifies, and this view's render is what hands the flag to the pane tree.
+ pub(crate) fn set_dim_inactive_panes(&mut self, on: bool, cx: &mut Context) {
+ self.update_config(cx, |cfg| cfg.dim_inactive_panes = on);
+ }
+
pub(crate) fn set_cursor_blink(&mut self, on: bool, cx: &mut Context) {
self.update_config(cx, |cfg| cfg.cursor_blink = on);
// Turning blink off mid-cycle could leave the cursor in its hidden phase;
@@ -5208,7 +5214,7 @@ impl Render for Tty7App {
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
.and_then(|leaf| self.render_ssh_status_strip(&leaf, cx));
- // Render the active tab's pane tree; show focus rings only when split.
+ // Render the active tab's pane tree.
let body = match self.tabs.get(self.active) {
// Zero tabs: the window's own face — the home page (see `ui::home`).
None => self.render_home(cx).into_any_element(),
@@ -5229,8 +5235,11 @@ impl Render for Tty7App {
.child(leaf.clone())
.into_any_element(),
None => {
- let show_focus = active_tab.pane.leaves().len() > 1;
- active_tab.pane.render(show_focus, window, cx)
+ // Fading the unfocused panes only says anything once the
+ // tab is actually split, and the user can turn it off.
+ let dim_inactive = active_tab.pane.leaves().len() > 1
+ && cx.global::().dim_inactive_panes;
+ active_tab.pane.render(dim_inactive, window, cx)
}
}
}
diff --git a/src/ui/pane.rs b/src/ui/pane.rs
index 9151b5a5..cade6881 100644
--- a/src/ui/pane.rs
+++ b/src/ui/pane.rs
@@ -524,26 +524,33 @@ impl Pane> {
self.close_leaf_where(&|v| v.entity_id() == target.entity_id())
}
- /// Render the subtree. `show_focus` draws a focus ring on the active leaf
- /// (suppressed when the tab has a single pane).
- pub fn render(&self, show_focus: bool, window: &mut Window, cx: &mut App) -> gpui::AnyElement {
+ /// Render the subtree. `dim_inactive` fades every leaf but the focused one;
+ /// the caller decides it — it is off for an unsplit tab (nothing to
+ /// distinguish) and off when the user turned `dim_inactive_panes` off. Kept
+ /// a parameter rather than a `Config` global read here so the tree stays
+ /// renderable without one, as the rest of this module is.
+ pub fn render(
+ &self,
+ dim_inactive: bool,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> gpui::AnyElement {
match self {
Pane::Empty => div().into_any_element(),
Pane::Leaf(v) => {
- let focused = show_focus && v.read(cx).focus_handle.contains_focused(window, cx);
+ let focused = v.read(cx).focus_handle.contains_focused(window, cx);
// No full border (it reads as a hard rectangle).
div()
.size_full()
.relative()
.overflow_hidden()
- // Inactive panes (only when the tab is actually split) fade back
- // so the focused terminal reads as foreground without a hard
- // border. Element opacity multiplies through the whole subtree
- // (terminal glyphs + cell fills), unlike a background-tinted
- // scrim which is near-invisible on a light theme (white on
- // white). Applied to the container, so a click still lands on
- // the terminal and focuses it.
- .when(show_focus && !focused, |d| d.opacity(0.55))
+ // Inactive panes fade back so the focused terminal reads as
+ // foreground without a hard border. Element opacity multiplies
+ // through the whole subtree (terminal glyphs + cell fills),
+ // unlike a background-tinted scrim which is near-invisible on a
+ // light theme (white on white). Applied to the container, so a
+ // click still lands on the terminal and focuses it.
+ .when(dim_inactive && !focused, |d| d.opacity(0.55))
.child(v.clone())
.into_any_element()
}
@@ -674,7 +681,7 @@ impl Pane> {
.flex_basis(px(0.))
.min_w_0()
.min_h_0()
- .child(a.render(show_focus, window, cx)),
+ .child(a.render(dim_inactive, window, cx)),
)
.child(divider)
.child(
@@ -684,7 +691,7 @@ impl Pane> {
.flex_basis(px(0.))
.min_w_0()
.min_h_0()
- .child(b.render(show_focus, window, cx)),
+ .child(b.render(dim_inactive, window, cx)),
)
.into_any_element()
}
diff --git a/src/ui/presets.rs b/src/ui/presets.rs
index 80e94963..aac14015 100644
--- a/src/ui/presets.rs
+++ b/src/ui/presets.rs
@@ -1109,7 +1109,7 @@ struct BuiltinSpec {
}
/// A hand-picked set of familiar terminal palettes.
-static BUILTINS: [BuiltinSpec; 8] = [
+static BUILTINS: [BuiltinSpec; 9] = [
BuiltinSpec {
id: "light",
name: "Light",
@@ -1295,6 +1295,35 @@ static BUILTINS: [BuiltinSpec; 8] = [
(0xff, 0xff, 0xff),
],
},
+ BuiltinSpec {
+ id: "one_dark_pro",
+ name: "One Dark Pro",
+ background: 0x282c34,
+ foreground: 0xabb2bf,
+ // The editor cursor / focus blue, not the syntax blue `#61afef`: the
+ // accent doubles as the switch's checked track, and `#61afef` sits at
+ // the same luminance as the `#abb2bf` knob (1.11:1 — invisible).
+ accent: 0x528bff,
+ caret: None,
+ ansi16: [
+ (0x3f, 0x44, 0x51),
+ (0xe0, 0x6c, 0x75),
+ (0x98, 0xc3, 0x79),
+ (0xe5, 0xc0, 0x7b),
+ (0x61, 0xaf, 0xef),
+ (0xc6, 0x78, 0xdd),
+ (0x56, 0xb6, 0xc2),
+ (0xab, 0xb2, 0xbf),
+ (0x5c, 0x63, 0x70),
+ (0xff, 0x61, 0x6e),
+ (0xa5, 0xe0, 0x75),
+ (0xf0, 0xa4, 0x5d),
+ (0x4d, 0xc4, 0xff),
+ (0xde, 0x73, 0xff),
+ (0x4c, 0xd1, 0xe0),
+ (0xe6, 0xe6, 0xe6),
+ ],
+ },
BuiltinSpec {
id: "rose_pine",
name: "Rosé Pine",
@@ -1341,7 +1370,7 @@ mod tests {
}
/// Brightness is inferred correctly: the four light built-ins classify light,
- /// the four dark ones dark.
+ /// the five dark ones dark.
#[test]
fn dark_is_inferred_from_background() {
let dark: Vec<_> = builtins()
@@ -1349,7 +1378,10 @@ mod tests {
.filter(|t| t.dark)
.map(|t| t.id)
.collect();
- assert_eq!(dark, ["dark", "dracula", "harbor", "rose_pine"]);
+ assert_eq!(
+ dark,
+ ["dark", "dracula", "harbor", "one_dark_pro", "rose_pine"]
+ );
}
/// The selection surface must stay a *tint* — decisively on the background's
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
index 5aa52d0d..edd32d10 100644
--- a/src/ui/settings.rs
+++ b/src/ui/settings.rs
@@ -144,6 +144,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: "Blur",
keywords: "transparency translucent frosted vibrancy window background",
},
+ SearchEntry {
+ section: Appearance,
+ title: "Dim inactive panes",
+ keywords: "fade unfocused inactive split pane focus opacity highlight active dimming",
+ },
SearchEntry {
section: Appearance,
title: "Font size",
@@ -1518,10 +1523,12 @@ impl Tty7App {
.into_any_element()
}
- /// Window section (Appearance): global opacity slider + blur switch that
- /// apply to every theme. Both are config *overrides* — until touched they
- /// follow the active theme's own `opacity`/`blur`, and "Follow theme"
- /// clears them back to that state.
+ /// Window section (Appearance): the global opacity slider and blur switch
+ /// that apply to every theme, then the inactive-pane dimming switch. The
+ /// first two are config *overrides* — until touched they follow the active
+ /// theme's own `opacity`/`blur`, and "Follow theme" clears them back to that
+ /// state; the dimming switch is a plain flag no theme carries a value for,
+ /// so it sits below that button and "Follow theme" leaves it alone.
fn render_window_section(&self, cx: &mut Context) -> AnyElement {
let Some(slider) = self
.active_settings()
@@ -1531,6 +1538,7 @@ impl Tty7App {
};
let config = cx.global::();
let overridden = config.window_opacity.is_some() || config.window_blur.is_some();
+ let dim_inactive_panes = config.dim_inactive_panes;
let theme = presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
let opacity = Tty7App::effective_window_opacity(cx);
let blur = cx.global::().window_blur.unwrap_or(theme.blur);
@@ -1554,6 +1562,10 @@ impl Tty7App {
cx.listener(|this, on: &bool, window, cx| this.set_window_blur(*on, window, cx)),
)
.into_any_element();
+ let dim_switch = crate::ui::theme::switch("dim-inactive-panes", cx)
+ .checked(dim_inactive_panes)
+ .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_dim_inactive_panes(*on, cx)))
+ .into_any_element();
v_flex()
// Not "Window": Settings → Window & Tabs owns that word for the
@@ -1587,6 +1599,14 @@ impl Tty7App {
),
)
})
+ // Below "Follow theme", which resets the two rows above it and not
+ // this one — a plain setting with no theme value behind it.
+ .child(self.settings_row(
+ "Dim inactive panes",
+ "Fade unfocused panes in a split so the active one stands out.",
+ dim_switch,
+ cx,
+ ))
.into_any_element()
}
@@ -4612,6 +4632,7 @@ mod tests {
"Sidebar grouping",
"Tab completion",
"History search",
+ "Dim inactive panes",
] {
assert!(
settings_search_entries().iter().any(|e| e.title == title),