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
+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