From 3e1535e9211b477ac56529ad2961ffb297ac595e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 21:18:25 -0400 Subject: [PATCH] T16: the settings window: Cmd-, opens it or brings it forward and Cmd-W closes it, Ctrl-, and Ctrl-Shift-W off macOS; General's rows in Ely's settings layout, Shell in Ely's path input and Start in as Home or the session's folder, each change checked, saved and applied to new sessions at once; a shell saves only when it names a program, else the row says why; the app quits with its last terminal window; shot aims at either window with to:settings and to:main --- AGENTS.md | 4 +- examples/shot.rs | 53 +++++++---- src/app.rs | 12 ++- src/settings/mod.rs | 69 +++++++++++++- src/settings/window.rs | 206 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 321 insertions(+), 23 deletions(-) create mode 100644 src/settings/window.rs diff --git a/AGENTS.md b/AGENTS.md index 792f210..b5069a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,13 +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; - 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`. +- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`; `window` is the settings window. - `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. @@ -64,4 +65,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-14: Files shows the shown session's folder in Ely's `Tree`, not `FileTree`, which takes every path up front and drops empty folders. A folder is read off the main thread when it first opens; the root and the open folders are read again whenever the session reports a folder, so at each prompt. Git's marks come from `git status --porcelain=v2 -z -- .`, run with `--no-optional-locks`, so it never holds `index.lock` against the user's own git, and under the user's `status.showUntrackedFiles`, so a home folder kept in git stays quick. An untracked folder marks what it holds. On macOS, whose file systems ignore Unicode forms, a name keeps its form on disk and meets Git's mark in composed form, as git's `core.precomposeunicode` writes it. A label built from a path shows each control character as its picture, `␊` for a newline: gpui shapes no newline, and a file may be named with one. A name not in UTF-8 keeps its bytes: its label shows it lossily, and its tree key adds the bytes after a NUL, which no path holds, so no two names share a key. A program without shell integration gets a line in the right column until it reports a folder. A control in a column header keeps its presses: Ely's header zooms the window on any double press. - 2026-10-14: git runs in English with `--literal-pathspecs` and without optional locks. Paths stay git's bytes until shown, so a name not in UTF-8 still names its own file; off Unix git writes UTF-8. Status reads porcelain v2. Paths to stage and unstage go in on stdin, NUL-ended (`--pathspec-from-file`), so any name and any count pass; an empty list is refused, since git would take it as the whole tree. Unstaging is `git reset`, which works before the first commit, and a staged rename unstages with its old path; a copy is a new file, its source no part of it. A diff is git's own, without external tools or text converters, a conflict against HEAD, our side, so a file we deleted reads as new; it is read into Ely's stretches; a binary file has none, and an untracked folder none either. Without an index git exits 1 for a difference and a failure alike; only a difference prints. What git warns of while it works goes to the log. The log needs a commit at HEAD: git refuses it before the first. - 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: empty for the login shell, a file at a full path, or a name on `PATH`; 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. diff --git a/examples/shot.rs b/examples/shot.rs index d0cd8bb..26245af 100644 --- a/examples/shot.rs +++ b/examples/shot.rs @@ -1,11 +1,12 @@ -//! Runs a program in the main window, plays keys and mouse gestures on it, and photographs it. +//! Runs a program in the main window, plays keys and mouse gestures on it, and photographs it; `to:settings` and `to:main` aim what follows. #[cfg(target_os = "macos")] fn main() { use std::time::Duration; use ely_gpui_component::Assets; - use elyterminal::{app, term::Launch}; + use elyterminal::{app, settings::window::SettingsWindow, term::Launch}; + use gpui::AnyWindowHandle; env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("elyterminal=info")) .init(); @@ -30,28 +31,42 @@ fn main() { .run(move |cx: &mut gpui::App| { ely_gpui_component::init(cx).expect("Ely starts"); app::start(cx).expect("the app starts"); - let window = app::open(launch, cx).expect("the window opens"); + let main: AnyWindowHandle = app::open(launch, cx).expect("the window opens").into(); cx.spawn(async move |cx| { - let number = window - .update(cx, |_, window, _| capture::number(window)) - .expect("the window lives") - .expect("a window number"); - capture::front(number).expect("the window on screen"); + let number = |aim: AnyWindowHandle, cx: &mut gpui::AsyncApp| { + let number = aim.update(cx, |_, window, _| capture::number(window)); + number.expect("the window lives").expect("a window number") + }; + let mut aim = main; + capture::front(number(aim, cx)).expect("the window on screen"); cx.background_executor().timer(Duration::from_secs(1)).await; for spec in &keys { - typing::post(spec, number).expect("a key event"); + match spec.as_str() { + "to:main" => { + aim = main; + capture::front(number(aim, cx)).expect("the window on screen"); + } + "to:settings" => { + let windows = cx.update(|cx| cx.windows()); + let settings = windows + .into_iter() + .find(|window| window.downcast::().is_some()); + aim = settings.expect("a settings window"); + capture::front(number(aim, cx)).expect("the window on screen"); + } + spec => typing::post(spec, number(aim, cx)).expect("a key event"), + } + if spec.starts_with("to:") { + log::info!("shot: {spec}"); + } cx.background_executor() .timer(Duration::from_millis(120)) .await; } cx.background_executor().timer(Duration::from_secs(1)).await; - 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"); + let title = aim.update(cx, |_, window, _| window.window_title()); + let title = title.expect("the window lives"); + capture::snapshot(number(aim, cx), std::path::Path::new(&out)).expect("a picture"); log::info!("shot: wrote {out}, titled {title:?}"); cx.update(|cx| cx.quit()); }) @@ -80,12 +95,13 @@ mod capture { u32::try_from(number).context("window number out of range") } - /// Shows the window without activating the app: gpui draws it, real input stays put. + /// Shows the window and makes it the app's key window, which AppKit sends keys to, without activating the app: gpui draws it, real input stays put. pub fn front(number: u32) -> anyhow::Result<()> { unsafe { let app: *mut Object = msg_send![class!(NSApplication), sharedApplication]; let window: *mut Object = msg_send![app, windowWithWindowNumber: i64::from(number)]; anyhow::ensure!(!window.is_null(), "no window {number}"); + let _: () = msg_send![window, makeKeyWindow]; let _: () = msg_send![window, orderFrontRegardless]; } Ok(()) @@ -144,7 +160,8 @@ mod typing { }; /// US layout key codes and what each key types. - const KEYS: [(&str, u16, &str); 26] = [ + const KEYS: [(&str, u16, &str); 27] = [ + (",", 43, ","), ("a", 0, "a"), ("b", 11, "b"), ("c", 8, "c"), diff --git a/src/app.rs b/src/app.rs index a58fd23..8e86102 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,7 +10,7 @@ use gpui::{ #[cfg(target_os = "macos")] use crate::term::mac; use crate::{ - settings::SettingsStore, + settings::{self, SettingsStore}, term::{ICONS, Launch, MONO, config, input, shell::ShellIntegration, spawn}, themes, workspace::{self, TITLE, Workspace}, @@ -41,9 +41,15 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> { ShellIntegration::init(cx)?; input::bind_keys(cx); workspace::bind_keys(cx); + settings::window::bind_keys(cx); + // Settings alone hold no terminal, so they go with the last one. cx.on_window_closed(|cx, _| { - if cx.windows().is_empty() { - log::info!("app: no window left; quitting"); + let windows = cx.windows(); + if !windows + .iter() + .any(|window| window.downcast::().is_some()) + { + log::info!("app: no terminal window left; quitting"); cx.quit(); } }) diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 80d0242..131b17e 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -1,6 +1,7 @@ mod store; +pub mod window; -use std::ops::RangeInclusive; +use std::{env, ffi::OsStr, ops::RangeInclusive, path::Path}; use serde::{Deserialize, Serialize}; @@ -129,10 +130,46 @@ impl Settings { } } anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty"); + anyhow::ensure!( + self.shell.is_empty() || runs(&self.shell, &env::var_os("PATH").unwrap_or_default()), + "shell is {:?}, which names no program", + self.shell + ); Ok(()) } } +/// Whether `shell` names a program a session starts: a file at a full path, or a name in one of `folders`, as `PATH` lists them. +fn runs(shell: &str, folders: &OsStr) -> bool { + let path = Path::new(shell); + if path.is_absolute() { + return program(path); + } + // A relative path would hang on each session's folder. + if path.components().count() != 1 { + return false; + } + env::split_paths(folders).any(|folder| { + let file = folder.join(path); + program(&file) || (cfg!(windows) && program(&file.with_extension("exe"))) + }) +} + +/// Whether `path` is a file a session can run. +fn program(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let run = + |meta: std::fs::Metadata| meta.is_file() && meta.permissions().mode() & 0o111 != 0; + path.metadata().is_ok_and(run) + } + #[cfg(not(unix))] + { + path.is_file() + } +} + fn within( field: &str, value: T, @@ -175,6 +212,36 @@ mod tests { ); } + #[test] + fn a_shell_names_a_program_or_none() { + let shell = |shell: &str| Settings { + shell: shell.into(), + ..Settings::default() + }; + assert!(shell("").validate().is_ok(), "the login shell"); + let named = if cfg!(windows) { "cmd" } else { "sh" }; + assert!(shell(named).validate().is_ok(), "a name on PATH"); + let error = shell("/no/such/shell").validate().expect_err("no file"); + let said = "shell is \"/no/such/shell\", which names no program"; + assert_eq!(error.to_string(), said); + assert!(shell("bin/sh").validate().is_err(), "a relative path"); + #[cfg(unix)] + { + assert!(runs("sh", OsStr::new("/bin"))); + let root = OsStr::new("/"); + assert!( + !runs("bin/sh", root), + "a relative path, though / holds bin/sh" + ); + assert!(shell("/bin/sh").validate().is_ok()); + assert!(shell("/bin").validate().is_err(), "a folder"); + assert!( + shell("/etc/hosts").validate().is_err(), + "a file that does not run" + ); + } + } + #[test] fn a_nan_is_out_of_range() { let settings = Settings { diff --git a/src/settings/window.rs b/src/settings/window.rs new file mode 100644 index 0000000..affe3e7 --- /dev/null +++ b/src/settings/window.rs @@ -0,0 +1,206 @@ +use ely_gpui_component::{ + buttons::SegmentedControl, + feedback::InlineMessage, + forms::{InputEvent, PathInput, TextInput}, + primitives::{FocusScope, IconName, Severity}, + settings::{SettingsLayout, SettingsRow, SettingsSection}, + shell::{ColumnShell, ResizeBorder, ShellColumn}, + theme::{ActiveTheme, ControlSize}, +}; +use gpui::{ + App, AppContext, Bounds, Context, Entity, FocusHandle, InteractiveElement, IntoElement, + KeyBinding, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, + Subscription, TitlebarOptions, Window, WindowBounds, WindowDecorations, WindowOptions, actions, + div, px, size, +}; + +use super::{SettingsStore, StartIn}; + +actions!(elyterminal, [OpenSettings, CloseSettings]); + +/// The key context of the settings window's content. +const CONTEXT: &str = "Settings"; + +/// Each category's key, name and icon. +const SECTIONS: [(&str, &str, IconName); 1] = [("general", "General", IconName::Settings)]; + +/// Cmd-, opens the settings, Cmd-W closes them; off macOS Ctrl-, and Ctrl-Shift-W, as Ghostty's. +pub fn bind_keys(cx: &mut App) { + let (open, close) = if cfg!(target_os = "macos") { + ("cmd-,", "cmd-w") + } else { + ("ctrl-,", "ctrl-shift-w") + }; + cx.bind_keys([ + KeyBinding::new(open, OpenSettings, None), + KeyBinding::new(close, CloseSettings, Some(CONTEXT)), + ]); + cx.on_action(|_: &OpenSettings, cx| open_window(cx)); +} + +/// Opens the settings window, or brings the open one forward. +fn open_window(cx: &mut App) { + let open = cx + .windows() + .into_iter() + .find_map(|window| window.downcast::()); + if let Some(window) = open { + log::info!("settings: the window comes forward"); + let forward = window.update(cx, |_, window, _| window.activate_window()); + if let Err(error) = forward { + log::error!("settings: the window did not come forward: {error:#}"); + } + return; + } + let bounds = Bounds::centered(None, size(px(760.0), px(560.0)), cx); + let options = WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: Some(TitlebarOptions { + title: Some("Settings".into()), + appears_transparent: true, + traffic_light_position: Some(cx.theme().traffic_light_origin()), + }), + // Ely's column draws the controls; only Linux reads this. + window_decorations: Some(WindowDecorations::Client), + ..Default::default() + }; + match cx.open_window(options, |window, cx| { + cx.new(|cx| SettingsWindow::new(window, cx)) + }) { + Ok(_) => log::info!("settings: the window opens"), + Err(error) => log::error!("settings: the window did not open: {error:#}"), + } +} + +/// The settings window: categories down the side, the open one's rows beside them. +pub struct SettingsWindow { + focus: FocusHandle, + section: SharedString, + shell: Entity, + /// Why the typed shell is not saved. + refused: Option, + _typed: Subscription, + _settings: Subscription, +} + +impl SettingsWindow { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let shell = cx.new(|cx| { + let mut input = TextInput::new(window, cx).placeholder("Login shell"); + input.set_text(SettingsStore::get(cx).shell.clone(), cx); + input + }); + let typed = cx.subscribe(&shell, |this, input, event: &InputEvent, cx| { + if let InputEvent::Changed = event { + let shell = input.read(cx).text().to_string(); + this.save_shell(shell, cx); + } + }); + // Focus inside, so the window's keys find its context. + let focus = cx.focus_handle(); + window.focus(&focus, cx); + Self { + focus, + section: SECTIONS[0].0.into(), + shell, + refused: None, + _typed: typed, + _settings: cx.observe_global::(|_, cx| cx.notify()), + } + } + + /// 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) { + let refused = match SettingsStore::update(cx, |settings| settings.shell = shell.clone()) { + Ok(()) => { + log::info!("settings: new sessions run {shell:?}"); + None + } + Err(error) => { + log::debug!("settings: the shell stays: {error:#}"); + Some(format!("{error:#}").into()) + } + }; + if refused != self.refused { + self.refused = refused; + cx.notify(); + } + } + + fn general(&self, cx: &Context) -> SettingsSection { + let start = match SettingsStore::get(cx).start_in { + StartIn::Home => "home", + StartIn::Session => "session", + }; + let shell = div() + .w(cx.theme().prose_width()) + .flex() + .flex_col() + .gap_1() + .child(PathInput::new("settings-shell", &self.shell)) + .children((self.refused.clone()).map(|why| InlineMessage::new(Severity::Danger, why))); + let start = SegmentedControl::new("settings-start-in", start) + .segment("home", "Home", None) + .segment("session", "Session's folder", None) + .size(ControlSize::Sm) + .on_change(|value, _, cx| start_in(value, cx)); + SettingsSection::new("General") + .row(SettingsRow::new("Shell").control(shell)) + .row(SettingsRow::new("Start in").control(start)) + } +} + +/// Where new sessions start, as the settings keep it. +fn start_in(value: &SharedString, cx: &mut App) { + let start = match value.as_ref() { + "home" => StartIn::Home, + "session" => StartIn::Session, + other => unreachable!("settings: no start {other}"), + }; + match SettingsStore::update(cx, |settings| settings.start_in = start) { + Ok(()) => log::info!("settings: new sessions start in {start:?}"), + Err(error) => log::error!("settings: the start stays: {error:#}"), + } +} + +impl Render for SettingsWindow { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let pick = cx.entity(); + let layout = SettingsLayout::new("settings", SECTIONS, self.section.clone(), { + move |key, _, cx| { + pick.update(cx, |this, cx| { + this.section = key.clone(); + cx.notify(); + }) + } + }) + .page(self.general(cx)); + let body = div() + .id("settings-body") + .size_full() + .overflow_y_scroll() + .p_6() + .child(layout); + let shell = ColumnShell::new("settings").column( + ShellColumn::new("settings-column") + .title("Settings") + .child(body), + ); + // The context sits above the focus root, so its keys work wherever focus is. + div() + .size_full() + .key_context(CONTEXT) + .on_action(|_: &CloseSettings, window, _| { + log::info!("settings: the window closes"); + window.remove_window(); + }) + .on_action(|_: &OpenSettings, window, _| { + log::info!("settings: the window comes forward"); + window.activate_window(); + }) + .child( + ResizeBorder::new() + .child(FocusScope::new(&self.focus).root().size_full().child(shell)), + ) + } +}