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
This commit is contained in:
11 files changed
+486
-35
No files matched your search
@@ -15,6 +15,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- `cargo run --example shot -- [--keys "SPECS"] <png> <program> [args…]` runs a program in the main window, plays SPECS on it, and writes a PNG of the window (macOS; no permission needed for the app's own window). It shows its window without activating the app, so gpui keeps drawing it and keystrokes or clicks meant for other apps stay there. Run it with `HOME` pointing at a temp folder to try settings without touching your own. SPECS, space-separated:
|
||||
- keys as real AppKit events: `a`, `ctrl-c`, `lopt-f` and `ropt-f` (left and right Option), `cmd-v`, `repeat-up` (AppKit's repeat flag);
|
||||
- input method calls: `mark:é` composes, `commit:é` types;
|
||||
- `activate` and `deactivate` the app, which takes real keystrokes while active: keep the two specs adjacent;
|
||||
- mouse gestures handed to gpui's view, at points from the content's top-left, modifiers as for keys: `click@x,y`, `click2@`, `click3@`, `rclick@`, `mclick@`, `drag@x,y@x,y`, `move@x,y`, the steps `ldown@`, `lup@`, `ldrag@`, `rdown@`, `rup@`, `mdown@`, `mup@`, and `scrollup@x,y@lines`, `scrolldown@x,y@lines`.
|
||||
- `scripts/check.sh`: fmt, clippy `-D warnings`, tests, the 500-line rule.
|
||||
|
||||
@@ -23,7 +24,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window.
|
||||
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`.
|
||||
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and the mouse modes before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and the mouse modes before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget` over alacritty's regex search.
|
||||
- `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title.
|
||||
- `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks.
|
||||
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
|
||||
@@ -56,4 +57,6 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-14: a wheel notch scrolls 3 lines, as Ghostty's discrete wheel; a trackpad scrolls its pixels. The wheel reports to a program that tracks the mouse, sends arrows on the alternate screen under mode 1007, and scrolls history otherwise.
|
||||
- 2026-10-14: Cmd-C and Cmd-V copy and paste on macOS, Ctrl-Shift-C and Ctrl-Shift-V elsewhere; Cmd-press, Ctrl-press off macOS, opens an OSC 8 target or a URL Ely's `links` finds in the wrapped line. Copies drop the newline alacritty ends a whole line with, as Ghostty's do. Bracketed pastes lose ESC and Ctrl-C, as alacritty's do; plain pastes end lines with CR. Typing and pasting clear the selection. Copy on select and OSC 52 writes to `p` or `s` go to the primary selection on Linux, as Ghostty's do; macOS and Windows have none, so they go to the clipboard.
|
||||
- 2026-10-14: a key's release goes out only when the terminal saw its press, so a key a binding takes sends no release, as in kitty.
|
||||
- 2026-10-14: find opens on Cmd-F (Ctrl-Shift-F off macOS) beside the grid, outside the `Terminal` key context, so its keys never reach the program. Matches wash in Ely's `warning` color, the current one stronger; the newest is current first, Enter steps older and Shift-Enter newer. Shift-Enter binds under `TerminalFind > ElyInput`, the context of Ely's text input, whose own Shift-Enter binding takes the key. New output searches again and keeps the current match.
|
||||
- 2026-10-14: the program hears focus in and out under mode 1004 when the terminal's focus or the window's activation changes. The window wears the program's title, the app's name while it names none. A bell is logged and makes no sound.
|
||||
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
|
||||
Generated
+1
@@ -1766,6 +1766,7 @@ dependencies = [
|
||||
"objc",
|
||||
"polling",
|
||||
"raw-window-handle",
|
||||
"regex",
|
||||
"rustix-openpty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -20,6 +20,7 @@ gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
|
||||
log = "0.4"
|
||||
polling = "3"
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_path_to_error = "0.1.20"
|
||||
|
||||
+19
-5
@@ -44,12 +44,14 @@ fn main() {
|
||||
.await;
|
||||
}
|
||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||
let number = window
|
||||
.update(cx, |_, window, _| capture::number(window))
|
||||
.expect("the window lives")
|
||||
.expect("a window number");
|
||||
let (number, title) = window
|
||||
.update(cx, |_, window, _| {
|
||||
(capture::number(window), window.window_title())
|
||||
})
|
||||
.expect("the window lives");
|
||||
let number = number.expect("a window number");
|
||||
capture::snapshot(number, std::path::Path::new(&out)).expect("a picture");
|
||||
log::info!("shot: wrote {out}");
|
||||
log::info!("shot: wrote {out}, titled {title:?}");
|
||||
cx.update(|cx| cx.quit());
|
||||
})
|
||||
.detach();
|
||||
@@ -337,6 +339,18 @@ mod typing {
|
||||
if let Some((call, text)) = spec.split_once(':') {
|
||||
return compose(call, text, number);
|
||||
}
|
||||
if let "activate" | "deactivate" = spec {
|
||||
unsafe {
|
||||
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
|
||||
if spec == "activate" {
|
||||
let _: () = msg_send![app, activateIgnoringOtherApps: YES];
|
||||
} else {
|
||||
let _: () = msg_send![app, deactivate];
|
||||
}
|
||||
}
|
||||
log::info!("shot: {spec}");
|
||||
return Ok(());
|
||||
}
|
||||
let mut parts: Vec<&str> = spec.split('-').collect();
|
||||
let name = parts.pop().context("an empty key")?;
|
||||
let mut flags: u64 = 0;
|
||||
|
||||
+29
-3
@@ -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,
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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,4 +1,5 @@
|
||||
mod boxes;
|
||||
mod find;
|
||||
pub mod frame;
|
||||
pub mod ink;
|
||||
pub mod input;
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user