T10: focus in and out reach a program under mode 1004; the window wears the program's title; a bell is logged and silent; the scrollback setting caps history and trims it live; Cmd-F finds in the scrollback with Ely's FindWidget, matches washed, Enter older, Shift-Enter newer, new output searched again
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Failing after 8m24s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 16:26:20 -04:00
1 parent a39be06cdc
commit 4d8788ff22
11 files changed
+486 -35

No files matched your search

+29 -3
View File
@@ -4,21 +4,46 @@ use anyhow::Context as _;
use ely_gpui_component::{primitives::FocusScope, theme::ActiveTheme};
use gpui::{
App, AppContext, Bounds, Context, Entity, FocusHandle, Focusable, IntoElement, ParentElement,
Render, Styled, TitlebarOptions, Window, WindowBounds, WindowHandle, WindowOptions, px, size,
Render, Styled, Subscription, TitlebarOptions, Window, WindowBounds, WindowHandle,
WindowOptions, px, size,
};
#[cfg(target_os = "macos")]
use crate::term::mac;
use crate::{
settings::SettingsStore,
term::{ICONS, Launch, MONO, TerminalView, config, input, shell::ShellIntegration, spawn},
term::{
ICONS, Launch, MONO, TerminalEvent, TerminalView, config, input, shell::ShellIntegration,
spawn,
},
themes,
};
/// The window title while the program names none.
const TITLE: &str = "ELYTerminal";
/// The main window's content.
pub struct Workspace {
focus: FocusHandle,
terminal: Entity<TerminalView>,
_events: Subscription,
}
impl Workspace {
/// The window wears the terminal's title.
fn terminal_event(
&mut self,
_: &Entity<TerminalView>,
event: &TerminalEvent,
window: &mut Window,
_: &mut Context<Self>,
) {
if let TerminalEvent::Title(title) = event {
let title = if title.is_empty() { TITLE } else { title };
log::debug!("app: the window title is {title:?}");
window.set_window_title(title);
}
}
}
impl Render for Workspace {
@@ -75,7 +100,7 @@ pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result<WindowHandle<Workspa
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(TitlebarOptions {
title: Some("ELYTerminal".into()),
title: Some(TITLE.into()),
..Default::default()
}),
..Default::default()
@@ -89,6 +114,7 @@ pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result<WindowHandle<Workspa
window.focus(&terminal.focus_handle(cx), cx);
cx.new(|cx| Workspace {
focus: cx.focus_handle(),
_events: cx.subscribe_in(&terminal, window, Workspace::terminal_event),
terminal,
})
})?;
+264
View File
@@ -0,0 +1,264 @@
use alacritty_terminal::{
grid::Dimensions,
index::{Column, Direction, Point},
term::{
Term,
search::{Match, RegexIter, RegexSearch},
},
};
use ely_gpui_component::{
editor::{FindOptions, FindWidget},
forms::{InputEvent, TextInput},
};
use gpui::{
AppContext, Context, Entity, Focusable, Hsla, InteractiveElement, IntoElement, ParentElement,
SharedString, Subscription, Window, div,
};
use super::{
input::{FIND, FindNewer},
view::TerminalView,
};
/// An open find: its query and options, the matches oldest first, the one stepped to.
pub struct Finder {
query: Entity<TextInput>,
options: FindOptions,
hits: Vec<Match>,
current: Option<usize>,
error: Option<SharedString>,
_input: Subscription,
}
impl Finder {
/// The matches on screen, each with its wash; the current one's is stronger.
pub fn lit<T>(&self, term: &Term<T>, color: Hsla) -> Vec<(Match, Hsla)> {
let offset = term.grid().display_offset() as i32;
let (top, bottom) = (-offset, term.screen_lines() as i32 - 1 - offset);
self.hits
.iter()
.enumerate()
.filter(|(_, hit)| hit.end().line.0 >= top && hit.start().line.0 <= bottom)
.map(|(at, hit)| {
let wash = if Some(at) == self.current { 0.55 } else { 0.25 };
(hit.clone(), color.opacity(wash))
})
.collect()
}
}
/// `query` under `options` as the grid's search reads it; word edges are ASCII, which its lazy DFA needs.
fn pattern(query: &str, options: FindOptions) -> String {
let body = if options.regex {
query.to_string()
} else {
regex::escape(query)
};
let body = if options.word {
format!(r"(?-u:\b)(?:{body})(?-u:\b)")
} else {
body
};
let case = if options.case { "(?-i)" } else { "(?i)" };
format!("{case}{body}")
}
/// Every match of `pattern` in the scrollback and the screen, oldest first.
fn search_all<T>(term: &Term<T>, pattern: &str) -> Result<Vec<Match>, String> {
let mut regex = RegexSearch::new(pattern).map_err(|error| error.to_string())?;
let start = Point::new(term.topmost_line(), Column(0));
let end = Point::new(term.bottommost_line(), term.last_column());
Ok(RegexIter::new(start, end, Direction::Right, term, &mut regex).collect())
}
/// The match one newer than `current`, or one older, round the ends.
fn stepped(current: Option<usize>, count: usize, newer: bool) -> Option<usize> {
let at = current?;
Some(if newer {
(at + 1) % count
} else {
(at + count - 1) % count
})
}
impl TerminalView {
/// Opens the find box, or takes the focus back to it.
pub(super) fn find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let query = match &self.finder {
Some(finder) => finder.query.clone(),
None => {
let query = cx.new(|cx| TextInput::new(window, cx));
let input = cx.subscribe_in(&query, window, |view, _, event, _, cx| match event {
InputEvent::Changed => view.search(true, cx),
InputEvent::Submit => view.step(false, cx),
InputEvent::Focus | InputEvent::Blur => {}
});
log::debug!("terminal {}: find opens", self.pty.pid);
self.finder = Some(Finder {
query: query.clone(),
options: FindOptions::default(),
hits: Vec::new(),
current: None,
error: None,
_input: input,
});
query
}
};
query.update(cx, |input, cx| {
let all = 0..input.text().len();
input.select(all, cx);
});
window.focus(&query.focus_handle(cx), cx);
cx.notify();
}
fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.finder.take().is_some() {
log::debug!("terminal {}: find closes", self.pty.pid);
window.focus(&self.focus, cx);
cx.notify();
}
}
/// Finds the query anew; a `fresh` query starts at the newest match, new output keeps the current one.
pub(super) fn search(&mut self, fresh: bool, cx: &mut Context<Self>) {
let Some(finder) = self.finder.as_mut() else {
return;
};
let query = finder.query.read(cx).text().to_string();
let found = if query.is_empty() {
Ok(Vec::new())
} else {
search_all(&self.pty.term.lock(), &pattern(&query, finder.options))
};
let (hits, error) = match found {
Ok(hits) => (hits, None),
Err(error) => {
log::debug!(
"terminal {}: {query:?} is no pattern: {error}",
self.pty.pid
);
(Vec::new(), Some(SharedString::from(error)))
}
};
let newest = hits.len().checked_sub(1);
let kept = finder.current.filter(|at| *at < hits.len());
finder.current = if fresh { newest } else { kept.or(newest) };
finder.error = error;
finder.hits = hits;
if fresh {
log::debug!(
"terminal {}: {} matches of {query:?}",
self.pty.pid,
finder.hits.len()
);
self.reveal(cx);
} else {
cx.notify();
}
}
/// Steps to a newer match, or an older one, and scrolls it into view.
fn step(&mut self, newer: bool, cx: &mut Context<Self>) {
let Some(finder) = self.finder.as_mut() else {
return;
};
finder.current = stepped(finder.current, finder.hits.len(), newer);
self.reveal(cx);
cx.notify();
}
fn reveal(&mut self, cx: &mut Context<Self>) {
let start = self.finder.as_ref().and_then(|finder| {
let at = finder.current?;
Some(*finder.hits[at].start())
});
if let Some(start) = start {
log::trace!("terminal {}: match at {start:?}", self.pty.pid);
self.pty.term.lock().scroll_to_point(start);
}
cx.notify();
}
fn set_options(&mut self, options: FindOptions, cx: &mut Context<Self>) {
if let Some(finder) = self.finder.as_mut() {
finder.options = options;
self.search(true, cx);
}
}
/// The find box over the grid; Shift-Enter steps to a newer match.
pub(super) fn find_box(&self, cx: &mut Context<Self>) -> Option<impl IntoElement + use<>> {
let finder = self.finder.as_ref()?;
let (flip, step, close, back) = (cx.entity(), cx.entity(), cx.entity(), cx.entity());
let total = finder.hits.len();
let widget = FindWidget::new(
("find", cx.entity_id()),
&finder.query,
finder.current,
total,
)
.options(finder.options)
.on_options(move |options, _, cx| flip.update(cx, |view, cx| view.set_options(options, cx)))
.on_step(move |newer, _, cx| step.update(cx, |view, cx| view.step(newer, cx)))
.on_close(move |window, cx| close.update(cx, |view, cx| view.close_find(window, cx)));
let widget = match &finder.error {
Some(error) => widget.error(error.clone()),
None => widget,
};
Some(
div()
.key_context(FIND)
.on_action(move |_: &FindNewer, _, cx| {
back.update(cx, |view, cx| view.step(true, cx))
})
.child(widget),
)
}
}
#[cfg(test)]
mod tests {
use alacritty_terminal::term::test::mock_term;
use super::*;
#[test]
fn find_takes_case_words_and_patterns() {
let term = mock_term("one two One\r\nthree phone one");
let count = |query: &str, options: FindOptions| {
search_all(&term, &pattern(query, options)).map(|all| all.len())
};
assert_eq!(count("one", FindOptions::default()), Ok(4));
let case = FindOptions {
case: true,
..FindOptions::default()
};
assert_eq!(count("One", case), Ok(1));
let word = FindOptions {
word: true,
..FindOptions::default()
};
assert_eq!(count("one", word), Ok(3), "phone holds no whole word");
let regex = FindOptions {
regex: true,
..FindOptions::default()
};
assert_eq!(count("t[wh]", regex), Ok(2));
assert_eq!(
count("(", FindOptions::default()),
Ok(0),
"plain text is escaped"
);
assert!(count("(", regex).is_err());
}
#[test]
fn steps_go_round_the_ends() {
assert_eq!(stepped(Some(0), 3, false), Some(2));
assert_eq!(stepped(Some(2), 3, true), Some(0));
assert_eq!(stepped(Some(1), 3, false), Some(0));
assert_eq!(stepped(None, 0, true), None);
}
}
+61 -12
View File
@@ -2,7 +2,7 @@ use alacritty_terminal::{
event::EventListener,
grid::Dimensions,
index::Point,
term::{Term, cell::Cell, cell::Flags, color::Colors},
term::{Term, cell::Cell, cell::Flags, color::Colors, search::Match},
vte::ansi::CursorShape,
};
use gpui::Hsla;
@@ -83,8 +83,8 @@ impl Frame {
}
}
/// The screen as it shows now, scrolled back or not.
pub fn frame<T: EventListener>(term: &Term<T>, ink: &Ink) -> Frame {
/// The screen as it shows now, scrolled back or not; `lit` matches wear their washes.
pub fn frame<T: EventListener>(term: &Term<T>, ink: &Ink, lit: &[(Match, Hsla)]) -> Frame {
let content = term.renderable_content();
let offset = content.display_offset as i32;
let mut rows = vec![Row::default(); term.screen_lines()];
@@ -94,17 +94,23 @@ pub fn frame<T: EventListener>(term: &Term<T>, ink: &Ink) -> Frame {
let (point, cell) = (indexed.point, indexed.cell);
let row = (point.line.0 + offset) as usize;
let mut look = style(cell, ink, content.colors);
let selected = content.selection.as_ref().is_some_and(|range| {
let covers = |contains: &dyn Fn(Point) -> bool| {
let (line, column) = (point.line, point.column);
range.contains(point)
|| (cell.flags.contains(Flags::WIDE_CHAR)
&& range.contains(Point::new(line, column + 1)))
contains(point)
|| (cell.flags.contains(Flags::WIDE_CHAR) && contains(Point::new(line, column + 1)))
|| (cell.flags.contains(Flags::WIDE_CHAR_SPACER)
&& range.contains(Point::new(line, column - 1)))
});
if selected {
&& contains(Point::new(line, column - 1)))
};
if content
.selection
.as_ref()
.is_some_and(|range| covers(&|at| range.contains(at)))
{
look.bg = Some(ink.selection);
look.fg = ink.selection_text.unwrap_or(look.fg);
} else if let Some((_, wash)) = lit.iter().find(|(hit, _)| covers(&|at| hit.contains(&at)))
{
look.bg = Some(*wash);
}
let spacer = cell
.flags
@@ -213,7 +219,7 @@ mod tests {
let mut parser: Processor = Processor::new();
parser.advance(&mut term, bytes);
let ink = Ink::new(themes::find("dracula").expect("dracula"), term.colors());
(frame(&term, &ink), ink)
(frame(&term, &ink, &[]), ink)
}
#[test]
@@ -319,7 +325,7 @@ mod tests {
selection.update(Point::new(Line(0), Column(3)), Side::Left);
term.selection = Some(selection);
let ink = Ink::new(themes::find("dracula").expect("dracula"), term.colors());
let frame = frame(&term, &ink);
let frame = frame(&term, &ink, &[]);
let grounds: Vec<_> = frame.rows[0]
.runs
.iter()
@@ -332,6 +338,49 @@ mod tests {
);
}
#[test]
fn found_matches_wear_their_washes_under_the_selection() {
use alacritty_terminal::index::{Column, Line, Side};
use alacritty_terminal::selection::{Selection, SelectionType};
let mut term = Term::new(Config::default(), &TermSize::new(12, 3), VoidListener);
let mut parser: Processor = Processor::new();
parser.advance(&mut term, "ab\u{4e2d}d ab".as_bytes());
let at = |column| Point::new(Line(0), Column(column));
let ink = Ink::new(themes::find("dracula").expect("dracula"), term.colors());
let (found, current) = (ink.ansi[3].opacity(0.25), ink.ansi[3].opacity(0.55));
let lit = [(at(1)..=at(2), found), (at(6)..=at(7), current)];
let grounds = |frame: &Frame| -> Vec<_> {
frame.rows[0]
.runs
.iter()
.map(|(bytes, style)| (*bytes, style.bg))
.collect()
};
let shown = frame(&term, &ink, &lit);
assert_eq!(
&grounds(&shown)[..5],
[
(1, None),
(5, Some(found)),
(2, None),
(2, Some(current)),
(4, None)
],
"b and both halves of the wide cell"
);
term.selection = Some(Selection::new(SelectionType::Simple, at(6), Side::Left));
term.selection
.as_mut()
.expect("a selection")
.update(at(6), Side::Right);
let shown = frame(&term, &ink, &lit);
assert_eq!(
grounds(&shown)[3],
(1, Some(ink.selection)),
"the selection wins"
);
}
#[test]
fn hidden_cells_show_nothing_but_their_ground() {
let (frame, _) = shown("\x1b[8;4;9;41mSe\u{301}\x1b[0m\x1b[2D".as_bytes());
+14 -5
View File
@@ -11,21 +11,30 @@ use super::{
view::TerminalView,
};
actions!(elyterminal, [Copy, Paste]);
actions!(elyterminal, [Copy, Paste, Find, FindNewer]);
/// The key context a terminal's root sets.
pub const CONTEXT: &str = "Terminal";
/// Copy and paste; Ely's focus keys go to the terminal's listeners instead.
/// The key context of a terminal's find box.
pub const FIND: &str = "TerminalFind";
/// Copy, paste and find; Ely's focus keys go to the terminal's listeners instead.
pub fn bind_keys(cx: &mut App) {
let (copy, paste) = if cfg!(target_os = "macos") {
("cmd-c", "cmd-v")
let (copy, paste, find) = if cfg!(target_os = "macos") {
("cmd-c", "cmd-v", "cmd-f")
} else {
("ctrl-shift-c", "ctrl-shift-v")
("ctrl-shift-c", "ctrl-shift-v", "ctrl-shift-f")
};
cx.bind_keys([
KeyBinding::new(copy, Copy, Some(CONTEXT)),
KeyBinding::new(paste, Paste, Some(CONTEXT)),
KeyBinding::new(find, Find, Some(CONTEXT)),
KeyBinding::new(
"shift-enter",
FindNewer,
Some(&format!("{FIND} > ElyInput")),
),
]);
cx.bind_keys(
["tab", "shift-tab", "f6", "shift-f6"]
+1
View File
@@ -1,4 +1,5 @@
mod boxes;
mod find;
pub mod frame;
pub mod ink;
pub mod input;
+25
View File
@@ -377,3 +377,28 @@ mod tests {
assert_eq!((size.num_cols, size.num_lines), (2, 1));
}
}
#[cfg(test)]
mod scrollback {
use alacritty_terminal::{event::VoidListener, grid::Dimensions, vte::ansi::Processor};
use super::*;
#[test]
fn the_setting_caps_history_and_a_change_trims_it() {
let settings = Settings {
scrollback: 5,
..Settings::default()
};
let mut term = Term::new(config(&settings), &TermSize::new(10, 3), VoidListener);
let mut parser: Processor = Processor::new();
let lines: String = (1..=20).map(|line| format!("{line}\r\n")).collect();
parser.advance(&mut term, lines.as_bytes());
assert_eq!(term.grid().history_size(), 5);
term.set_options(config(&Settings {
scrollback: 2,
..settings
}));
assert_eq!(term.grid().history_size(), 2);
}
}
+67 -9
View File
@@ -1,8 +1,12 @@
use std::{collections::HashSet, path::PathBuf, process::ExitStatus, rc::Rc, time::Duration};
use alacritty_terminal::{
event::Event, grid::Dimensions, term::ClipboardType, vte::ansi::CursorShape,
event::Event,
grid::Dimensions,
term::{ClipboardType, TermMode},
vte::ansi::CursorShape,
};
use ely_gpui_component::theme::ActiveTheme;
use futures::StreamExt;
use gpui::{
Bounds, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures, FontStyle,
@@ -11,9 +15,10 @@ use gpui::{
};
use super::{
find::Finder,
frame::frame,
ink::Ink,
input::{self, Copy, Paste},
input::{self, Copy, Find, Paste},
mouse::MouseMode,
paint::{self, Look},
pointer::Pointer,
@@ -55,13 +60,16 @@ pub struct TerminalView {
pub(super) mouse: MouseMode,
/// Keys whose press this terminal saw, so their releases may go out.
pub(super) held: HashSet<String>,
pub(super) finder: Option<Finder>,
pub(super) size: (usize, usize),
cell: (u16, u16),
/// Focused in an active window, as the program last heard.
focused: bool,
blinking: bool,
shown: bool,
_events: Task<()>,
_blink: Task<()>,
_activation: Subscription,
_focus: [Subscription; 3],
_settings: Subscription,
}
@@ -80,21 +88,30 @@ impl TerminalView {
let term = pty.term.lock();
(term.columns(), term.screen_lines())
};
let focus = cx.focus_handle().tab_stop(true);
let refocus =
|view: &mut Self, window: &mut Window, cx: &mut Context<Self>| view.refocus(window, cx);
Self {
pty,
focus: cx.focus_handle().tab_stop(true),
_focus: [
cx.observe_window_activation(window, refocus),
cx.on_focus(&focus, window, refocus),
cx.on_blur(&focus, window, refocus),
],
focus,
marked: None,
caret: Bounds::default(),
pointer: Pointer::default(),
mouse: MouseMode::default(),
held: HashSet::new(),
finder: None,
size,
cell: (8, 16),
focused: false,
blinking: false,
shown: true,
_events: Self::pump(events, cx),
_blink: Self::blink(cx),
_activation: cx.observe_window_activation(window, |_, _, cx| cx.notify()),
_settings: cx.observe_global::<SettingsStore>(|view, cx| {
view.pty
.term
@@ -153,7 +170,33 @@ impl TerminalView {
true
}
/// Tells a program that asked, by mode 1004, when the terminal gains or loses focus.
fn refocus(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let focused = self.focus.is_focused(window) && window.is_window_active();
if focused != self.focused {
self.focused = focused;
let asked = self.pty.term.lock().mode().contains(TermMode::FOCUS_IN_OUT);
log::debug!(
"terminal {}: focus {}{}",
self.pty.pid,
if focused { "in" } else { "out" },
if asked { ", reported" } else { "" }
);
if asked {
self.pty.write(if focused {
&b"\x1b[I"[..]
} else {
&b"\x1b[O"[..]
});
}
}
cx.notify();
}
fn handle(&mut self, events: Vec<PtyEvent>, cx: &mut Context<Self>) {
let drawn = events
.iter()
.any(|event| matches!(event, PtyEvent::Term(Event::Wakeup)));
for event in events {
match event {
PtyEvent::Folder(folder) => cx.emit(TerminalEvent::Folder(folder)),
@@ -161,6 +204,10 @@ impl TerminalView {
PtyEvent::Mouse(mode) => self.mouse = mode,
}
}
// ponytail: a full search per output batch while find is open; search new lines alone if scrollbacks lag.
if drawn && self.finder.is_some() {
self.search(false, cx);
}
cx.notify();
}
@@ -170,7 +217,10 @@ impl TerminalView {
Event::MouseCursorDirty | Event::CursorBlinkingChange => {}
Event::Title(title) => cx.emit(TerminalEvent::Title(title)),
Event::ResetTitle => cx.emit(TerminalEvent::Title(String::new())),
Event::Bell => cx.emit(TerminalEvent::Bell),
Event::Bell => {
log::debug!("terminal {}: a bell, kept silent", self.pty.pid);
cx.emit(TerminalEvent::Bell);
}
Event::ClipboardStore(kind, text) => {
log::info!("terminal {}: OSC 52 writes the {kind:?}", self.pty.pid);
self.copy_text(text, kind == ClipboardType::Selection, cx);
@@ -250,7 +300,10 @@ impl Render for TerminalView {
let theme = themes::active(&settings, window.appearance());
let term = self.pty.term.lock();
let ink = Ink::new(theme, term.colors());
let shown = frame(&term, &ink);
let lit = self.finder.as_ref().map_or_else(Vec::new, |finder| {
finder.lit(&term, cx.theme().colors.warning)
});
let shown = frame(&term, &ink, &lit);
let blinks = term.cursor_style().blinking;
drop(term);
let focused = self.focus.is_focused(window) && window.is_window_active();
@@ -277,6 +330,7 @@ impl Render for TerminalView {
.on_key_up(cx.listener(Self::key_up))
.on_action(cx.listener(|view, _: &Copy, _, cx| view.copy(cx)))
.on_action(cx.listener(|view, _: &Paste, _, cx| view.paste(cx)))
.on_action(cx.listener(|view, _: &Find, window, cx| view.find(window, cx)))
.on_any_mouse_down(cx.listener(Self::mouse_down))
.on_mouse_move(cx.listener(Self::mouse_move))
.on_scroll_wheel(cx.listener(Self::wheel))
@@ -291,7 +345,7 @@ impl Render for TerminalView {
root.on_mouse_up(button, cx.listener(Self::mouse_up))
.on_mouse_up_out(button, cx.listener(Self::mouse_up))
});
match look(&settings, &ink, window) {
let root = match look(&settings, &ink, window) {
Ok(look) => {
self.cell = (
f32::from(look.cell.width).round() as u16,
@@ -310,6 +364,10 @@ impl Render for TerminalView {
log::error!("terminal {}: {error:#}", self.pty.pid);
root.text_color(ink.fg).child(format!("{error:#}"))
}
}
};
div().relative().size_full().child(root).children(
self.find_box(cx)
.map(|found| div().absolute().top_2().right_4().child(found)),
)
}
}