T17: Appearance: every theme as a card over a small window in its colors, a separate dark theme with its own grid, font family, size, line height and ligatures, cursor style and blink over a live prompt, opacity by whole percents, blur, padding; a theme change re-dresses the chrome at once; a change the store refuses says why at the top of the page
This commit is contained in:
6 files changed
+491
-15
No files matched your search
@@ -23,7 +23,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
## 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.
|
||||
- `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/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,3 +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.
|
||||
+11
@@ -65,6 +65,17 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
||||
"settings: font_family is {family:?}, which is not installed"
|
||||
);
|
||||
themes::apply(cx);
|
||||
let chosen =
|
||||
|settings: &settings::Settings| (settings.theme.clone(), settings.dark_theme.clone());
|
||||
let mut worn = chosen(SettingsStore::get(cx));
|
||||
cx.observe_global::<SettingsStore>(move |cx| {
|
||||
let now = chosen(SettingsStore::get(cx));
|
||||
if now != worn {
|
||||
worn = now;
|
||||
themes::apply(cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+91
-12
@@ -10,11 +10,14 @@ use ely_gpui_component::{
|
||||
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,
|
||||
Subscription, Task, TitlebarOptions, Window, WindowBounds, WindowDecorations, WindowOptions,
|
||||
actions, div, px, size,
|
||||
};
|
||||
|
||||
use super::{SettingsStore, StartIn};
|
||||
use super::{Settings, SettingsStore, StartIn};
|
||||
use crate::term::BLINK;
|
||||
|
||||
mod appearance;
|
||||
|
||||
actions!(elyterminal, [OpenSettings, CloseSettings]);
|
||||
|
||||
@@ -22,7 +25,10 @@ actions!(elyterminal, [OpenSettings, CloseSettings]);
|
||||
const CONTEXT: &str = "Settings";
|
||||
|
||||
/// Each category's key, name and icon.
|
||||
const SECTIONS: [(&str, &str, IconName); 1] = [("general", "General", IconName::Settings)];
|
||||
const SECTIONS: [(&str, &str, IconName); 2] = [
|
||||
("general", "General", IconName::Settings),
|
||||
("appearance", "Appearance", IconName::Palette),
|
||||
];
|
||||
|
||||
/// 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) {
|
||||
@@ -52,7 +58,7 @@ fn open_window(cx: &mut App) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let bounds = Bounds::centered(None, size(px(760.0), px(560.0)), cx);
|
||||
let bounds = Bounds::centered(None, size(px(900.0), px(640.0)), cx);
|
||||
let options = WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
@@ -79,8 +85,14 @@ pub struct SettingsWindow {
|
||||
shell: Entity<TextInput>,
|
||||
/// Why the typed shell is not saved.
|
||||
refused: Option<SharedString>,
|
||||
/// Why the last change did not save.
|
||||
failed: Option<SharedString>,
|
||||
font: Entity<TextInput>,
|
||||
/// Whether the cursor preview shows, as it blinks.
|
||||
lit: bool,
|
||||
_typed: Subscription,
|
||||
_settings: Subscription,
|
||||
_blink: Task<()>,
|
||||
}
|
||||
|
||||
impl SettingsWindow {
|
||||
@@ -90,6 +102,7 @@ impl SettingsWindow {
|
||||
input.set_text(SettingsStore::get(cx).shell.clone(), cx);
|
||||
input
|
||||
});
|
||||
let font = cx.new(|cx| TextInput::new(window, cx));
|
||||
let typed = cx.subscribe(&shell, |this, input, event: &InputEvent, cx| {
|
||||
if let InputEvent::Changed = event {
|
||||
let shell = input.read(cx).text().to_string();
|
||||
@@ -104,11 +117,33 @@ impl SettingsWindow {
|
||||
section: SECTIONS[0].0.into(),
|
||||
shell,
|
||||
refused: None,
|
||||
failed: None,
|
||||
font,
|
||||
lit: true,
|
||||
_typed: typed,
|
||||
_settings: cx.observe_global::<SettingsStore>(|_, cx| cx.notify()),
|
||||
_blink: Self::blink(cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Blinks the cursor preview as a terminal blinks its cursor, while it shows.
|
||||
fn blink(cx: &mut Context<Self>) -> Task<()> {
|
||||
cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(BLINK).await;
|
||||
let ticked = this.update(cx, |this, cx| {
|
||||
if this.section == "appearance" && SettingsStore::get(cx).cursor_blink {
|
||||
this.lit = !this.lit;
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
if ticked.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 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()) {
|
||||
@@ -143,7 +178,10 @@ impl SettingsWindow {
|
||||
.segment("home", "Home", None)
|
||||
.segment("session", "Session's folder", None)
|
||||
.size(ControlSize::Sm)
|
||||
.on_change(|value, _, cx| start_in(value, cx));
|
||||
.on_change({
|
||||
let this = cx.entity();
|
||||
move |value, _, cx| start_in(&this, value, cx)
|
||||
});
|
||||
SettingsSection::new("General")
|
||||
.row(SettingsRow::new("Shell").control(shell))
|
||||
.row(SettingsRow::new("Start in").control(start))
|
||||
@@ -151,21 +189,62 @@ impl SettingsWindow {
|
||||
}
|
||||
|
||||
/// Where new sessions start, as the settings keep it.
|
||||
fn start_in(value: &SharedString, cx: &mut App) {
|
||||
fn start_in(this: &Entity<SettingsWindow>, 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:#}"),
|
||||
set(
|
||||
this,
|
||||
format!("new sessions start in {start:?}"),
|
||||
cx,
|
||||
|settings| settings.start_in = start,
|
||||
);
|
||||
}
|
||||
|
||||
/// Saves one change and logs 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),
|
||||
) {
|
||||
let mut next = SettingsStore::get(cx).clone();
|
||||
edit(&mut next);
|
||||
if next == *SettingsStore::get(cx) {
|
||||
return;
|
||||
}
|
||||
let failed = match SettingsStore::update(cx, |settings| *settings = next) {
|
||||
Ok(()) => {
|
||||
log::info!("settings: {change}");
|
||||
None
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("settings: refused {change}: {error:#}");
|
||||
Some(format!("{error:#}").into())
|
||||
}
|
||||
};
|
||||
this.update(cx, |this, cx| {
|
||||
this.failed = failed;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
impl Render for SettingsWindow {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let pick = cx.entity();
|
||||
let page = match self.section.as_ref() {
|
||||
"general" => self.general(cx).into_any_element(),
|
||||
"appearance" => self.appearance(window, cx).into_any_element(),
|
||||
other => unreachable!("settings: no section {other}"),
|
||||
};
|
||||
let page = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.children((self.failed.clone()).map(|why| InlineMessage::new(Severity::Danger, why)))
|
||||
.child(page);
|
||||
let layout = SettingsLayout::new("settings", SECTIONS, self.section.clone(), {
|
||||
move |key, _, cx| {
|
||||
pick.update(cx, |this, cx| {
|
||||
@@ -174,7 +253,7 @@ impl Render for SettingsWindow {
|
||||
})
|
||||
}
|
||||
})
|
||||
.page(self.general(cx));
|
||||
.page(page);
|
||||
let body = div()
|
||||
.id("settings-body")
|
||||
.size_full()
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
use std::ops::RangeInclusive;
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::SegmentedControl,
|
||||
forms::{FontPicker, NumberInput, RadioCard, Slider, Switch},
|
||||
layout::SimpleGrid,
|
||||
settings::{SettingsRow, SettingsSection},
|
||||
theme::{ActiveTheme, ControlSize, Mix, Radius, TextSize},
|
||||
};
|
||||
use gpui::{
|
||||
App, Context, Div, Entity, Hsla, ParentElement, SharedString, Styled, Window, div, prelude::*,
|
||||
px, relative, rems,
|
||||
};
|
||||
|
||||
use super::{SettingsWindow, set};
|
||||
use crate::{
|
||||
settings::{CursorStyle, FONT_SIZE, LINE_HEIGHT, OPACITY, PADDING, Settings, SettingsStore},
|
||||
themes::{self, TerminalTheme},
|
||||
};
|
||||
|
||||
/// Each cursor style, its key and its name.
|
||||
const CURSORS: [(CursorStyle, &str, &str); 4] = [
|
||||
(CursorStyle::Block, "block", "Block"),
|
||||
(CursorStyle::Bar, "bar", "Bar"),
|
||||
(CursorStyle::Underline, "underline", "Underline"),
|
||||
(CursorStyle::Hollow, "hollow", "Hollow"),
|
||||
];
|
||||
|
||||
impl SettingsWindow {
|
||||
/// The theme, font, cursor and window rows.
|
||||
pub(super) fn appearance(&self, window: &Window, cx: &Context<Self>) -> Div {
|
||||
let settings = SettingsStore::get(cx);
|
||||
let theme = cx.theme();
|
||||
let this = cx.entity();
|
||||
let separate = settings.dark_theme.is_some();
|
||||
let dark = Switch::new("dark_theme", separate).on_change({
|
||||
let this = this.clone();
|
||||
move |on, _, cx| {
|
||||
let theme = SettingsStore::get(cx).theme.clone();
|
||||
set(
|
||||
&this,
|
||||
format!("separate dark theme is {on}"),
|
||||
cx,
|
||||
|settings| settings.dark_theme = on.then_some(theme),
|
||||
)
|
||||
}
|
||||
});
|
||||
let light = separate.then_some("Light");
|
||||
let mut themes = SettingsSection::new("Theme")
|
||||
.row(grid(&this, light, false, settings, window, cx))
|
||||
.row(SettingsRow::new("Separate dark theme").control(dark));
|
||||
if separate {
|
||||
themes = themes.row(grid(&this, Some("Dark"), true, settings, window, cx));
|
||||
}
|
||||
let family = FontPicker::new("font_family", &self.font)
|
||||
.selected(settings.font_family.clone())
|
||||
.on_change({
|
||||
let this = this.clone();
|
||||
move |family, _, cx| {
|
||||
let family = family.to_string();
|
||||
set(
|
||||
&this,
|
||||
format!("font_family is {family:?}"),
|
||||
cx,
|
||||
|settings| settings.font_family = family,
|
||||
)
|
||||
}
|
||||
});
|
||||
let font = SettingsSection::new("Font")
|
||||
.row(SettingsRow::new("Family").control(div().w(theme.prose_width()).child(family)))
|
||||
.row(SettingsRow::new("Size").control(number(
|
||||
&this,
|
||||
"font_size",
|
||||
settings.font_size,
|
||||
&FONT_SIZE,
|
||||
(1.0, 1),
|
||||
|settings, size| settings.font_size = size,
|
||||
)))
|
||||
.row(SettingsRow::new("Line height").control(number(
|
||||
&this,
|
||||
"line_height",
|
||||
settings.line_height,
|
||||
&LINE_HEIGHT,
|
||||
(0.05, 2),
|
||||
|settings, height| settings.line_height = height,
|
||||
)))
|
||||
.row(SettingsRow::new("Ligatures").control(switch(
|
||||
&this,
|
||||
"ligatures",
|
||||
settings.ligatures,
|
||||
|settings, on| settings.ligatures = on,
|
||||
)));
|
||||
let (_, now, _) = CURSORS
|
||||
.iter()
|
||||
.find(|(style, ..)| *style == settings.cursor_style)
|
||||
.expect("every cursor style is offered");
|
||||
let style = CURSORS
|
||||
.iter()
|
||||
.fold(
|
||||
SegmentedControl::new("cursor_style", *now),
|
||||
|control, (_, key, name)| control.segment(*key, *name, None),
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.on_change({
|
||||
let this = this.clone();
|
||||
move |key, _, cx| {
|
||||
let (style, ..) = CURSORS
|
||||
.iter()
|
||||
.find(|(_, each, _)| *each == key.as_ref())
|
||||
.expect("a cursor style the control offers");
|
||||
let style = *style;
|
||||
set(
|
||||
&this,
|
||||
format!("cursor_style is {style:?}"),
|
||||
cx,
|
||||
|settings| settings.cursor_style = style,
|
||||
)
|
||||
}
|
||||
});
|
||||
let active = themes::active(settings, window.appearance());
|
||||
let lit = self.lit || !settings.cursor_blink;
|
||||
let cursor = SettingsSection::new("Cursor")
|
||||
.row(SettingsRow::new("Style").control(style))
|
||||
.row(SettingsRow::new("Blink").control(switch(
|
||||
&this,
|
||||
"cursor_blink",
|
||||
settings.cursor_blink,
|
||||
|settings, on| settings.cursor_blink = on,
|
||||
)))
|
||||
.row(SettingsRow::new("Preview").control(prompt(settings, active, lit, cx)));
|
||||
let opacity = percent(settings.background_opacity);
|
||||
let slider = Slider::new("background_opacity", opacity)
|
||||
.range(percent(*OPACITY.start()), percent(*OPACITY.end()))
|
||||
.on_change({
|
||||
let this = this.clone();
|
||||
move |percent, _, cx| {
|
||||
let value = fraction(percent);
|
||||
set(
|
||||
&this,
|
||||
format!("background_opacity is {value}"),
|
||||
cx,
|
||||
|settings| settings.background_opacity = value,
|
||||
)
|
||||
}
|
||||
});
|
||||
let opacity = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(div().w(theme.label_width()).child(slider))
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(format!("{opacity}%")),
|
||||
);
|
||||
let background = SettingsSection::new("Window")
|
||||
.row(SettingsRow::new("Background opacity").control(opacity))
|
||||
.row(SettingsRow::new("Background blur").control(switch(
|
||||
&this,
|
||||
"background_blur",
|
||||
settings.background_blur,
|
||||
|settings, on| settings.background_blur = on,
|
||||
)))
|
||||
.row(SettingsRow::new("Horizontal padding").control(number(
|
||||
&this,
|
||||
"padding_x",
|
||||
settings.padding_x,
|
||||
&PADDING,
|
||||
(1.0, 0),
|
||||
|settings, padding| settings.padding_x = padding,
|
||||
)))
|
||||
.row(SettingsRow::new("Vertical padding").control(number(
|
||||
&this,
|
||||
"padding_y",
|
||||
settings.padding_y,
|
||||
&PADDING,
|
||||
(1.0, 0),
|
||||
|settings, padding| settings.padding_y = padding,
|
||||
)));
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_6()
|
||||
.child(themes)
|
||||
.child(font)
|
||||
.child(cursor)
|
||||
.child(background)
|
||||
}
|
||||
}
|
||||
|
||||
/// `fraction` in whole percents; the slider moves by them, so a saved fraction carries no float noise.
|
||||
fn percent(fraction: f32) -> f64 {
|
||||
(f64::from(fraction) * 100.).round()
|
||||
}
|
||||
|
||||
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,
|
||||
on: bool,
|
||||
put: fn(&mut Settings, bool),
|
||||
) -> Switch {
|
||||
let this = this.clone();
|
||||
Switch::new(field, on).on_change(move |on, _, cx| {
|
||||
set(&this, format!("{field} is {on}"), cx, |settings| {
|
||||
put(settings, on)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Every built-in theme as a card over its preview; `dark` picks the one a dark system wears.
|
||||
fn grid(
|
||||
this: &Entity<SettingsWindow>,
|
||||
title: Option<&'static str>,
|
||||
dark: bool,
|
||||
settings: &Settings,
|
||||
window: &Window,
|
||||
cx: &App,
|
||||
) -> Div {
|
||||
let theme = cx.theme();
|
||||
let (field, chosen) = if dark {
|
||||
("dark_theme", settings.dark_theme.as_deref())
|
||||
} else {
|
||||
("theme", Some(settings.theme.as_str()))
|
||||
};
|
||||
let cards = themes::all().iter().map(|each| {
|
||||
let (id, this) = (each.id, this.clone());
|
||||
let key = SharedString::from(format!("{field}-{id}"));
|
||||
RadioCard::new(key, chosen == Some(id), each.name)
|
||||
.preview(preview(each, cx))
|
||||
.on_select(move |_, cx| {
|
||||
set(&this, format!("{field} is {id}"), cx, |settings| {
|
||||
if dark {
|
||||
settings.dark_theme = Some(id.into());
|
||||
} else {
|
||||
settings.theme = id.into();
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
let rem = window.rem_size();
|
||||
let (min, gap) = (
|
||||
theme.label_width().to_pixels(rem),
|
||||
rems(0.75).to_pixels(rem),
|
||||
);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.py_3()
|
||||
.border_b_1()
|
||||
.border_color(theme.colors.border)
|
||||
.children(title.map(|title| div().text_size(theme.text_size(TextSize::Sm)).child(title)))
|
||||
.child(
|
||||
SimpleGrid::new(SharedString::from(format!("{field}-grid")), min, gap).children(cards),
|
||||
)
|
||||
}
|
||||
|
||||
/// A small window in `theme`'s colors: the sessions strip, then a prompt, its output and the cursor.
|
||||
fn preview(theme: &TerminalTheme, cx: &App) -> Div {
|
||||
let (bg, fg) = (theme.background, theme.foreground);
|
||||
let bar = |color: Hsla, width: f32| div().h_1().w(relative(width)).rounded_full().bg(color);
|
||||
let prompt = |end: Div| {
|
||||
let dot = div()
|
||||
.flex_none()
|
||||
.size_1p5()
|
||||
.rounded_full()
|
||||
.bg(theme.ansi[2]);
|
||||
div().flex().items_center().gap_1().child(dot).child(end)
|
||||
};
|
||||
let strip = div()
|
||||
.w(relative(0.2))
|
||||
.h_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.pt_2()
|
||||
.bg(bg.mix(&fg, 0.06))
|
||||
.children([0.6, 0.3, 0.3].map(|alpha| bar(fg.opacity(alpha), 0.6)));
|
||||
let lines = div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.justify_center()
|
||||
.gap_1()
|
||||
.px_2()
|
||||
.child(prompt(bar(fg, 0.5)))
|
||||
.child(bar(theme.ansi[4], 0.75))
|
||||
.child(bar(theme.ansi[5], 0.55))
|
||||
.child(prompt(div().flex_none().w_1p5().h_2p5().bg(theme.cursor)));
|
||||
let chrome = cx.theme();
|
||||
div()
|
||||
.w_full()
|
||||
.aspect_ratio(2.)
|
||||
.flex()
|
||||
.overflow_hidden()
|
||||
.rounded(chrome.radius(Radius::Sm))
|
||||
.border_1()
|
||||
.border_color(chrome.colors.border)
|
||||
.bg(bg)
|
||||
.child(strip)
|
||||
.child(lines)
|
||||
}
|
||||
|
||||
/// A prompt in the terminal's theme and font, its cursor drawn as the terminal draws it.
|
||||
fn prompt(settings: &Settings, theme: &TerminalTheme, lit: bool, cx: &App) -> Div {
|
||||
let rule = (px(settings.font_size) / 14.).max(px(1.));
|
||||
let mark = div().absolute().bg(theme.cursor);
|
||||
let cursor = match settings.cursor_style {
|
||||
CursorStyle::Block => mark.inset_0(),
|
||||
CursorStyle::Bar => mark.top_0().bottom_0().left_0().w(rule * 2.),
|
||||
CursorStyle::Underline => mark.left_0().right_0().bottom_0().h(rule * 2.),
|
||||
CursorStyle::Hollow => div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.border_1()
|
||||
.border_color(theme.cursor),
|
||||
};
|
||||
let chrome = cx.theme();
|
||||
div()
|
||||
.flex()
|
||||
.p_2()
|
||||
.rounded(chrome.radius(Radius::Sm))
|
||||
.border_1()
|
||||
.border_color(chrome.colors.border)
|
||||
.bg(theme.background)
|
||||
.text_color(theme.foreground)
|
||||
.font_family(settings.font_family.clone())
|
||||
.text_size(px(settings.font_size))
|
||||
.line_height(relative(settings.line_height))
|
||||
.child(div().text_color(theme.ansi[2]).child("$"))
|
||||
.child(" ls")
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.child(" ")
|
||||
.when(lit, |cell| cell.child(cursor)),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn each_percent_saves_as_its_own_short_fraction() {
|
||||
let (low, high) = (percent(*OPACITY.start()), percent(*OPACITY.end()));
|
||||
assert_eq!((low, high), (30., 100.));
|
||||
for whole in 30..=100 {
|
||||
let saved = fraction(f64::from(whole));
|
||||
assert_eq!(percent(saved), f64::from(whole));
|
||||
let text = serde_json::to_string(&saved).expect("a number");
|
||||
assert!(text.len() <= 4, "{whole}% saves as {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -18,4 +18,4 @@ mod unix;
|
||||
mod view;
|
||||
|
||||
pub use pty::{Events, Launch, Listener, Pty, PtyEvent, config, fit, spawn};
|
||||
pub use view::{ICONS, MONO, TerminalEvent, TerminalView};
|
||||
pub use view::{BLINK, ICONS, MONO, TerminalEvent, TerminalView};
|
||||
+1
-1
@@ -36,7 +36,7 @@ pub const ICONS: &str = "JetBrainsMono Nerd Font Mono";
|
||||
pub const MONO: &str = "JetBrains Mono";
|
||||
|
||||
/// How long the cursor shows, then hides, while it blinks.
|
||||
const BLINK: Duration = Duration::from_millis(600);
|
||||
pub const BLINK: Duration = Duration::from_millis(600);
|
||||
|
||||
/// What a terminal tells its owner.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
Reference in new issue
Block a user