Addresses the partial ports listed in docs/rewrite/notes/review-completeness-codex.md (details in docs/rewrite/notes/gaps-X2.md): - Flatpak: the exit watcher no longer consumes the host command; a shared FlatpakSignalHandle lets closing a surface still SIGHUP the host process group. - Child environment is byte-preserving end to end (ghostty_os::EnvMap of OsStrings) and Snap paths are filtered like filterSnapPaths. - argv handling no longer panics on non-UTF-8 arguments. - URI percent-decoding keeps raw bytes; OSC 7 pwd is passed through without lossy conversion. - Keybinding comparison/hashing uses full Unicode case folding from a table generated from CaseFolding.txt (Unicode 17.0.0). - Link regexes set Oniguruma's whole-search retry limit through the new minimal ghostty-onig crate (one SAFETY-commented FFI call). - OSC 9 NUL-capacity check ordering matches Zig. - Wayland registry binds/clears globals advertised after startup. - Custom shaders may pass sampler2D to helper functions (naga adapter). - +list-themes preview renders OSC 8 links, styled underlines and follows color-scheme reports. Also includes the inspector's integration edits in shared files (renderer state hook, termio VT recording, core/apprt-gtk wiring), X1's config/input/cli additions for completion generation, and a fix for a race in the termio backpressure test (it wrote before `stty raw` applied, so the cooked line discipline dropped bytes past MAX_CANON). Test Plan: - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings
2192 lines
75 KiB
Rust
2192 lines
75 KiB
Rust
//! The interactive theme preview of `+list-themes` (Zig: `Preview` in
|
|
//! list_themes.zig, built on libvaxis). This is a ratatui + crossterm
|
|
//! implementation with the same layout, key bindings and behaviour.
|
|
//!
|
|
//! Known differences from the vaxis version:
|
|
//! * input is read with `termina` (not crossterm) so terminal responses
|
|
//! such as the color-scheme report are visible; drawing still goes
|
|
//! through ratatui's crossterm backend,
|
|
//! * OSC 8 hyperlinks and the double/dashed/dotted/curly underline styles
|
|
//! are re-emitted as raw escapes after each frame (see [`Overlay`]),
|
|
//! * the color scheme is queried (`CSI ? 996 n`) and tracked
|
|
//! (`CSI ? 2031 h`) (sent unconditionally; vaxis gates on DECRQM 2031 which
|
|
//! termina cannot parse); it starts as light
|
|
//! like the Zig code does, but `COLORFGBG` is used as a hint if set.
|
|
|
|
use std::cell::RefCell;
|
|
use std::fmt::Write as _;
|
|
use std::io::Write;
|
|
|
|
use ghostty_config::config::Config;
|
|
use ghostty_config::types_a::TerminalColor;
|
|
use ratatui::Frame;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::crossterm::event::{
|
|
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
|
|
};
|
|
use ratatui::layout::{Position, Rect};
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
|
|
use super::{
|
|
ColorScheme, SMALL_LIST_THRESHOLD, ThemeListElement, absolute_path, should_include_theme,
|
|
};
|
|
use crate::error::Error;
|
|
|
|
const LOREM_IPSUM: &str = include_str!("../lorem_ipsum.txt");
|
|
|
|
/// Width of the theme list on the left.
|
|
const LIST_WIDTH: u16 = 32;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Mode {
|
|
Normal,
|
|
Help,
|
|
Search,
|
|
Save,
|
|
}
|
|
|
|
/// The terminal's color scheme (`vaxis.Color.Scheme`).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Scheme {
|
|
Light,
|
|
Dark,
|
|
}
|
|
|
|
/// A minimal single line text input (`vaxis.widgets.TextInput`).
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct TextInput {
|
|
pub text: String,
|
|
/// Cursor position in chars.
|
|
pub cursor: usize,
|
|
}
|
|
|
|
impl TextInput {
|
|
fn byte_idx(&self, chars: usize) -> usize {
|
|
self.text
|
|
.char_indices()
|
|
.nth(chars)
|
|
.map_or(self.text.len(), |(i, _)| i)
|
|
}
|
|
|
|
fn len(&self) -> usize {
|
|
self.text.chars().count()
|
|
}
|
|
|
|
pub fn clear(&mut self) {
|
|
self.text.clear();
|
|
self.cursor = 0;
|
|
}
|
|
|
|
/// Handle an editing key. Returns true if it was consumed.
|
|
pub fn update(&mut self, key: &KeyEvent) -> bool {
|
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
|
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
|
match key.code {
|
|
KeyCode::Char(c) if !ctrl && !alt => {
|
|
let at = self.byte_idx(self.cursor);
|
|
self.text.insert(at, c);
|
|
self.cursor += 1;
|
|
}
|
|
KeyCode::Backspace => {
|
|
if self.cursor > 0 {
|
|
let at = self.byte_idx(self.cursor - 1);
|
|
self.text.remove(at);
|
|
self.cursor -= 1;
|
|
}
|
|
}
|
|
KeyCode::Delete | KeyCode::Char('d') if key.code == KeyCode::Delete || ctrl => {
|
|
if self.cursor < self.len() {
|
|
let at = self.byte_idx(self.cursor);
|
|
self.text.remove(at);
|
|
}
|
|
}
|
|
KeyCode::Left | KeyCode::Char('b') if key.code == KeyCode::Left || ctrl => {
|
|
self.cursor = self.cursor.saturating_sub(1);
|
|
}
|
|
KeyCode::Right | KeyCode::Char('f') if key.code == KeyCode::Right || ctrl => {
|
|
self.cursor = (self.cursor + 1).min(self.len());
|
|
}
|
|
KeyCode::Home => self.cursor = 0,
|
|
KeyCode::Char('a') if ctrl => self.cursor = 0,
|
|
KeyCode::End => self.cursor = self.len(),
|
|
KeyCode::Char('e') if ctrl => self.cursor = self.len(),
|
|
KeyCode::Char('u') if ctrl => {
|
|
let at = self.byte_idx(self.cursor);
|
|
self.text.drain(..at);
|
|
self.cursor = 0;
|
|
}
|
|
KeyCode::Char('k') if ctrl => {
|
|
let at = self.byte_idx(self.cursor);
|
|
self.text.truncate(at);
|
|
}
|
|
KeyCode::Char('w') if ctrl => {
|
|
let upto = self.byte_idx(self.cursor);
|
|
let head = self.text[..upto].trim_end_matches(' ');
|
|
let new_end = head.rfind(' ').map_or(0, |i| i + 1);
|
|
self.text.drain(new_end..upto);
|
|
self.cursor = self.text[..new_end].chars().count();
|
|
}
|
|
_ => return false,
|
|
}
|
|
true
|
|
}
|
|
}
|
|
|
|
/// `zf.rank` with `.plain = true, .to_lower = true`: every token must match
|
|
/// the (lowercased) name as a subsequence. Only whether a rank exists is used
|
|
/// by the preview, so the score is a simple span length (lower is better).
|
|
#[must_use]
|
|
pub fn rank(name: &str, tokens: &[&str]) -> Option<f64> {
|
|
let name: Vec<char> = name.to_lowercase().chars().collect();
|
|
let mut total = 0.0;
|
|
for token in tokens {
|
|
let token: Vec<char> = token.chars().collect();
|
|
if token.is_empty() {
|
|
continue;
|
|
}
|
|
// Prefer a contiguous match.
|
|
let contiguous = name
|
|
.windows(token.len())
|
|
.position(|w| w == token.as_slice());
|
|
if let Some(start) = contiguous {
|
|
total += start as f64;
|
|
continue;
|
|
}
|
|
let mut ti = 0;
|
|
let mut first = None;
|
|
let mut last = 0;
|
|
for (i, c) in name.iter().enumerate() {
|
|
if ti < token.len() && *c == token[ti] {
|
|
first.get_or_insert(i);
|
|
last = i;
|
|
ti += 1;
|
|
}
|
|
}
|
|
if ti < token.len() {
|
|
return None;
|
|
}
|
|
total += 1000.0 + (last - first.unwrap_or(0)) as f64;
|
|
}
|
|
Some(total)
|
|
}
|
|
|
|
/// Standard base64 (for OSC 52).
|
|
fn base64(data: &[u8]) -> String {
|
|
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
let mut out = String::new();
|
|
for chunk in data.chunks(3) {
|
|
let n = (u32::from(chunk[0]) << 16)
|
|
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
|
|
| u32::from(*chunk.get(2).unwrap_or(&0));
|
|
out.push(T[(n >> 18) as usize & 63] as char);
|
|
out.push(T[(n >> 12) as usize & 63] as char);
|
|
out.push(if chunk.len() > 1 {
|
|
T[(n >> 6) as usize & 63] as char
|
|
} else {
|
|
'='
|
|
});
|
|
out.push(if chunk.len() > 2 {
|
|
T[n as usize & 63] as char
|
|
} else {
|
|
'='
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Underline styles ratatui cannot express (`SGR 4:N`).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Ul {
|
|
Double,
|
|
Curly,
|
|
Dotted,
|
|
Dashed,
|
|
}
|
|
|
|
impl Ul {
|
|
fn sgr(self) -> &'static str {
|
|
match self {
|
|
Ul::Double => "4:2",
|
|
Ul::Curly => "4:3",
|
|
Ul::Dotted => "4:4",
|
|
Ul::Dashed => "4:5",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A recorded decoration of drawn text (absolute cell coordinates).
|
|
struct Deco {
|
|
x: u16,
|
|
y: u16,
|
|
width: u16,
|
|
link: Option<String>,
|
|
ul: Option<Ul>,
|
|
}
|
|
|
|
/// A decorated run of cells, re-emitted as raw escapes after ratatui has
|
|
/// flushed the frame: text with an OSC 8 hyperlink and/or an extended
|
|
/// underline style. Each run is a single-style, single-row segment.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Overlay {
|
|
pub x: u16,
|
|
pub y: u16,
|
|
/// The cell text of the run.
|
|
pub text: String,
|
|
pub style: Style,
|
|
pub link: Option<String>,
|
|
pub ul: Option<Ul>,
|
|
}
|
|
|
|
impl Overlay {
|
|
fn capture(buf: &Buffer, d: &Deco) -> Overlay {
|
|
let mut text = String::new();
|
|
let mut skip = 0u16;
|
|
let mut style = Style::default();
|
|
for dx in 0..d.width {
|
|
let Some(cell) = buf.cell((d.x + dx, d.y)) else {
|
|
break;
|
|
};
|
|
if dx == 0 {
|
|
style = cell.style();
|
|
}
|
|
if skip > 0 {
|
|
skip -= 1;
|
|
continue;
|
|
}
|
|
text.push_str(cell.symbol());
|
|
skip = gw(cell.symbol()).saturating_sub(1);
|
|
}
|
|
Overlay {
|
|
x: d.x,
|
|
y: d.y,
|
|
text,
|
|
style,
|
|
link: d.link.clone(),
|
|
ul: d.ul,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn sgr_color(c: Color, fg: bool) -> String {
|
|
let (base, bright, ext, reset) = if fg {
|
|
(30, 90, 38, 39)
|
|
} else {
|
|
(40, 100, 48, 49)
|
|
};
|
|
match c {
|
|
Color::Reset => reset.to_string(),
|
|
Color::Black => base.to_string(),
|
|
Color::Red => (base + 1).to_string(),
|
|
Color::Green => (base + 2).to_string(),
|
|
Color::Yellow => (base + 3).to_string(),
|
|
Color::Blue => (base + 4).to_string(),
|
|
Color::Magenta => (base + 5).to_string(),
|
|
Color::Cyan => (base + 6).to_string(),
|
|
Color::Gray => (base + 7).to_string(),
|
|
Color::DarkGray => bright.to_string(),
|
|
Color::LightRed => (bright + 1).to_string(),
|
|
Color::LightGreen => (bright + 2).to_string(),
|
|
Color::LightYellow => (bright + 3).to_string(),
|
|
Color::LightBlue => (bright + 4).to_string(),
|
|
Color::LightMagenta => (bright + 5).to_string(),
|
|
Color::LightCyan => (bright + 6).to_string(),
|
|
Color::White => (bright + 7).to_string(),
|
|
Color::Rgb(r, g, b) => format!("{ext};2;{r};{g};{b}"),
|
|
Color::Indexed(i) => format!("{ext};5;{i}"),
|
|
}
|
|
}
|
|
|
|
/// The raw escape output that draws `overlays` on top of the frame.
|
|
#[must_use]
|
|
pub fn render_overlays(overlays: &[Overlay]) -> String {
|
|
let mut out = String::new();
|
|
for o in overlays {
|
|
let mut sgr: Vec<String> = vec!["0".to_owned()];
|
|
if let Some(c) = o.style.fg.map(|c| sgr_color(c, true)) {
|
|
sgr.push(c);
|
|
}
|
|
if let Some(c) = o.style.bg.map(|c| sgr_color(c, false)) {
|
|
sgr.push(c);
|
|
}
|
|
if o.style.add_modifier.contains(Modifier::BOLD) {
|
|
sgr.push("1".to_owned());
|
|
}
|
|
if o.style.add_modifier.contains(Modifier::ITALIC) {
|
|
sgr.push("3".to_owned());
|
|
}
|
|
if o.style.add_modifier.contains(Modifier::UNDERLINED) {
|
|
sgr.push("4".to_owned());
|
|
}
|
|
if let Some(ul) = o.ul {
|
|
sgr.push(ul.sgr().to_owned());
|
|
}
|
|
let _ = write!(out, "\x1b[{};{}H", o.y + 1, o.x + 1);
|
|
let _ = write!(out, "\x1b[{}m", sgr.join(";"));
|
|
if let Some(link) = &o.link {
|
|
let _ = write!(out, "\x1b]8;;{link}\x1b\\");
|
|
}
|
|
out.push_str(&o.text);
|
|
if o.link.is_some() {
|
|
out.push_str("\x1b]8;;\x1b\\");
|
|
}
|
|
out.push_str("\x1b[0m");
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Cached result of loading the currently previewed theme.
|
|
enum Loaded {
|
|
Ok(Box<Config>),
|
|
Err(String),
|
|
}
|
|
|
|
/// A pending side effect requested by the state (needs the terminal).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Effect {
|
|
/// Copy the text to the clipboard (OSC 52).
|
|
Copy(String),
|
|
}
|
|
|
|
/// The preview state (Zig: `Preview`).
|
|
pub struct Preview {
|
|
pub should_quit: bool,
|
|
pub themes: Vec<ThemeListElement>,
|
|
pub filtered: Vec<usize>,
|
|
pub current: usize,
|
|
pub window: usize,
|
|
pub hex: bool,
|
|
pub mode: Mode,
|
|
pub color_scheme: Scheme,
|
|
pub text_input: TextInput,
|
|
pub theme_filter: ColorScheme,
|
|
/// Last mouse event, consumed by the next draw.
|
|
mouse: Option<MouseEvent>,
|
|
/// The list height as of the last draw (for clamping the window).
|
|
list_height: u16,
|
|
loaded: Option<(String, Loaded)>,
|
|
/// Where `w` writes the auto theme file (overridable for tests).
|
|
pub auto_theme_path: Option<String>,
|
|
effects: Vec<Effect>,
|
|
overlays: Vec<Overlay>,
|
|
}
|
|
|
|
fn initial_scheme() -> Scheme {
|
|
// COLORFGBG is "fg;bg" (sometimes "fg;default;bg"); bg 0-6 and 8 are dark.
|
|
if let Ok(v) = std::env::var("COLORFGBG") {
|
|
if let Some(bg) = v.rsplit(';').next().and_then(|s| s.parse::<u8>().ok()) {
|
|
return if bg <= 6 || bg == 8 {
|
|
Scheme::Dark
|
|
} else {
|
|
Scheme::Light
|
|
};
|
|
}
|
|
}
|
|
Scheme::Light
|
|
}
|
|
|
|
impl Preview {
|
|
#[must_use]
|
|
pub fn new(themes: Vec<ThemeListElement>, theme_filter: ColorScheme) -> Preview {
|
|
let mut p = Preview {
|
|
should_quit: false,
|
|
themes,
|
|
filtered: vec![],
|
|
current: 0,
|
|
window: 0,
|
|
hex: false,
|
|
mode: Mode::Normal,
|
|
color_scheme: initial_scheme(),
|
|
text_input: TextInput::default(),
|
|
theme_filter,
|
|
mouse: None,
|
|
list_height: 0,
|
|
loaded: None,
|
|
auto_theme_path: None,
|
|
effects: vec![],
|
|
overlays: vec![],
|
|
};
|
|
p.update_filtered();
|
|
p
|
|
}
|
|
|
|
/// Recompute the filtered theme list (Zig: `updateFiltered`).
|
|
pub fn update_filtered(&mut self) {
|
|
let relative = self.current.saturating_sub(self.window);
|
|
let selected: Option<String> = self
|
|
.filtered
|
|
.get(self.current)
|
|
.map(|&i| self.themes[i].theme.clone());
|
|
|
|
let old = self.filtered.clone();
|
|
self.filtered.clear();
|
|
|
|
let mut theme_config = Config::new_default();
|
|
let lowered = self.text_input.text.to_ascii_lowercase();
|
|
let tokens: Vec<&str> = lowered.split(' ').filter(|t| !t.is_empty()).collect();
|
|
let searching = !self.text_input.text.is_empty();
|
|
|
|
for (i, theme) in self.themes.iter_mut().enumerate() {
|
|
// Errors are ignored here (Zig would abort the whole preview).
|
|
let _ = theme_config.load_file(&absolute_path(&theme.path));
|
|
if !should_include_theme(self.theme_filter, &theme_config) {
|
|
continue;
|
|
}
|
|
if searching {
|
|
theme.rank = rank(&theme.theme, &tokens);
|
|
if theme.rank.is_some() {
|
|
self.filtered.push(i);
|
|
}
|
|
} else {
|
|
self.filtered.push(i);
|
|
theme.rank = None;
|
|
}
|
|
}
|
|
|
|
if old == self.filtered {
|
|
return;
|
|
}
|
|
|
|
if self.filtered.is_empty() {
|
|
self.current = 0;
|
|
self.window = 0;
|
|
return;
|
|
}
|
|
|
|
let (current, window) = (|| {
|
|
let Some(selected) = &selected else {
|
|
return (0, 0);
|
|
};
|
|
for (i, &index) in self.filtered.iter().enumerate() {
|
|
if &self.themes[index].theme == selected {
|
|
// Keep the relative position but ensure all search results are visible
|
|
let new_window = i.saturating_sub(relative);
|
|
// If the new window would hide some results at the top, adjust it
|
|
return (
|
|
i,
|
|
if self.filtered.len() <= SMALL_LIST_THRESHOLD {
|
|
0
|
|
} else {
|
|
new_window
|
|
},
|
|
);
|
|
}
|
|
}
|
|
(0, 0)
|
|
})();
|
|
self.current = current;
|
|
self.window = window;
|
|
}
|
|
|
|
pub fn up(&mut self, count: usize) {
|
|
if self.filtered.is_empty() {
|
|
self.current = 0;
|
|
return;
|
|
}
|
|
self.current = self.current.saturating_sub(count);
|
|
}
|
|
|
|
pub fn down(&mut self, count: usize) {
|
|
if self.filtered.is_empty() {
|
|
self.current = 0;
|
|
return;
|
|
}
|
|
self.current += count;
|
|
if self.current >= self.filtered.len() {
|
|
self.current = self.filtered.len() - 1;
|
|
}
|
|
}
|
|
|
|
fn selected(&self) -> Option<&ThemeListElement> {
|
|
self.filtered.get(self.current).map(|&i| &self.themes[i])
|
|
}
|
|
|
|
/// Take the pending side effects.
|
|
pub fn take_effects(&mut self) -> Vec<Effect> {
|
|
std::mem::take(&mut self.effects)
|
|
}
|
|
|
|
/// Handle a terminal event (Zig: `update`).
|
|
pub fn update(&mut self, event: &Event) {
|
|
match event {
|
|
Event::Key(key) if key.kind != KeyEventKind::Release => self.update_key(key),
|
|
Event::Mouse(m) => self.mouse = Some(*m),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn update_key(&mut self, key: &KeyEvent) {
|
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
|
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
|
let plain = key.modifiers.difference(KeyModifiers::SHIFT).is_empty();
|
|
let code = key.code;
|
|
let is_char = |c: char| code == KeyCode::Char(c) && plain && !shift;
|
|
|
|
if ctrl && code == KeyCode::Char('c') {
|
|
self.should_quit = true;
|
|
}
|
|
|
|
match self.mode {
|
|
Mode::Normal => {
|
|
if plain && (is_char('q') || code == KeyCode::Esc) {
|
|
self.should_quit = true;
|
|
}
|
|
if (plain && (is_char('?') || code == KeyCode::Char('?') || code == KeyCode::F(1)))
|
|
|| (ctrl && code == KeyCode::Char('h'))
|
|
{
|
|
self.mode = Mode::Help;
|
|
}
|
|
if is_char('/') {
|
|
self.mode = Mode::Search;
|
|
}
|
|
if plain && code == KeyCode::Enter {
|
|
self.mode = Mode::Save;
|
|
}
|
|
if ctrl && matches!(code, KeyCode::Char('x' | '/' | '_')) {
|
|
self.text_input.clear();
|
|
self.update_filtered();
|
|
}
|
|
if plain && code == KeyCode::Home {
|
|
self.current = 0;
|
|
}
|
|
if plain && code == KeyCode::End {
|
|
self.current = self.filtered.len().saturating_sub(1);
|
|
}
|
|
if plain && (matches!(code, KeyCode::Char('j' | '+') | KeyCode::Down)) {
|
|
self.down(1);
|
|
}
|
|
if plain && code == KeyCode::PageDown {
|
|
self.down(20);
|
|
}
|
|
if plain && (matches!(code, KeyCode::Char('k' | '-') | KeyCode::Up)) {
|
|
self.up(1);
|
|
}
|
|
if plain && code == KeyCode::PageUp {
|
|
self.up(20);
|
|
}
|
|
if plain && matches!(code, KeyCode::Char('h' | 'x')) && !shift {
|
|
self.hex = true;
|
|
}
|
|
if is_char('d') {
|
|
self.hex = false;
|
|
}
|
|
if is_char('c') {
|
|
if let Some(t) = self.selected() {
|
|
let name = t.theme.clone();
|
|
self.effects.push(Effect::Copy(name));
|
|
}
|
|
} else if code == KeyCode::Char('C')
|
|
|| (code == KeyCode::Char('c') && shift && !ctrl)
|
|
{
|
|
if let Some(t) = self.selected() {
|
|
let path = t.path.clone();
|
|
self.effects.push(Effect::Copy(path));
|
|
}
|
|
}
|
|
if is_char('f') {
|
|
self.theme_filter = match self.theme_filter {
|
|
ColorScheme::All => ColorScheme::Dark,
|
|
ColorScheme::Dark => ColorScheme::Light,
|
|
ColorScheme::Light => ColorScheme::All,
|
|
};
|
|
self.update_filtered();
|
|
}
|
|
}
|
|
Mode::Help => {
|
|
if is_char('q') {
|
|
self.should_quit = true;
|
|
}
|
|
if (plain
|
|
&& (is_char('?')
|
|
|| code == KeyCode::Char('?')
|
|
|| code == KeyCode::Esc
|
|
|| code == KeyCode::F(1)))
|
|
|| (ctrl && code == KeyCode::Char('h'))
|
|
{
|
|
self.mode = Mode::Normal;
|
|
}
|
|
}
|
|
Mode::Search => {
|
|
if plain && matches!(code, KeyCode::Esc | KeyCode::Enter) {
|
|
self.mode = Mode::Normal;
|
|
return;
|
|
}
|
|
if ctrl && matches!(code, KeyCode::Char('x' | '/' | '_')) {
|
|
self.text_input.clear();
|
|
self.update_filtered();
|
|
return;
|
|
}
|
|
self.text_input.update(key);
|
|
self.update_filtered();
|
|
}
|
|
Mode::Save => {
|
|
if is_char('q') {
|
|
self.should_quit = true;
|
|
}
|
|
if plain && matches!(code, KeyCode::Esc | KeyCode::Enter) {
|
|
self.mode = Mode::Normal;
|
|
}
|
|
if is_char('w') {
|
|
self.save_selected_theme();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn save_selected_theme(&self) {
|
|
let Some(theme) = self.selected() else {
|
|
return;
|
|
};
|
|
let _ = write_auto_theme_file(self.auto_theme_path.as_deref(), &theme.theme);
|
|
}
|
|
|
|
// ---- colors ----
|
|
|
|
fn ui_fg(&self) -> Color {
|
|
match self.color_scheme {
|
|
Scheme::Light => Color::Rgb(0, 0, 0),
|
|
Scheme::Dark => Color::Rgb(0xff, 0xff, 0xff),
|
|
}
|
|
}
|
|
fn ui_bg(&self) -> Color {
|
|
match self.color_scheme {
|
|
Scheme::Light => Color::Rgb(0xff, 0xff, 0xff),
|
|
Scheme::Dark => Color::Rgb(0, 0, 0),
|
|
}
|
|
}
|
|
fn ui_standard(&self) -> Style {
|
|
Style::default().fg(self.ui_fg()).bg(self.ui_bg())
|
|
}
|
|
fn ui_hover_bg(&self) -> Color {
|
|
match self.color_scheme {
|
|
Scheme::Light => Color::Rgb(0xbb, 0xbb, 0xbb),
|
|
Scheme::Dark => Color::Rgb(0x22, 0x22, 0x22),
|
|
}
|
|
}
|
|
fn ui_highlighted(&self) -> Style {
|
|
Style::default().fg(self.ui_fg()).bg(self.ui_hover_bg())
|
|
}
|
|
fn ui_selected(&self) -> Style {
|
|
let bg = match self.color_scheme {
|
|
Scheme::Light => Color::Rgb(0xaa, 0xaa, 0xaa),
|
|
Scheme::Dark => Color::Rgb(0x33, 0x33, 0x33),
|
|
};
|
|
Style::default().fg(Color::Rgb(0, 0xaa, 0)).bg(bg)
|
|
}
|
|
fn ui_err(&self) -> Style {
|
|
Style::default().fg(Color::Rgb(0xff, 0, 0)).bg(self.ui_bg())
|
|
}
|
|
|
|
// ---- drawing ----
|
|
|
|
/// Draw the whole UI into `buf`. Returns the cursor position (search mode).
|
|
pub fn draw(&mut self, buf: &mut Buffer) -> Option<Position> {
|
|
let area = buf.area;
|
|
let cell = RefCell::new(buf);
|
|
let decos = RefCell::new(Vec::new());
|
|
let win = Win {
|
|
buf: &cell,
|
|
decos: &decos,
|
|
area,
|
|
};
|
|
win.clear();
|
|
|
|
let theme_list = win.child(0, 0, LIST_WIDTH.min(area.width), area.height);
|
|
self.list_height = theme_list.area.height;
|
|
|
|
let mut highlight: Option<u16> = None;
|
|
|
|
if let Some(mouse) = self.mouse.take() {
|
|
if self.mode == Mode::Normal {
|
|
match mouse.kind {
|
|
MouseEventKind::ScrollUp => self.up(1),
|
|
MouseEventKind::ScrollDown => self.down(1),
|
|
_ => {}
|
|
}
|
|
if mouse.column < theme_list.area.width && mouse.row < theme_list.area.height {
|
|
if mouse.kind == MouseEventKind::Up(MouseButton::Left) {
|
|
let selection = self.window + usize::from(mouse.row);
|
|
if selection < self.filtered.len() {
|
|
self.current = selection;
|
|
}
|
|
}
|
|
highlight = Some(mouse.row);
|
|
}
|
|
}
|
|
}
|
|
|
|
if self.filtered.is_empty() {
|
|
self.current = 0;
|
|
self.window = 0;
|
|
} else {
|
|
let height = usize::from(theme_list.area.height).max(1);
|
|
let start = self.window;
|
|
let end = self.window + height - 1;
|
|
if self.current > end {
|
|
self.window = self.current + 1 - height;
|
|
}
|
|
if self.current < start {
|
|
self.window = self.current;
|
|
}
|
|
if self.window >= self.filtered.len() {
|
|
self.window = self.filtered.len() - 1;
|
|
}
|
|
}
|
|
|
|
let ui = self.ui_standard();
|
|
theme_list.fill(ui);
|
|
|
|
for row in 0..theme_list.area.height {
|
|
let index = self.window + usize::from(row);
|
|
if index >= self.filtered.len() {
|
|
break;
|
|
}
|
|
let theme = &self.themes[self.filtered[index]];
|
|
|
|
#[derive(PartialEq)]
|
|
enum St {
|
|
Normal,
|
|
Highlighted,
|
|
Selected,
|
|
}
|
|
let style = if index == self.current {
|
|
St::Selected
|
|
} else if highlight == Some(row) {
|
|
St::Highlighted
|
|
} else {
|
|
St::Normal
|
|
};
|
|
|
|
if style == St::Selected {
|
|
theme_list.print("\u{276f} ", self.ui_selected(), row, 0);
|
|
}
|
|
theme_list.print_ex(
|
|
&theme.theme,
|
|
match style {
|
|
St::Normal => self.ui_standard(),
|
|
St::Highlighted => self.ui_highlighted(),
|
|
St::Selected => self.ui_selected(),
|
|
},
|
|
row,
|
|
2,
|
|
Some(&theme.to_uri()),
|
|
None,
|
|
);
|
|
if style == St::Selected {
|
|
let name_len = gw(&theme.theme);
|
|
if name_len + 4 < LIST_WIDTH {
|
|
for i in (2 + name_len)..(LIST_WIDTH - 2) {
|
|
theme_list.print(" ", self.ui_selected(), row, i);
|
|
}
|
|
}
|
|
theme_list.print(
|
|
" \u{276e}",
|
|
self.ui_selected(),
|
|
row,
|
|
LIST_WIDTH.saturating_sub(2),
|
|
);
|
|
}
|
|
}
|
|
|
|
self.draw_preview(&win, LIST_WIDTH);
|
|
|
|
let mut cursor = None;
|
|
match self.mode {
|
|
Mode::Normal => {}
|
|
Mode::Help => {
|
|
let (width, height) = (60u16, 22u16);
|
|
let child = win.child_border(
|
|
(area.width / 2).saturating_sub(width / 2),
|
|
(area.height / 2).saturating_sub(height / 2),
|
|
width,
|
|
height,
|
|
self.ui_standard(),
|
|
);
|
|
let key_help: [(&str, &str); 19] = [
|
|
("^C, q, ESC", "Quit."),
|
|
("F1, ?, ^H", "Toggle help window."),
|
|
("f", "Cycle through theme filters."),
|
|
("k, \u{2191}", "Move up 1 theme."),
|
|
("ScrollUp", "Move up 1 theme."),
|
|
("PgUp", "Move up 20 themes."),
|
|
("j, \u{2193}", "Move down 1 theme."),
|
|
("ScrollDown", "Move down 1 theme."),
|
|
("PgDown", "Move down 20 themes."),
|
|
("h, x", "Show palette numbers in hexadecimal."),
|
|
("d", "Show palette numbers in decimal."),
|
|
("c", "Copy theme name to the clipboard."),
|
|
("C", "Copy theme path to the clipboard."),
|
|
("Home", "Go to the start of the list."),
|
|
("End", "Go to the end of the list."),
|
|
("/", "Start search."),
|
|
("^X, ^/", "Clear search."),
|
|
("\u{23ce}", "Save theme or close search window."),
|
|
("w", "Write theme to auto config file."),
|
|
];
|
|
for (i, (keys, help)) in key_help.iter().enumerate() {
|
|
let row = u16::try_from(i).unwrap_or(0) + 1;
|
|
child.print(keys, self.ui_standard(), row, 2);
|
|
child.print("\u{2014}", self.ui_standard(), row, 15);
|
|
child.print(help, self.ui_standard(), row, 17);
|
|
}
|
|
}
|
|
Mode::Search => {
|
|
let child = win.child_border(
|
|
20,
|
|
area.height.saturating_sub(5),
|
|
area.width.saturating_sub(40),
|
|
3,
|
|
self.ui_standard(),
|
|
);
|
|
child.print(&self.text_input.text, self.ui_standard(), 0, 0);
|
|
let col = gw(&self
|
|
.text_input
|
|
.text
|
|
.chars()
|
|
.take(self.text_input.cursor)
|
|
.collect::<String>());
|
|
if col < child.area.width {
|
|
cursor = Some(Position::new(child.area.x + col, child.area.y));
|
|
}
|
|
}
|
|
Mode::Save => {
|
|
if let Some(theme) = self.selected() {
|
|
let (width, height) = (92u16, 17u16);
|
|
let child = win.child_border(
|
|
(area.width / 2).saturating_sub(width / 2),
|
|
(area.height / 2).saturating_sub(height / 2),
|
|
width,
|
|
height,
|
|
self.ui_standard(),
|
|
);
|
|
let theme_line = format!("theme = {}", theme.theme);
|
|
let save_instructions: [&str; 14] = [
|
|
"To apply this theme, add the following line to your Ghostty configuration:",
|
|
"",
|
|
&theme_line,
|
|
"",
|
|
"Save the configuration file and then reload it to apply the new theme.",
|
|
"",
|
|
"Or press 'w' to write an auto theme file to your system's preferred default config path.",
|
|
"Then add the following line to your Ghostty configuration and reload:",
|
|
"",
|
|
"config-file = ?auto/theme.ghostty",
|
|
"",
|
|
"For more details on configuration and themes, visit the Ghostty documentation:",
|
|
"",
|
|
"https://ghostty.org/docs/config/reference",
|
|
];
|
|
for (i, instruction) in save_instructions.iter().enumerate() {
|
|
child.print(
|
|
instruction,
|
|
self.ui_standard(),
|
|
u16::try_from(i).unwrap_or(0) + 1,
|
|
2,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect the decorations (hyperlinks, extended underlines) with the
|
|
// final cell contents/styles for the raw-escape overlay pass.
|
|
let decos = decos.into_inner();
|
|
let buf = cell.into_inner();
|
|
self.overlays = decos
|
|
.iter()
|
|
.map(|d| Overlay::capture(buf, d))
|
|
.collect::<Vec<_>>();
|
|
cursor
|
|
}
|
|
|
|
/// Hyperlink / extended-underline overlays of the last [`Self::draw`].
|
|
#[must_use]
|
|
pub fn overlays(&self) -> &[Overlay] {
|
|
&self.overlays
|
|
}
|
|
|
|
fn load_current(&mut self) -> Option<&Loaded> {
|
|
let theme = self.selected()?.clone();
|
|
let stale = self.loaded.as_ref().is_none_or(|(p, _)| p != &theme.path);
|
|
if stale {
|
|
let mut config = Config::new_default();
|
|
let loaded = match config.load_file(&absolute_path(&theme.path)) {
|
|
Ok(()) => Loaded::Ok(Box::new(config)),
|
|
Err(e) => Loaded::Err(error_name(&e)),
|
|
};
|
|
self.loaded = Some((theme.path, loaded));
|
|
}
|
|
self.loaded.as_ref().map(|(_, l)| l)
|
|
}
|
|
|
|
fn draw_preview(&mut self, win: &Win<'_, '_>, x_off: u16) {
|
|
let area = win.area;
|
|
let x_off = x_off.min(area.width);
|
|
let width = area.width - x_off;
|
|
let ui_err = self.ui_err();
|
|
let ui_std = self.ui_standard();
|
|
let hex = self.hex;
|
|
|
|
if self.filtered.is_empty() {
|
|
let child = win.child(0, 0, area.width, area.height);
|
|
child.fill(ui_std);
|
|
child.print(
|
|
"No theme found!",
|
|
ui_std,
|
|
(area.height / 2).saturating_sub(1),
|
|
(area.width / 2).saturating_sub(7),
|
|
);
|
|
return;
|
|
}
|
|
|
|
let Some(theme) = self.selected().cloned() else {
|
|
return;
|
|
};
|
|
let config = match self.load_current() {
|
|
Some(Loaded::Ok(c)) => c.clone(),
|
|
Some(Loaded::Err(err)) => {
|
|
let child = win.child(x_off, 0, width, area.height);
|
|
child.fill(ui_std);
|
|
let middle = child.area.height / 2;
|
|
let text = format!("Unable to open {} from:", theme.theme);
|
|
child.print(
|
|
&text,
|
|
ui_err,
|
|
middle.saturating_sub(1),
|
|
(child.area.width / 2).saturating_sub(gw(&text) / 2),
|
|
);
|
|
child.print_ex(
|
|
&theme.path,
|
|
ui_err,
|
|
middle,
|
|
(child.area.width / 2).saturating_sub(gw(&theme.path) / 2),
|
|
Some(&theme.to_uri()),
|
|
None,
|
|
);
|
|
child.print(
|
|
err,
|
|
ui_err,
|
|
middle + 1,
|
|
(child.area.width / 2).saturating_sub(gw(err) / 2),
|
|
);
|
|
return;
|
|
}
|
|
None => return,
|
|
};
|
|
|
|
let mut next_start: u16 = 0;
|
|
|
|
let rgb = |c: ghostty_config::types_a::Color| Color::Rgb(c.r, c.g, c.b);
|
|
let term_rgb = |c: &Option<TerminalColor>, default: Color| match c {
|
|
Some(TerminalColor::Color(c)) => rgb(*c),
|
|
_ => default,
|
|
};
|
|
let fg = rgb(config.foreground);
|
|
let bg = rgb(config.background);
|
|
let cursor_fg = term_rgb(&config.cursor_text, bg);
|
|
let cursor_bg = term_rgb(&config.cursor_color, fg);
|
|
let selection_fg = term_rgb(&config.selection_foreground, bg);
|
|
let selection_bg = term_rgb(&config.selection_background, fg);
|
|
let cursor = Style::default().fg(cursor_fg).bg(cursor_bg);
|
|
let standard_selection = Style::default().fg(selection_fg).bg(selection_bg);
|
|
let standard = Style::default().fg(fg).bg(bg);
|
|
let standard_bold = standard.add_modifier(Modifier::BOLD);
|
|
let standard_italic = standard.add_modifier(Modifier::ITALIC);
|
|
let standard_bold_italic = standard.add_modifier(Modifier::BOLD | Modifier::ITALIC);
|
|
// Single underline is a plain SGR 4; the other styles (double,
|
|
// dashed, dotted, curly) are re-emitted as raw `SGR 4:N` overlays.
|
|
|
|
let color = |i: usize| {
|
|
let c = config.palette.value[i];
|
|
Color::Rgb(c.r, c.g, c.b)
|
|
};
|
|
|
|
// Header.
|
|
{
|
|
let child = win.child(x_off, next_start, width, 4);
|
|
child.fill(standard);
|
|
let uri = theme.to_uri();
|
|
child.print_ex(
|
|
&theme.theme,
|
|
standard_bold_italic,
|
|
1,
|
|
(child.area.width / 2).saturating_sub(gw(&theme.theme) / 2),
|
|
Some(&uri),
|
|
None,
|
|
);
|
|
child.print_ex(
|
|
&theme.path,
|
|
standard,
|
|
2,
|
|
(child.area.width / 2).saturating_sub(gw(&theme.path) / 2),
|
|
Some(&uri),
|
|
None,
|
|
);
|
|
next_start += child.area.height;
|
|
}
|
|
|
|
// Diagnostics.
|
|
if !config.diagnostics.items().is_empty() {
|
|
let n = u16::try_from(config.diagnostics.items().len()).unwrap_or(u16::MAX);
|
|
let child = win.child(x_off, next_start, width, 2 + n);
|
|
let text = "Problems were encountered trying to load the theme:";
|
|
child.print(
|
|
text,
|
|
ui_err,
|
|
0,
|
|
(child.area.width / 2).saturating_sub(gw(text) / 2),
|
|
);
|
|
for (i, diag) in config.diagnostics.items().iter().enumerate() {
|
|
child.print(
|
|
&diag.to_string(),
|
|
ui_err,
|
|
2 + u16::try_from(i).unwrap_or(0),
|
|
2,
|
|
);
|
|
}
|
|
next_start += 2 + n;
|
|
}
|
|
|
|
// Palette.
|
|
{
|
|
let child = win.child(x_off, next_start, width, 6);
|
|
child.fill(standard);
|
|
for i in 0..16usize {
|
|
let r = u16::try_from(i / 8).unwrap_or(0);
|
|
let c = u16::try_from(i % 8).unwrap_or(0);
|
|
let text = if hex {
|
|
format!(" {i:02x}")
|
|
} else {
|
|
format!("{i:3}")
|
|
};
|
|
child.print(&text, standard, 3 * r, c * 8);
|
|
let block = Style::default().fg(color(i)).bg(bg);
|
|
child.print("\u{2588}\u{2588}\u{2588}\u{2588}", block, 3 * r, 4 + c * 8);
|
|
child.print(
|
|
"\u{2588}\u{2588}\u{2588}\u{2588}",
|
|
block,
|
|
3 * r + 1,
|
|
4 + c * 8,
|
|
);
|
|
}
|
|
next_start += child.area.height;
|
|
}
|
|
|
|
// Code sample.
|
|
{
|
|
let child = win.child(x_off, next_start, width, 24);
|
|
let bold = Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD);
|
|
let c = |i: usize| Style::default().fg(color(i)).bg(bg);
|
|
let (color1, color2, color3, color4, color5, color6) =
|
|
(c(1), c(2), c(3), c(4), c(5), c(6));
|
|
let color6ul = color6.add_modifier(Modifier::UNDERLINED);
|
|
let (color10, color12, color238) = (c(10), c(12), c(238));
|
|
child.fill(standard);
|
|
|
|
let rule = |row: u16, head: &str| {
|
|
child.print_segments(&[(head, color238)], row, 2);
|
|
if child.area.width > 10 {
|
|
for col in 10..child.area.width {
|
|
child.print("\u{2500}", color238, row, col);
|
|
}
|
|
}
|
|
};
|
|
|
|
child.print_segments(
|
|
&[
|
|
("\u{2192}", color2),
|
|
(" ", standard),
|
|
("bat", color4),
|
|
(" ", standard),
|
|
("ziggzagg.zig", color6ul),
|
|
],
|
|
0,
|
|
2,
|
|
);
|
|
rule(
|
|
1,
|
|
"\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{252c}",
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
(" \u{2502} ", color238),
|
|
("File: ", standard),
|
|
("ziggzagg.zig", bold),
|
|
],
|
|
2,
|
|
2,
|
|
);
|
|
rule(
|
|
3,
|
|
"\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{253c}",
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
(" 1 \u{2502} ", color238),
|
|
("const", color5),
|
|
(" std ", standard),
|
|
("= @import", color5),
|
|
("(", standard),
|
|
("\"std\"", color10),
|
|
(");", standard),
|
|
],
|
|
4,
|
|
2,
|
|
);
|
|
child.print_segments(&[(" 2 \u{2502}", color238)], 5, 2);
|
|
child.print_segments(
|
|
&[
|
|
(" 3 \u{2502} ", color238),
|
|
("pub ", color5),
|
|
("fn ", color12),
|
|
("main", color2),
|
|
("() ", standard),
|
|
("!", color5),
|
|
("void", color12),
|
|
(" {", standard),
|
|
],
|
|
6,
|
|
2,
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
(" 4 \u{2502} ", color238),
|
|
("const ", color5),
|
|
("stdout ", standard),
|
|
("=", color5),
|
|
(" std.Io.getStdOut().writer();", standard),
|
|
],
|
|
7,
|
|
2,
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
(" 5 \u{2502} ", color238),
|
|
("var ", color5),
|
|
("i:", standard),
|
|
(" usize", color12),
|
|
(" =", color5),
|
|
(" 1", color4),
|
|
(";", standard),
|
|
],
|
|
8,
|
|
2,
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
(" 6 \u{2502} ", color238),
|
|
("while ", color5),
|
|
("(i ", standard),
|
|
("<= ", color5),
|
|
("16", color4),
|
|
(") : (i ", standard),
|
|
("+= ", color5),
|
|
("1", color4),
|
|
(") {", standard),
|
|
],
|
|
9,
|
|
2,
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
(" 7 \u{2502} ", color238),
|
|
("if ", color5),
|
|
("(i ", standard),
|
|
("% ", color5),
|
|
("15 ", color4),
|
|
("== ", color5),
|
|
("0", color4),
|
|
(") {", standard),
|
|
],
|
|
10,
|
|
2,
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
(" 8 \u{2502} ", color238),
|
|
("try ", color5),
|
|
("stdout.writeAll(", standard),
|
|
("\"ZiggZagg", color10),
|
|
("\\n", color12),
|
|
("\"", color10),
|
|
(");", standard),
|
|
],
|
|
11,
|
|
2,
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
(" 9 \u{2502} ", color238),
|
|
("} ", standard),
|
|
("else if ", color5),
|
|
("(i ", standard),
|
|
("% ", color5),
|
|
("3 ", color4),
|
|
("== ", color5),
|
|
("0", color4),
|
|
(") {", standard),
|
|
],
|
|
12,
|
|
2,
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
(" 10 \u{2502} ", color238),
|
|
("try ", color5),
|
|
("stdout.writeAll(", standard),
|
|
("\"Zigg", color10),
|
|
("\\n", color12),
|
|
("\"", color10),
|
|
(");", standard),
|
|
],
|
|
13,
|
|
2,
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
(" 11 \u{2502} ", color238),
|
|
("} ", standard),
|
|
("else if ", color5),
|
|
("(i ", standard),
|
|
("% ", color5),
|
|
("5 ", color4),
|
|
("== ", color5),
|
|
("0", color4),
|
|
(") {", standard),
|
|
],
|
|
14,
|
|
2,
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
(" 12 \u{2502} ", color238),
|
|
("try ", color5),
|
|
("stdout.writeAll(", standard),
|
|
("\"Zagg", color10),
|
|
("\\n", color12),
|
|
("\"", color10),
|
|
(");", standard),
|
|
],
|
|
15,
|
|
2,
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
(" 13 \u{2502} ", color238),
|
|
("} ", standard),
|
|
("else ", color5),
|
|
("{", standard),
|
|
],
|
|
16,
|
|
2,
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
(" 14 \u{2502} ", color238),
|
|
("try ", color5),
|
|
("stdout.print(\"{d}\\n\", .{i})", standard_selection),
|
|
(";", cursor),
|
|
],
|
|
17,
|
|
2,
|
|
);
|
|
child.print_segments(
|
|
&[(" 15 \u{2502} ", color238), ("}", standard)],
|
|
18,
|
|
2,
|
|
);
|
|
child.print_segments(
|
|
&[(" 16 \u{2502} ", color238), ("}", standard)],
|
|
19,
|
|
2,
|
|
);
|
|
child.print_segments(&[(" 17 \u{2502} ", color238), ("}", standard)], 20, 2);
|
|
rule(
|
|
21,
|
|
"\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2534}",
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
("ghostty ", color6),
|
|
("on ", standard),
|
|
("\u{e0a0} main ", color4),
|
|
("[+] ", color1),
|
|
("via ", standard),
|
|
("\u{e0a0} v0.13.0 ", color3),
|
|
("via ", standard),
|
|
("\u{f313} impure (ghostty-env)", color4),
|
|
],
|
|
22,
|
|
2,
|
|
);
|
|
child.print_segments(
|
|
&[
|
|
("\u{2726} ", color4),
|
|
("at ", standard),
|
|
("10:36:15 ", color3),
|
|
("\u{2192}", color2),
|
|
],
|
|
23,
|
|
2,
|
|
);
|
|
next_start += child.area.height;
|
|
}
|
|
|
|
if next_start < area.height {
|
|
let child = win.child(x_off, next_start, width, area.height - next_start);
|
|
child.fill(standard);
|
|
let mut words = LOREM_IPSUM.split([' ', '\n']);
|
|
let mut row: u16 = 1;
|
|
let mut col: u16 = 2;
|
|
while row < child.area.height {
|
|
let word = if let Some(w) = words.next() {
|
|
w
|
|
} else {
|
|
words = LOREM_IPSUM.split([' ', '\n']);
|
|
words.next().unwrap_or("")
|
|
};
|
|
let word_len = u16::try_from(word.len()).unwrap_or(u16::MAX);
|
|
if usize::from(col) + word.len() > usize::from(child.area.width) {
|
|
row += 1;
|
|
col = 2;
|
|
}
|
|
let mut ul = None;
|
|
let style = match word {
|
|
"ipsum" => Style::default().fg(color(2)).bg(bg),
|
|
"consectetur" => standard_bold,
|
|
"reprehenderit" => standard_italic,
|
|
"Praesent" => standard_bold_italic,
|
|
"auctor" => standard.add_modifier(Modifier::UNDERLINED),
|
|
"dui" => {
|
|
ul = Some(Ul::Double);
|
|
standard
|
|
}
|
|
"erat" => {
|
|
ul = Some(Ul::Dashed);
|
|
standard
|
|
}
|
|
"enim" => {
|
|
ul = Some(Ul::Dotted);
|
|
standard
|
|
}
|
|
"odio" => {
|
|
ul = Some(Ul::Curly);
|
|
standard
|
|
}
|
|
_ => standard,
|
|
};
|
|
child.print_ex(word, style, row, col, None, ul);
|
|
col = col.saturating_add(word_len).saturating_add(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The Zig error name for display (`{}` of an error).
|
|
fn error_name(e: &ghostty_config::config::Error) -> String {
|
|
use ghostty_config::config::Error;
|
|
match e {
|
|
Error::Io(io) => match io.kind() {
|
|
std::io::ErrorKind::NotFound => "FileNotFound".to_string(),
|
|
std::io::ErrorKind::PermissionDenied => "AccessDenied".to_string(),
|
|
_ => io.to_string(),
|
|
},
|
|
e => e.to_string(),
|
|
}
|
|
}
|
|
|
|
/// `resolveAutoThemePath`: `<config dir>/auto/theme.ghostty`.
|
|
pub fn resolve_auto_theme_path() -> Result<String, Error> {
|
|
let main_cfg_path = ghostty_config::file_load::preferred_default_file_path()
|
|
.map_err(|e| Error::Other(e.to_string()))?;
|
|
let base_dir = std::path::Path::new(&main_cfg_path)
|
|
.parent()
|
|
.ok_or_else(|| Error::Other("BadPathName".to_string()))?;
|
|
Ok(base_dir
|
|
.join("auto")
|
|
.join("theme.ghostty")
|
|
.to_string_lossy()
|
|
.into_owned())
|
|
}
|
|
|
|
/// `writeAutoThemeFile`.
|
|
pub fn write_auto_theme_file(path: Option<&str>, theme_name: &str) -> Result<(), Error> {
|
|
let auto_path = match path {
|
|
Some(p) => p.to_string(),
|
|
None => resolve_auto_theme_path()?,
|
|
};
|
|
if let Some(dir) = std::path::Path::new(&auto_path).parent() {
|
|
std::fs::create_dir_all(dir)?;
|
|
}
|
|
std::fs::write(&auto_path, format!("theme = {theme_name}\n"))?;
|
|
Ok(())
|
|
}
|
|
|
|
// ---- A tiny window abstraction over a ratatui buffer (vaxis.Window) ----
|
|
|
|
/// Display width of a string.
|
|
fn gw(s: &str) -> u16 {
|
|
crate::tui::gwidth(s)
|
|
}
|
|
|
|
/// A clipped, offset view onto the buffer (`vaxis.Window`). All coordinates
|
|
/// passed to its methods are relative to the window.
|
|
#[derive(Clone, Copy)]
|
|
struct Win<'a, 'b> {
|
|
buf: &'a RefCell<&'b mut Buffer>,
|
|
decos: &'a RefCell<Vec<Deco>>,
|
|
area: Rect,
|
|
}
|
|
|
|
impl Win<'_, '_> {
|
|
/// `Window.clear`.
|
|
fn clear(&self) {
|
|
self.reset_area(self.area);
|
|
}
|
|
|
|
fn reset_area(&self, area: Rect) {
|
|
let mut buf = self.buf.borrow_mut();
|
|
for y in area.top()..area.bottom() {
|
|
for x in area.left()..area.right() {
|
|
if let Some(c) = buf.cell_mut((x, y)) {
|
|
c.reset();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `Window.fill` with a blank cell of the given style.
|
|
fn fill(&self, style: Style) {
|
|
// Anything previously drawn here is overwritten, including its
|
|
// hyperlink/underline decorations.
|
|
let a = self.area;
|
|
self.decos.borrow_mut().retain(|d| {
|
|
!(d.y >= a.top() && d.y < a.bottom() && d.x < a.right() && d.x + d.width > a.left())
|
|
});
|
|
let mut buf = self.buf.borrow_mut();
|
|
for y in self.area.top()..self.area.bottom() {
|
|
for x in self.area.left()..self.area.right() {
|
|
if let Some(c) = buf.cell_mut((x, y)) {
|
|
c.reset();
|
|
c.set_symbol(" ");
|
|
c.set_style(style);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `Window.child` (without border); clipped to this window.
|
|
fn child(&self, x_off: u16, y_off: u16, width: u16, height: u16) -> Self {
|
|
let x = self.area.x.saturating_add(x_off).min(self.area.right());
|
|
let y = self.area.y.saturating_add(y_off).min(self.area.bottom());
|
|
let w = width.min(self.area.right() - x);
|
|
let h = height.min(self.area.bottom() - y);
|
|
Win {
|
|
buf: self.buf,
|
|
decos: self.decos,
|
|
area: Rect::new(x, y, w, h),
|
|
}
|
|
}
|
|
|
|
/// `Window.child` with a border on all sides; returns the inner window.
|
|
fn child_border(&self, x_off: u16, y_off: u16, width: u16, height: u16, style: Style) -> Self {
|
|
let outer = self.child(x_off, y_off, width, height);
|
|
outer.fill(style);
|
|
let a = outer.area;
|
|
if a.width >= 2 && a.height >= 2 {
|
|
let mut buf = self.buf.borrow_mut();
|
|
let mut put = |x: u16, y: u16, s: &str| {
|
|
if let Some(c) = buf.cell_mut((x, y)) {
|
|
c.set_symbol(s);
|
|
c.set_style(style);
|
|
}
|
|
};
|
|
for x in (a.left() + 1)..(a.right() - 1) {
|
|
put(x, a.top(), "\u{2500}");
|
|
put(x, a.bottom() - 1, "\u{2500}");
|
|
}
|
|
for y in (a.top() + 1)..(a.bottom() - 1) {
|
|
put(a.left(), y, "\u{2502}");
|
|
put(a.right() - 1, y, "\u{2502}");
|
|
}
|
|
put(a.left(), a.top(), "\u{256d}");
|
|
put(a.right() - 1, a.top(), "\u{256e}");
|
|
put(a.left(), a.bottom() - 1, "\u{2570}");
|
|
put(a.right() - 1, a.bottom() - 1, "\u{256f}");
|
|
}
|
|
outer.child(1, 1, a.width.saturating_sub(2), a.height.saturating_sub(2))
|
|
}
|
|
|
|
/// `Window.printSegment` without wrapping (text is clipped). Returns the
|
|
/// column after the text.
|
|
fn print(&self, text: &str, style: Style, row: u16, col: u16) -> u16 {
|
|
self.print_ex(text, style, row, col, None, None)
|
|
}
|
|
|
|
/// `Window.printSegment` with an optional OSC 8 hyperlink and an optional
|
|
/// extended underline style (ratatui cannot express either, so they are
|
|
/// recorded and re-emitted as raw escapes after the frame is flushed).
|
|
fn print_ex(
|
|
&self,
|
|
text: &str,
|
|
style: Style,
|
|
row: u16,
|
|
col: u16,
|
|
link: Option<&str>,
|
|
ul: Option<Ul>,
|
|
) -> u16 {
|
|
if row >= self.area.height || col >= self.area.width {
|
|
return col;
|
|
}
|
|
let x = self.area.x + col;
|
|
let y = self.area.y + row;
|
|
let max = usize::from(self.area.width - col);
|
|
let (nx, _) = self.buf.borrow_mut().set_stringn(x, y, text, max, style);
|
|
if (link.is_some() || ul.is_some()) && nx > x {
|
|
self.decos.borrow_mut().push(Deco {
|
|
x,
|
|
y,
|
|
width: nx - x,
|
|
link: link.map(str::to_owned),
|
|
ul,
|
|
});
|
|
}
|
|
nx - self.area.x
|
|
}
|
|
|
|
/// `Window.print`: several segments one after another.
|
|
fn print_segments(&self, segments: &[(&str, Style)], row: u16, col: u16) -> u16 {
|
|
let mut col = col;
|
|
for (text, style) in segments {
|
|
col = self.print(text, *style, row, col);
|
|
}
|
|
col
|
|
}
|
|
}
|
|
|
|
// ---- The terminal driver ----
|
|
|
|
/// Convert a `termina` key event to the crossterm event type `Preview`
|
|
/// handles. Keys the preview has no use for map to `None`.
|
|
fn convert_key(k: termina::event::KeyEvent) -> Option<KeyEvent> {
|
|
use termina::event::{KeyCode as T, KeyEventKind as TK, Modifiers as TM};
|
|
let code = match k.code {
|
|
T::Char(c) => KeyCode::Char(c),
|
|
T::Enter => KeyCode::Enter,
|
|
T::Backspace => KeyCode::Backspace,
|
|
T::Tab => KeyCode::Tab,
|
|
T::Escape => KeyCode::Esc,
|
|
T::Left => KeyCode::Left,
|
|
T::Right => KeyCode::Right,
|
|
T::Up => KeyCode::Up,
|
|
T::Down => KeyCode::Down,
|
|
T::Home => KeyCode::Home,
|
|
T::End => KeyCode::End,
|
|
T::BackTab => KeyCode::BackTab,
|
|
T::PageUp => KeyCode::PageUp,
|
|
T::PageDown => KeyCode::PageDown,
|
|
T::Insert => KeyCode::Insert,
|
|
T::Delete => KeyCode::Delete,
|
|
T::Function(n) => KeyCode::F(n),
|
|
_ => return None,
|
|
};
|
|
let mut modifiers = KeyModifiers::NONE;
|
|
for (t, c) in [
|
|
(TM::SHIFT, KeyModifiers::SHIFT),
|
|
(TM::ALT, KeyModifiers::ALT),
|
|
(TM::CONTROL, KeyModifiers::CONTROL),
|
|
(TM::SUPER, KeyModifiers::SUPER),
|
|
(TM::HYPER, KeyModifiers::HYPER),
|
|
(TM::META, KeyModifiers::META),
|
|
] {
|
|
if k.modifiers.contains(t) {
|
|
modifiers |= c;
|
|
}
|
|
}
|
|
let kind = match k.kind {
|
|
TK::Press => KeyEventKind::Press,
|
|
TK::Release => KeyEventKind::Release,
|
|
TK::Repeat => KeyEventKind::Repeat,
|
|
};
|
|
let mut ev = KeyEvent::new(code, modifiers);
|
|
ev.kind = kind;
|
|
Some(ev)
|
|
}
|
|
|
|
fn convert_mouse(m: termina::event::MouseEvent) -> MouseEvent {
|
|
use termina::event::{MouseButton as TB, MouseEventKind as TK};
|
|
let button = |b: TB| match b {
|
|
TB::Left => MouseButton::Left,
|
|
TB::Right => MouseButton::Right,
|
|
TB::Middle => MouseButton::Middle,
|
|
};
|
|
let kind = match m.kind {
|
|
TK::Down(b) => MouseEventKind::Down(button(b)),
|
|
TK::Up(b) => MouseEventKind::Up(button(b)),
|
|
TK::Drag(b) => MouseEventKind::Drag(button(b)),
|
|
TK::Moved => MouseEventKind::Moved,
|
|
TK::ScrollDown => MouseEventKind::ScrollDown,
|
|
TK::ScrollUp => MouseEventKind::ScrollUp,
|
|
TK::ScrollLeft => MouseEventKind::ScrollLeft,
|
|
TK::ScrollRight => MouseEventKind::ScrollRight,
|
|
};
|
|
MouseEvent {
|
|
kind,
|
|
column: m.column,
|
|
row: m.row,
|
|
modifiers: KeyModifiers::NONE,
|
|
}
|
|
}
|
|
|
|
/// Feed one terminal input event to the preview. Handles the terminal's
|
|
/// color-scheme report (`CSI ? 997 ; 1|2 n`, sent in answer to
|
|
/// `CSI ? 996 n` and on every change while `CSI ? 2031 h` is active), which
|
|
/// vaxis delivers as its `color_scheme` event.
|
|
fn handle_terminal_event(app: &mut Preview, ev: termina::Event) {
|
|
use termina::escape::csi::{Csi, Mode, ThemeMode};
|
|
match ev {
|
|
termina::Event::Key(k) => {
|
|
if let Some(k) = convert_key(k) {
|
|
app.update(&Event::Key(k));
|
|
}
|
|
}
|
|
termina::Event::Mouse(m) => app.update(&Event::Mouse(convert_mouse(m))),
|
|
termina::Event::Csi(Csi::Mode(Mode::ReportTheme(t))) => {
|
|
app.color_scheme = match t {
|
|
ThemeMode::Dark => Scheme::Dark,
|
|
ThemeMode::Light => Scheme::Light,
|
|
};
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Run the interactive preview (Zig: `preview`). Blocks until the user quits.
|
|
pub fn preview(themes: Vec<ThemeListElement>, theme_filter: ColorScheme) -> Result<(), Error> {
|
|
let mut app = Preview::new(themes, theme_filter);
|
|
run_loop(&mut app)
|
|
}
|
|
|
|
fn run_loop(app: &mut Preview) -> Result<(), Error> {
|
|
use ratatui::Terminal as RatTerminal;
|
|
use ratatui::backend::CrosstermBackend;
|
|
use termina::escape::csi::{Csi, DecPrivateMode, DecPrivateModeCode, Mode};
|
|
use termina::{PlatformTerminal, Terminal as _};
|
|
|
|
let mut tty = PlatformTerminal::new()?;
|
|
tty.enter_raw_mode()?;
|
|
|
|
// Alternate screen, title, mouse reporting.
|
|
write!(
|
|
tty,
|
|
"\x1b[?1049h\x1b]2;\u{1f47b} Ghostty Theme Preview \u{1f47b}\x1b\\\
|
|
\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h"
|
|
)?;
|
|
|
|
// Request the current color scheme (`CSI ? 996 n`) and subscribe to
|
|
// changes (`CSI ? 2031 h`). vaxis gates this on a DECRQM 2031 answer,
|
|
// but termina cannot parse that report, so (like most TUIs) both are
|
|
// sent unconditionally: terminals without support ignore them.
|
|
let theme_mode = DecPrivateMode::Code(DecPrivateModeCode::Theme);
|
|
write!(
|
|
tty,
|
|
"{}{}",
|
|
Csi::Mode(Mode::QueryTheme),
|
|
Csi::Mode(Mode::SetDecPrivateMode(theme_mode)),
|
|
)?;
|
|
tty.flush()?;
|
|
|
|
let mut terminal = RatTerminal::new(CrosstermBackend::new(std::io::stdout()))?;
|
|
let result = (|| -> Result<(), Error> {
|
|
while !app.should_quit {
|
|
let mut cursor = None;
|
|
terminal.draw(|f| cursor = draw_frame(app, f))?;
|
|
|
|
// Hyperlinks and extended underlines ratatui can't express.
|
|
if !app.overlays().is_empty() {
|
|
let backend = terminal.backend_mut();
|
|
write!(backend, "{}", render_overlays(app.overlays()))?;
|
|
if let Some(pos) = cursor {
|
|
write!(backend, "\x1b[{};{}H", pos.y + 1, pos.x + 1)?;
|
|
}
|
|
backend.flush()?;
|
|
}
|
|
|
|
// Block for an event, then drain everything that is pending.
|
|
let first = tty.read(|_| true)?;
|
|
handle_terminal_event(app, first);
|
|
while tty.poll(|_| true, Some(std::time::Duration::ZERO))? {
|
|
let ev = tty.read(|_| true)?;
|
|
handle_terminal_event(app, ev);
|
|
}
|
|
for effect in app.take_effects() {
|
|
match effect {
|
|
Effect::Copy(text) => {
|
|
let out = terminal.backend_mut();
|
|
write!(out, "\x1b]52;c;{}\x1b\\", base64(text.as_bytes()))?;
|
|
out.flush()?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
})();
|
|
|
|
let _ = terminal.show_cursor();
|
|
let _ = write!(
|
|
tty,
|
|
"{}\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1049l",
|
|
Csi::Mode(Mode::ResetDecPrivateMode(theme_mode)),
|
|
);
|
|
let _ = tty.flush();
|
|
let _ = tty.enter_cooked_mode();
|
|
result
|
|
}
|
|
|
|
fn draw_frame(app: &mut Preview, f: &mut Frame<'_>) -> Option<Position> {
|
|
let cursor = app.draw(f.buffer_mut());
|
|
if let Some(pos) = cursor {
|
|
f.set_cursor_position(pos);
|
|
}
|
|
cursor
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#![allow(clippy::unwrap_used)]
|
|
use super::*;
|
|
use ghostty_config::theme::Location;
|
|
|
|
fn make_themes(dir: &std::path::Path, names: &[(&str, &str)]) -> Vec<ThemeListElement> {
|
|
names
|
|
.iter()
|
|
.map(|(name, bg)| {
|
|
let path = dir.join(name);
|
|
std::fs::write(
|
|
&path,
|
|
format!("background = {bg}\nforeground = #eeeeee\npalette = 1=#ff0000\n"),
|
|
)
|
|
.unwrap();
|
|
ThemeListElement {
|
|
location: Location::Resources,
|
|
path: path.to_string_lossy().into_owned(),
|
|
theme: (*name).to_string(),
|
|
rank: None,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn key(code: KeyCode) -> Event {
|
|
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
|
|
}
|
|
fn ch(c: char) -> Event {
|
|
key(KeyCode::Char(c))
|
|
}
|
|
fn ctrl(c: char) -> Event {
|
|
Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
|
|
}
|
|
|
|
fn app(tmp: &tempfile::TempDir) -> Preview {
|
|
let themes = make_themes(
|
|
tmp.path(),
|
|
&[
|
|
("alpha", "#000000"),
|
|
("beta", "#ffffff"),
|
|
("gamma-dark", "#101010"),
|
|
("delta-light", "#f0f0f0"),
|
|
],
|
|
);
|
|
let mut p = Preview::new(themes, ColorScheme::All);
|
|
p.color_scheme = Scheme::Light;
|
|
p
|
|
}
|
|
|
|
fn render(p: &mut Preview, w: u16, h: u16) -> Buffer {
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, w, h));
|
|
p.draw(&mut buf);
|
|
buf
|
|
}
|
|
|
|
fn row_text(buf: &Buffer, y: u16) -> String {
|
|
(0..buf.area.width)
|
|
.map(|x| buf[(x, y)].symbol().to_string())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn navigation_and_quit() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut p = app(&tmp);
|
|
assert_eq!(4, p.filtered.len());
|
|
p.update(&ch('j'));
|
|
p.update(&key(KeyCode::Down));
|
|
assert_eq!(2, p.current);
|
|
p.update(&key(KeyCode::PageDown));
|
|
assert_eq!(3, p.current);
|
|
p.update(&ch('k'));
|
|
assert_eq!(2, p.current);
|
|
p.update(&key(KeyCode::PageUp));
|
|
assert_eq!(0, p.current);
|
|
p.update(&key(KeyCode::End));
|
|
assert_eq!(3, p.current);
|
|
p.update(&key(KeyCode::Home));
|
|
assert_eq!(0, p.current);
|
|
assert!(!p.should_quit);
|
|
p.update(&ch('q'));
|
|
assert!(p.should_quit);
|
|
|
|
let mut p = app(&tmp);
|
|
p.update(&ctrl('c'));
|
|
assert!(p.should_quit);
|
|
let mut p = app(&tmp);
|
|
p.update(&key(KeyCode::Esc));
|
|
assert!(p.should_quit);
|
|
}
|
|
|
|
#[test]
|
|
fn filter_cycles_and_filters() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut p = app(&tmp);
|
|
p.update(&ch('f'));
|
|
assert_eq!(ColorScheme::Dark, p.theme_filter);
|
|
let names: Vec<_> = p
|
|
.filtered
|
|
.iter()
|
|
.map(|&i| p.themes[i].theme.as_str())
|
|
.collect();
|
|
assert_eq!(vec!["alpha", "gamma-dark"], names);
|
|
p.update(&ch('f'));
|
|
assert_eq!(ColorScheme::Light, p.theme_filter);
|
|
assert_eq!(2, p.filtered.len());
|
|
p.update(&ch('f'));
|
|
assert_eq!(ColorScheme::All, p.theme_filter);
|
|
assert_eq!(4, p.filtered.len());
|
|
}
|
|
|
|
#[test]
|
|
fn search_mode() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut p = app(&tmp);
|
|
p.update(&ch('/'));
|
|
assert_eq!(Mode::Search, p.mode);
|
|
p.update(&ch('G'));
|
|
p.update(&ch('a'));
|
|
p.update(&ch('m'));
|
|
assert_eq!("Gam", p.text_input.text);
|
|
assert_eq!(1, p.filtered.len());
|
|
assert_eq!("gamma-dark", p.themes[p.filtered[0]].theme);
|
|
p.update(&key(KeyCode::Backspace));
|
|
p.update(&key(KeyCode::Backspace));
|
|
p.update(&key(KeyCode::Backspace));
|
|
assert_eq!(4, p.filtered.len());
|
|
p.update(&ch('l'));
|
|
p.update(&ch('t'));
|
|
assert_eq!(1, p.filtered.len()); // "delta-light" is the only match for "lt"
|
|
p.update(&ctrl('x'));
|
|
assert_eq!("", p.text_input.text);
|
|
assert_eq!(4, p.filtered.len());
|
|
p.update(&key(KeyCode::Enter));
|
|
assert_eq!(Mode::Normal, p.mode);
|
|
}
|
|
|
|
#[test]
|
|
fn hex_toggle_and_help_mode() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut p = app(&tmp);
|
|
p.update(&ch('x'));
|
|
assert!(p.hex);
|
|
p.update(&ch('d'));
|
|
assert!(!p.hex);
|
|
p.update(&key(KeyCode::F(1)));
|
|
assert_eq!(Mode::Help, p.mode);
|
|
// Keys are modal: 'j' does nothing in help.
|
|
p.update(&ch('j'));
|
|
assert_eq!(0, p.current);
|
|
p.update(&key(KeyCode::Esc));
|
|
assert_eq!(Mode::Normal, p.mode);
|
|
assert!(!p.should_quit);
|
|
p.update(&ch('?'));
|
|
assert_eq!(Mode::Help, p.mode);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_and_save() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut p = app(&tmp);
|
|
p.update(&ch('c'));
|
|
p.update(&Event::Key(KeyEvent::new(
|
|
KeyCode::Char('C'),
|
|
KeyModifiers::SHIFT,
|
|
)));
|
|
let effects = p.take_effects();
|
|
assert_eq!(Effect::Copy("alpha".into()), effects[0]);
|
|
assert_eq!(
|
|
Effect::Copy(tmp.path().join("alpha").to_string_lossy().into_owned()),
|
|
effects[1]
|
|
);
|
|
|
|
let auto = tmp.path().join("cfg/auto/theme.ghostty");
|
|
p.auto_theme_path = Some(auto.to_string_lossy().into_owned());
|
|
p.update(&key(KeyCode::Enter));
|
|
assert_eq!(Mode::Save, p.mode);
|
|
p.update(&ch('w'));
|
|
assert_eq!("theme = alpha\n", std::fs::read_to_string(&auto).unwrap());
|
|
p.update(&key(KeyCode::Esc));
|
|
assert_eq!(Mode::Normal, p.mode);
|
|
}
|
|
|
|
#[test]
|
|
fn draw_layout() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut p = app(&tmp);
|
|
p.update(&ch('j'));
|
|
let buf = render(&mut p, 120, 60);
|
|
// List on the left: selected item has the marker and the arrow at col 30.
|
|
let r1 = row_text(&buf, 1);
|
|
assert!(r1.starts_with("\u{276f} beta"), "{r1:?}");
|
|
assert!(r1.contains("\u{276e}"), "{r1:?}");
|
|
assert!(row_text(&buf, 0).starts_with(" alpha"));
|
|
// Preview: theme name centered in the header, palette numbers, code sample.
|
|
let preview_header = row_text(&buf, 1);
|
|
assert!(preview_header.contains("beta"));
|
|
let all: String = (0..60)
|
|
.map(|y| row_text(&buf, y))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(all.contains(" 0"));
|
|
assert!(all.contains("ziggzagg.zig"));
|
|
assert!(all.contains("const std = @import(\"std\");"));
|
|
assert!(all.contains("Lorem ipsum"));
|
|
// The palette swatches use the theme palette color 1.
|
|
let swatch = (32..120)
|
|
.find(|&x| buf[(x, 4)].fg == Color::Rgb(255, 0, 0))
|
|
.is_some();
|
|
assert!(swatch);
|
|
// Background of the preview is the theme background.
|
|
assert_eq!(Color::Rgb(255, 255, 255), buf[(100, 0)].bg);
|
|
}
|
|
|
|
#[test]
|
|
fn hyperlinks_and_underline_overlays() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut p = app(&tmp);
|
|
let _ = render(&mut p, 120, 60);
|
|
let ov = p.overlays().to_vec();
|
|
// List entries (4) + header name + header path are hyperlinks.
|
|
let links: Vec<&Overlay> = ov.iter().filter(|o| o.link.is_some()).collect();
|
|
assert!(links.len() >= 6, "{links:?}");
|
|
let alpha = links
|
|
.iter()
|
|
.find(|o| o.text == "alpha" && o.x == 2)
|
|
.unwrap();
|
|
assert!(alpha.link.as_deref().unwrap().starts_with("file:///"));
|
|
assert!(alpha.link.as_deref().unwrap().ends_with("/alpha"));
|
|
// The five lorem ipsum words get their own underline overlays.
|
|
let uls: Vec<(String, Ul)> = ov
|
|
.iter()
|
|
.filter_map(|o| o.ul.map(|u| (o.text.clone(), u)))
|
|
.collect();
|
|
for (word, ul) in [
|
|
("dui", Ul::Double),
|
|
("erat", Ul::Dashed),
|
|
("enim", Ul::Dotted),
|
|
("odio", Ul::Curly),
|
|
] {
|
|
assert!(uls.contains(&(word.to_owned(), ul)), "{word}: {uls:?}");
|
|
}
|
|
// And the raw escapes carry OSC 8 and the 4:N styles.
|
|
let raw = render_overlays(&ov);
|
|
assert!(raw.contains("\x1b]8;;file:///"));
|
|
assert!(raw.contains("\x1b]8;;\x1b\\"));
|
|
for sgr in ["4:2", "4:3", "4:4", "4:5"] {
|
|
assert!(raw.contains(sgr), "{sgr}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn popup_drops_covered_overlays() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut p = app(&tmp);
|
|
p.update(&ch('?'));
|
|
let _ = render(&mut p, 120, 40);
|
|
// The help window covers the middle of the screen: no overlay may
|
|
// intersect it.
|
|
let (w, h) = (60u16, 22u16);
|
|
let (x0, y0) = (60 - w / 2, 20 - h / 2);
|
|
for o in p.overlays() {
|
|
let inside = o.y >= y0 && o.y < y0 + h && o.x < x0 + w && o.x + 1 > x0;
|
|
assert!(!inside, "{o:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_theme_report_sets_scheme() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut p = app(&tmp);
|
|
let mut parser = termina::Parser::default();
|
|
parser.parse(b"\x1b[?997;1n", false);
|
|
handle_terminal_event(&mut p, parser.pop().unwrap());
|
|
assert_eq!(Scheme::Dark, p.color_scheme);
|
|
parser.parse(b"\x1b[?997;2n", false);
|
|
handle_terminal_event(&mut p, parser.pop().unwrap());
|
|
assert_eq!(Scheme::Light, p.color_scheme);
|
|
}
|
|
|
|
#[test]
|
|
fn termina_key_conversion() {
|
|
use termina::event::{KeyCode as T, Modifiers};
|
|
let k = convert_key(termina::event::KeyEvent::new(
|
|
T::Char('x'),
|
|
Modifiers::CONTROL,
|
|
))
|
|
.unwrap();
|
|
assert_eq!(KeyCode::Char('x'), k.code);
|
|
assert!(k.modifiers.contains(KeyModifiers::CONTROL));
|
|
assert_eq!(KeyCode::Esc, convert_key(T::Escape.into()).unwrap().code);
|
|
assert!(convert_key(T::CapsLock.into()).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn draw_help_and_save_and_search_overlays() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut p = app(&tmp);
|
|
p.update(&ch('?'));
|
|
let buf = render(&mut p, 120, 40);
|
|
let all: String = (0..40)
|
|
.map(|y| row_text(&buf, y))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(all.contains("Cycle through theme filters."));
|
|
assert!(all.contains('\u{256d}') && all.contains('\u{256f}'));
|
|
|
|
p.update(&key(KeyCode::Esc));
|
|
p.update(&key(KeyCode::Enter));
|
|
let buf = render(&mut p, 120, 40);
|
|
let all: String = (0..40)
|
|
.map(|y| row_text(&buf, y))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(all.contains("theme = alpha"));
|
|
assert!(all.contains("config-file = ?auto/theme.ghostty"));
|
|
|
|
p.update(&key(KeyCode::Esc));
|
|
p.update(&ch('/'));
|
|
p.update(&ch('b'));
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, 120, 40));
|
|
let cursor = p.draw(&mut buf);
|
|
assert!(cursor.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn empty_filter_shows_no_theme_found() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut p = app(&tmp);
|
|
p.update(&ch('/'));
|
|
p.update(&ch('z'));
|
|
p.update(&ch('z'));
|
|
assert_eq!(0, p.filtered.len());
|
|
let buf = render(&mut p, 80, 20);
|
|
assert!(row_text(&buf, 9).contains("No theme found!"));
|
|
// Navigation does not panic with an empty list.
|
|
p.update(&key(KeyCode::Enter));
|
|
p.update(&key(KeyCode::Esc));
|
|
p.update(&key(KeyCode::End));
|
|
p.update(&ch('j'));
|
|
p.update(&ch('c'));
|
|
assert_eq!(0, p.current);
|
|
}
|
|
|
|
#[test]
|
|
fn unreadable_theme_shows_error() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut p = app(&tmp);
|
|
std::fs::remove_file(tmp.path().join("alpha")).unwrap();
|
|
let buf = render(&mut p, 120, 30);
|
|
let all: String = (0..30)
|
|
.map(|y| row_text(&buf, y))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(all.contains("Unable to open alpha from:"), "{all}");
|
|
assert!(all.contains("FileNotFound"));
|
|
}
|
|
|
|
#[test]
|
|
fn mouse_click_selects_and_wheel_scrolls() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut p = app(&tmp);
|
|
let mouse = |kind, row| {
|
|
Event::Mouse(MouseEvent {
|
|
kind,
|
|
column: 3,
|
|
row,
|
|
modifiers: KeyModifiers::NONE,
|
|
})
|
|
};
|
|
p.update(&mouse(MouseEventKind::Up(MouseButton::Left), 2));
|
|
render(&mut p, 120, 30);
|
|
assert_eq!(2, p.current);
|
|
p.update(&mouse(MouseEventKind::ScrollUp, 0));
|
|
render(&mut p, 120, 30);
|
|
assert_eq!(1, p.current);
|
|
p.update(&mouse(MouseEventKind::ScrollDown, 0));
|
|
render(&mut p, 120, 30);
|
|
assert_eq!(2, p.current);
|
|
// Click below the last theme does nothing.
|
|
p.update(&mouse(MouseEventKind::Up(MouseButton::Left), 20));
|
|
render(&mut p, 120, 30);
|
|
assert_eq!(2, p.current);
|
|
}
|
|
|
|
#[test]
|
|
fn window_scrolls_with_selection() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let names: Vec<String> = (0..40).map(|i| format!("theme{i:02}")).collect();
|
|
let refs: Vec<(&str, &str)> = names.iter().map(|n| (n.as_str(), "#000000")).collect();
|
|
let themes = make_themes(tmp.path(), &refs);
|
|
let mut p = Preview::new(themes, ColorScheme::All);
|
|
render(&mut p, 100, 10);
|
|
p.update(&key(KeyCode::PageDown));
|
|
let buf = render(&mut p, 100, 10);
|
|
assert_eq!(20, p.current);
|
|
assert_eq!(11, p.window);
|
|
assert!(row_text(&buf, 9).starts_with("\u{276f} theme20"));
|
|
}
|
|
|
|
#[test]
|
|
fn rank_is_subsequence() {
|
|
assert!(rank("Catppuccin Mocha", &["cat", "moc"]).is_some());
|
|
assert!(rank("Catppuccin Mocha", &["cpm"]).is_some());
|
|
assert!(rank("Catppuccin Mocha", &["xyz"]).is_none());
|
|
assert!(rank("abc", &["cba"]).is_none());
|
|
assert!(rank("abc", &[]).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn text_input_editing() {
|
|
let mut t = TextInput::default();
|
|
for c in "héllo wörld".chars() {
|
|
t.update(&KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
|
}
|
|
assert_eq!("héllo wörld", t.text);
|
|
t.update(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
|
t.update(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
|
assert_eq!("héllo wörd", t.text);
|
|
t.update(&KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
|
|
assert_eq!("héllo d", t.text);
|
|
t.update(&KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
|
|
t.update(&KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
|
|
assert_eq!("éllo d", t.text);
|
|
t.update(&KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
|
assert_eq!("", t.text);
|
|
}
|
|
|
|
#[test]
|
|
fn base64_encoding() {
|
|
assert_eq!("", base64(b""));
|
|
assert_eq!("Zg==", base64(b"f"));
|
|
assert_eq!("Zm8=", base64(b"fo"));
|
|
assert_eq!("Zm9v", base64(b"foo"));
|
|
assert_eq!("Zm9vYmFy", base64(b"foobar"));
|
|
}
|
|
}
|