T17: a font saves, and a settings file starts, only when gpui loads it as the terminal draws it, so the terminal never falls back without a word; numbers save on Enter, on leaving the field and on each step; after a refused change a number field drops its draft and the font picker shows the saved family again
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Failing after 9m27s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 22:37:18 -04:00
1 parent f2cc16fd94
commit ee37018577
9 files changed
+109 -61

No files matched your search

+3 -3
View File
@@ -16,14 +16,14 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- 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;
- `to:settings` and `to:main` aim the specs after them, and the picture, at that window, which becomes the app's key window, since AppKit sends keys there; the app stays inactive;
- `to:settings` and `to:main` aim the specs after them, and the picture, at that window, which becomes the app's key window, since AppKit sends keys there; the app stays inactive, and gpui sends an inactive window no focus or blur events, so press Enter to commit a field;
- 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.
## Layout
- `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/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/sessions.rs`: a session and its row. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, discard, commit and log; `src/git/diff.rs` reads git's diff into Ely's stretches. `src/git/panel.rs`: the Git panel's state and actions; `src/git/panel/view.rs`: how it draws.
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`; `window` is the settings window, `window/appearance` its Appearance page.
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`; `window` is the settings window, `window/appearance` its Appearance page. `tests/fonts.rs` brings its own main: gpui builds its Mac platform only on the main thread.
- `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. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
- `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.
@@ -67,4 +67,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- 2026-10-14: the Git panel shows HEAD's branch, or its short commit while detached; Ely's `ChangesList` of conflicted, staged, unstaged and untracked files; Ely's `CommitInput`; Ely's `CommitList` of the last 50 commits. A row's press opens its diff in Ely's fullscreen `Dialog` under the title bar, in Ely's `DiffViewer`, unified or split; a binary file, an untracked folder and a change of no line say so instead. Every control Ely draws works: stage, unstage, stage all, unstage all, amend, and discard, which asks first in Ely's `ConfirmDialog`: an edit goes back to the index (`git restore`); a new file, untracked or only intended to be added, is deleted (`git clean`, `git rm -f`). Git's clean passes over a repository of its own without a word, so a clean that removed nothing is an error. Actions run off the main thread, each after the one asked before it; a failure shows in Ely's `Alert` with git's words until dismissed or the next action, and after any action Files reads its marks again. A folder change drops the open diff, and one still loading, and blanks the view unless the new folder lies inside the shown work tree, so a cd within it does not blink; no action runs until the shown folder has read, so none reaches the repository left behind. A commit empties only the fields that still hold what it committed, and only once the shown folder has read and is in the same repository: a draft typed while it ran, or while a folder read, stays. A path shows on one line and once: as it is, or quoted as git quotes a name with control characters, quotes, backslashes or bytes not in UTF-8; a folder drops git's trailing slash, since no file shares its name. A section lists 500 files at most, and a line says how many are listed: Ely's list draws every row, and gpui copies every row's hitboxes and paint each frame, so 3000 rows halved the terminal's frames. The Git body is a cached view, so a terminal's output does not draw it again; it comes back from the Files tab with its lists scrolled to the top, since a view cached while hidden drops its widgets' state. Files stays uncached, so its tree keeps its open folders: Ely's `Tree` draws only the rows in view.
- 2026-10-14: Cmd-, opens the settings window, or brings the open one forward, and Cmd-W closes it; off macOS Ctrl-, and Ctrl-Shift-W, as Ghostty's. Categories sit in Ely's `SettingsLayout`, rows in its `SettingsRow`; a category joins the list with its rows. Each change goes through `SettingsStore::update`, which checks and saves, and every view reads the global, so it applies at once. A shell saves only when it names a program the running user may run, as the system's `access` answers, ACLs too: empty for the login shell, a file at a full path, or a name found in one of `PATH`'s full folders; a relative folder counts for none, since it would hang on each session's own folder. On Windows a name without an extension means its `.exe`, as `CreateProcess` reads it; a name with one keeps it. A program whose path is not UTF-8 counts for none: a session starts its program by text. A session runs that same full path, so the check and the start agree; else the row says why and the saved one stays, since a bad shell stops the next start. The window takes focus inside when it opens, so its keys find its context. The app quits with its last terminal window: settings alone hold no terminal.
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
- 2026-10-14: Appearance shows every built-in theme as Ely's `RadioCard` in a `SimpleGrid`, each over a small window in the theme's colors. Separate dark theme copies the theme into `dark_theme`, so turning it on changes nothing until a dark card is picked. A theme change re-dresses the chrome at once. Font family comes from Ely's `FontPicker`; size, line height and padding from its `NumberInput`, which applies each settled number as it is typed. The cursor preview is a prompt in the terminal's font and theme, its cursor drawn as the terminal draws it, blinking on the terminal's beat. Opacity moves by whole percents, so the saved fraction carries no float noise. Blur saves here and reaches the window in T19. A change the store refuses leaves the setting as it was, and the page says why at its top. The settings window opens at 900 by 640, room for three theme columns.
- 2026-10-14: Appearance shows every built-in theme as Ely's `RadioCard` in a `SimpleGrid`, each over a small window in the theme's colors. Separate dark theme copies the theme into `dark_theme`, so turning it on changes nothing until a dark card is picked. A theme change re-dresses the chrome at once. Font family comes from Ely's `FontPicker`; size, line height and padding from its `NumberInput`, which saves a number on Enter, on leaving the field and on each step. A font saves, and a settings file starts, only when gpui loads it as the terminal draws it: gpui falls back to another font without a word and loads none without an `m`, so the terminal draws with no font but its own. The cursor preview is a prompt in the terminal's font and theme, its cursor drawn as the terminal draws it, blinking on the terminal's beat. Opacity moves by whole percents, so the saved fraction carries no float noise. Blur saves here and reaches the window in T19. A change the store refuses leaves the setting as it was, and the page says why at its top; a number field drops its draft, and the font picker shows the saved family again. The settings window opens at 900 by 640, room for three theme columns.
+4
View File
@@ -45,3 +45,7 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-cli
[lints.clippy]
all = { level = "warn", priority = -1 }
[[test]]
name = "fonts"
harness = false
-5
View File
@@ -59,11 +59,6 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
mac::repeat_held_keys();
mac::deliver_command_key_ups()?;
}
let family = &SettingsStore::get(cx).font_family;
anyhow::ensure!(
cx.text_system().all_font_names().contains(family),
"settings: font_family is {family:?}, which is not installed"
);
themes::apply(cx);
let chosen =
|settings: &settings::Settings| (settings.theme.clone(), settings.dark_theme.clone());
+10
View File
@@ -24,6 +24,8 @@ impl SettingsStore {
let folder = dirs::config_dir().context("settings: the platform names no config folder")?;
let path = folder.join("ELYTerminal").join("settings.json");
let settings = load(&path)?;
loads(&settings.font_family, cx)
.with_context(|| format!("settings: {} holds a bad value", path.display()))?;
cx.set_global(Self { settings, path });
Ok(())
}
@@ -41,12 +43,20 @@ impl SettingsStore {
return Ok(());
}
next.validate()?;
if next.font_family != store.settings.font_family {
loads(&next.font_family, cx)?;
}
save(&store.path, &next)?;
cx.global_mut::<Self>().settings = next;
Ok(())
}
}
/// Checks the terminal can load `family`.
fn loads(family: &str, cx: &App) -> anyhow::Result<()> {
crate::term::load_font(&gpui::font(family.to_string()), cx.text_system()).map(|_| ())
}
/// The file's settings; defaults when there is no file. A file that fails to read or check is an error.
pub fn load(path: &Path) -> anyhow::Result<Settings> {
let text = match fs::read_to_string(path) {
+12 -8
View File
@@ -87,6 +87,8 @@ pub struct SettingsWindow {
refused: Option<SharedString>,
/// Why the last change did not save.
failed: Option<SharedString>,
/// Refusals so far; a field keyed by it drops its draft and shows the settings again.
drafts: usize,
font: Entity<TextInput>,
/// Whether the cursor preview shows, as it blinks.
lit: bool,
@@ -118,6 +120,7 @@ impl SettingsWindow {
shell,
refused: None,
failed: None,
drafts: 0,
font,
lit: true,
_typed: typed,
@@ -146,9 +149,10 @@ impl SettingsWindow {
/// Saves `shell` when it names a program; else says why and keeps the saved one.
fn save_shell(&mut self, shell: String, cx: &mut Context<Self>) {
let refused = match SettingsStore::update(cx, |settings| settings.shell = shell.clone()) {
self.refused = match SettingsStore::update(cx, |settings| settings.shell = shell.clone()) {
Ok(()) => {
log::info!("settings: new sessions run {shell:?}");
self.failed = None;
None
}
Err(error) => {
@@ -156,10 +160,7 @@ impl SettingsWindow {
Some(format!("{error:#}").into())
}
};
if refused != self.refused {
self.refused = refused;
cx.notify();
}
cx.notify();
}
fn general(&self, cx: &Context<Self>) -> SettingsSection {
@@ -203,17 +204,17 @@ fn start_in(this: &Entity<SettingsWindow>, value: &SharedString, cx: &mut App) {
);
}
/// Saves one change and logs it; a refused one changes nothing, and the window says why.
/// Saves one change and logs it, telling whether the settings hold it; a refused one changes nothing, and the window says why.
fn set(
this: &Entity<SettingsWindow>,
change: String,
cx: &mut App,
edit: impl FnOnce(&mut Settings),
) {
) -> bool {
let mut next = SettingsStore::get(cx).clone();
edit(&mut next);
if next == *SettingsStore::get(cx) {
return;
return true;
}
let failed = match SettingsStore::update(cx, |settings| *settings = next) {
Ok(()) => {
@@ -225,10 +226,13 @@ fn set(
Some(format!("{error:#}").into())
}
};
let saved = failed.is_none();
this.update(cx, |this, cx| {
this.drafts += usize::from(!saved);
this.failed = failed;
cx.notify();
});
saved
}
impl Render for SettingsWindow {
+47 -41
View File
@@ -42,7 +42,7 @@ impl SettingsWindow {
format!("separate dark theme is {on}"),
cx,
|settings| settings.dark_theme = on.then_some(theme),
)
);
}
});
let light = separate.then_some("Light");
@@ -56,33 +56,40 @@ impl SettingsWindow {
.selected(settings.font_family.clone())
.on_change({
let this = this.clone();
move |family, _, cx| {
move |family, window, cx| {
let family = family.to_string();
set(
let saved = set(
&this,
format!("font_family is {family:?}"),
cx,
|settings| settings.font_family = family,
)
);
// A refused pick shows the saved family again, the list closed.
if !saved {
let saved = SettingsStore::get(cx).font_family.clone();
let input = this.read(cx).font.clone();
input.update(cx, |input, cx| input.set_text(saved, cx));
window.blur(cx);
}
}
});
let font = SettingsSection::new("Font")
.row(SettingsRow::new("Family").control(div().w(theme.prose_width()).child(family)))
.row(SettingsRow::new("Size").control(number(
&this,
.row(SettingsRow::new("Size").control(self.number(
"font_size",
settings.font_size,
&FONT_SIZE,
(1.0, 1),
|settings, size| settings.font_size = size,
cx,
)))
.row(SettingsRow::new("Line height").control(number(
&this,
.row(SettingsRow::new("Line height").control(self.number(
"line_height",
settings.line_height,
&LINE_HEIGHT,
(0.05, 2),
|settings, height| settings.line_height = height,
cx,
)))
.row(SettingsRow::new("Ligatures").control(switch(
&this,
@@ -114,7 +121,7 @@ impl SettingsWindow {
format!("cursor_style is {style:?}"),
cx,
|settings| settings.cursor_style = style,
)
);
}
});
let active = themes::active(settings, window.appearance());
@@ -140,7 +147,7 @@ impl SettingsWindow {
format!("background_opacity is {value}"),
cx,
|settings| settings.background_opacity = value,
)
);
}
});
let opacity = div()
@@ -161,21 +168,21 @@ impl SettingsWindow {
settings.background_blur,
|settings, on| settings.background_blur = on,
)))
.row(SettingsRow::new("Horizontal padding").control(number(
&this,
.row(SettingsRow::new("Horizontal padding").control(self.number(
"padding_x",
settings.padding_x,
&PADDING,
(1.0, 0),
|settings, padding| settings.padding_x = padding,
cx,
)))
.row(SettingsRow::new("Vertical padding").control(number(
&this,
.row(SettingsRow::new("Vertical padding").control(self.number(
"padding_y",
settings.padding_y,
&PADDING,
(1.0, 0),
|settings, padding| settings.padding_y = padding,
cx,
)));
div()
.flex()
@@ -186,6 +193,30 @@ impl SettingsWindow {
.child(cursor)
.child(background)
}
/// A field over `range`, by `step` to `places`, that saves each committed number through `put`; a refusal starts it again from the settings.
fn number(
&self,
field: &'static str,
value: f32,
range: &RangeInclusive<f32>,
(step, places): (f64, usize),
put: fn(&mut Settings, f32),
cx: &Context<Self>,
) -> NumberInput {
let this = cx.entity();
NumberInput::new((field, self.drafts), f64::from(value))
.range(f64::from(*range.start()), f64::from(*range.end()))
.step(step)
.precision(places)
.size(ControlSize::Sm)
.on_commit(move |value, _, cx| {
let value = value as f32;
set(&this, format!("{field} is {value}"), cx, |settings| {
put(settings, value)
});
})
}
}
/// `fraction` in whole percents; the slider moves by them, so a saved fraction carries no float noise.
@@ -197,31 +228,6 @@ fn fraction(percent: f64) -> f32 {
(percent / 100.) as f32
}
/// A field over `range`, by `step` to `places`, that saves each settled number through `put`.
fn number(
this: &Entity<SettingsWindow>,
field: &'static str,
value: f32,
range: &RangeInclusive<f32>,
(step, places): (f64, usize),
put: fn(&mut Settings, f32),
) -> NumberInput {
NumberInput::new(field, f64::from(value))
.range(f64::from(*range.start()), f64::from(*range.end()))
.step(step)
.precision(places)
.size(ControlSize::Sm)
.on_change({
let this = this.clone();
move |value, _, cx| {
let value = value as f32;
set(&this, format!("{field} is {value}"), cx, |settings| {
put(settings, value)
})
}
})
}
fn switch(
this: &Entity<SettingsWindow>,
field: &'static str,
@@ -232,7 +238,7 @@ fn switch(
Switch::new(field, on).on_change(move |on, _, cx| {
set(&this, format!("{field} is {on}"), cx, |settings| {
put(settings, on)
})
});
})
}
@@ -263,7 +269,7 @@ fn grid(
} else {
settings.theme = id.into();
}
})
});
})
});
let rem = window.rem_size();
+1 -1
View File
@@ -18,4 +18,4 @@ mod unix;
mod view;
pub use pty::{Events, Launch, Listener, Pty, PtyEvent, config, fit, spawn};
pub use view::{BLINK, ICONS, MONO, TerminalEvent, TerminalView};
pub use view::{BLINK, ICONS, MONO, TerminalEvent, TerminalView, load_font};
+15 -3
View File
@@ -9,9 +9,9 @@ use alacritty_terminal::{
use ely_gpui_component::theme::ActiveTheme;
use futures::StreamExt;
use gpui::{
Bounds, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures, FontStyle,
Bounds, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures, FontId, FontStyle,
FontWeight, IntoElement, MouseButton, ParentElement, Pixels, Render, Styled, Subscription,
Task, Window, div, prelude::*, px, size,
Task, TextSystem, Window, div, prelude::*, px, size,
};
use super::{
@@ -254,6 +254,18 @@ impl TerminalView {
}
}
/// `font` as gpui loads it; gpui falls back to another font without a word, and loads none without an `m`.
pub fn load_font(font: &Font, system: &TextSystem) -> anyhow::Result<FontId> {
let id = system.resolve_font(font);
let loaded = system.get_font_for_id(id);
anyhow::ensure!(
loaded.is_some_and(|loaded| loaded.family == font.family),
"font_family is {:?}, which the terminal cannot load",
font.family
);
Ok(id)
}
/// The font, cell and metrics the settings give at this window's scale.
fn look(settings: &Settings, ink: &Ink, window: &Window) -> anyhow::Result<Look> {
let font = Font {
@@ -269,7 +281,7 @@ fn look(settings: &Settings, ink: &Ink, window: &Window) -> anyhow::Result<Look>
};
let text = px(settings.font_size);
let system = window.text_system();
let id = system.resolve_font(&font);
let id = load_font(&font, system)?;
let advance = system
.advance(id, text, 'M')
.map_err(|error| anyhow::anyhow!("{} cannot measure M: {error}", settings.font_family))?;
+17
View File
@@ -0,0 +1,17 @@
//! gpui builds its Mac platform only on the main thread, so this test brings its own main.
#[cfg(target_os = "macos")]
fn main() {
use elyterminal::term::load_font;
let system = gpui::TextSystem::new(gpui_platform::current_platform(true).text_system());
assert!(load_font(&gpui::font("Menlo"), &system).is_ok());
// A family missing, and one gpui passes over for lacking an `m`.
for family in ["No Such Family", "Apple Color Emoji"] {
let error = load_font(&gpui::font(family), &system).expect_err(family);
assert!(error.to_string().contains(family), "{error}");
}
println!("fonts: a font gpui does not load is refused");
}
#[cfg(not(target_os = "macos"))]
fn main() {}