From 20fa7b0e162653e9ad15e513641ec592c716f0e9 Mon Sep 17 00:00:00 2001 From: thea Date: Sun, 22 Feb 2026 16:46:02 +1000 Subject: [PATCH] [+] Live filter preset selection --- Cargo.lock | 35 +++++ crates/hyfetch/Cargo.toml | 2 +- crates/hyfetch/src/bin/hyfetch.rs | 241 ++++++++++++++++++++++++------ hyfetch/main.py | 182 +++++++++++++++++----- pyproject.toml | 1 + 5 files changed, 375 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0b743eb4..89e83232 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,8 +115,11 @@ dependencies = [ "bitflags", "crossterm_winapi", "document-features", + "mio", "parking_lot", "rustix", + "signal-hook", + "signal-hook-mio", "winapi", ] @@ -411,6 +414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.59.0", ] @@ -703,6 +707,37 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "smallvec" version = "1.15.1" diff --git a/crates/hyfetch/Cargo.toml b/crates/hyfetch/Cargo.toml index 67810177..8844a325 100644 --- a/crates/hyfetch/Cargo.toml +++ b/crates/hyfetch/Cargo.toml @@ -15,7 +15,7 @@ ansi_colours = { workspace = true, features = [] } anstream = { workspace = true, features = [], optional = true } anyhow = { workspace = true, features = ["std"] } bpaf = { workspace = true, features = [] } -crossterm = { workspace = true, features = [] } +crossterm = { workspace = true, features = ["events"] } deranged = { workspace = true, features = ["serde", "std"] } directories = { workspace = true, features = [] } enterpolation = { workspace = true, features = ["bspline", "std"] } diff --git a/crates/hyfetch/src/bin/hyfetch.rs b/crates/hyfetch/src/bin/hyfetch.rs index 59a61ea3..70d9c939 100644 --- a/crates/hyfetch/src/bin/hyfetch.rs +++ b/crates/hyfetch/src/bin/hyfetch.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use std::cmp; use std::fmt::Write as _; use std::fs::{self, File}; -use std::io::{self, IsTerminal as _, Read as _}; +use std::io::{self, IsTerminal as _, Read as _, Write}; use std::iter; use std::iter::zip; use std::num::NonZeroU8; @@ -10,6 +10,8 @@ use std::path::{Path, PathBuf}; use aho_corasick::AhoCorasick; use anyhow::{Context as _, Result}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use deranged::RangedU8; use enterpolation::bspline::BSpline; use enterpolation::{Curve as _, Generator as _}; @@ -453,6 +455,40 @@ fn create_config( ////////////////////////////// // 3. Choose preset + struct RawModeGuard { + enabled: bool, + } + + impl RawModeGuard { + fn new() -> Result { + Ok(Self { enabled: false }) + } + + fn enable(&mut self) -> Result<()> { + if !self.enabled { + enable_raw_mode().context("failed to enable terminal raw mode")?; + self.enabled = true; + } + Ok(()) + } + + fn disable(&mut self) -> Result<()> { + if self.enabled { + disable_raw_mode().context("failed to disable terminal raw mode")?; + self.enabled = false; + } + Ok(()) + } + } + + impl Drop for RawModeGuard { + fn drop(&mut self) { + if self.enabled { + let _ = disable_raw_mode(); + } + } + } + // Create flag lines let mut flags = Vec::with_capacity(Preset::COUNT); let spacing = { @@ -479,7 +515,11 @@ fn create_config( name = preset.as_ref(), spacing = usize::from(spacing) ); - flags.push([name, flag.clone(), flag.clone(), flag]); + flags.push(( + preset.clone(), + [name, flag.clone(), flag.clone(), flag], + preset.as_ref().to_ascii_lowercase(), + )); } // Calculate flags per row @@ -489,34 +529,28 @@ fn create_config( let rows_per_page = (term_h.saturating_sub(13) / 5).clamp(1, u8::MAX.into()) as u8; (flags_per_row, rows_per_page) }; - let num_pages = (Preset::COUNT.div_ceil(flags_per_row as usize * rows_per_page as usize)).clamp(0, u8::MAX.into()) as u8; + let flags_per_page = usize::from(flags_per_row) * usize::from(rows_per_page); - // Create pages - let mut pages = Vec::with_capacity(usize::from(num_pages)); - for flags in flags.chunks(usize::from( - u16::from(flags_per_row) - .checked_mul(u16::from(rows_per_page)) - .unwrap(), - )) { - let mut page = Vec::with_capacity(usize::from(rows_per_page)); - for flags in flags.chunks(usize::from(flags_per_row)) { - page.push(flags); + fn filter_flag_indices(query: &str, flags: &[(Preset, [String; 4], String)]) -> Vec { + if query.is_empty() { + return (0..flags.len()).collect(); } - pages.push(page); + + let mut matched = flags + .iter() + .enumerate() + .filter_map(|(idx, (_, _, preset_name))| { + let position = preset_name.find(query)?; + Some((idx, preset_name.starts_with(query), position)) + }) + .collect::>(); + + // Prefix matches are shown first, then other substring matches ordered by earliest index. + matched.sort_by_key(|&(idx, is_prefix, position)| (!is_prefix, position, idx)); + matched.into_iter().map(|(idx, _, _)| idx).collect() } - let print_flag_page = |page, page_num: u8| -> Result<()> { - clear_screen(Some(&title), color_mode, debug_mode).context("failed to clear screen")?; - print_title_prompt(option_counter, "Let's choose a flag!"); - println!("Available flag presets:\nPage: {page_num} of {num_pages}\n", page_num = page_num + 1); - for &row in page { - print_flag_row(row, color_mode).context("failed to print flag row")?; - } - println!(); - Ok(()) - }; - - fn print_flag_row(row: &[[String; 4]], color_mode: AnsiMode) -> Result<()> { + fn print_flag_row(row: &[&[String; 4]], color_mode: AnsiMode) -> Result<()> { for i in 0..4 { let mut line = Vec::new(); for flag in row { @@ -539,32 +573,138 @@ fn create_config( ) .expect("coloring text with default preset should not fail"); + let print_flag_page = |filtered_indices: &[usize], + page_num: usize, + filter: &str, + hint: Option<&str>| + -> Result<()> { + let num_pages = filtered_indices.len().div_ceil(flags_per_page).max(1); + clear_screen(Some(&title), color_mode, debug_mode).context("failed to clear screen")?; + print_title_prompt(option_counter, "Let's choose a flag!"); + println!( + "Available flag presets:\nPage: {page} of {num_pages}\n", + page = page_num + 1 + ); + + let start = page_num * flags_per_page; + let end = (start + flags_per_page).min(filtered_indices.len()); + let mut visible_rows: usize = 0; + if start >= end { + println!("No presets matched this filter.\n"); + } else { + for row in filtered_indices[start..end].chunks(usize::from(flags_per_row)) { + let row = row + .iter() + .map(|&idx| &flags[idx].1) + .collect::>(); + print_flag_row(&row, color_mode).context("failed to print flag row")?; + visible_rows += 1; + } + println!(); + } + // Keep the prompt anchored by reserving a full page worth of flag rows. + for _ in visible_rows..usize::from(rows_per_page) { + for _ in 0..5 { + println!(); + } + } + + println!( + "Use arrow keys to go to the previous/next page. Type to filter and press Enter to select." + ); + printc( + format!( + "Which {preset_default_colored} do you want to use? (default: {})", + Preset::Rainbow.as_ref() + ), + color_mode, + ) + .context("failed to print preset prompt")?; + print!("> {filter}"); + io::stdout().flush().context("failed to flush preset prompt")?; + + if let Some(hint) = hint { + println!("{hint}"); + } + Ok(()) + }; + let preset: Preset; let color_profile; - let mut page: u8 = 0; + let mut page: usize = 0; + let mut filter = String::new(); + let mut hint: Option<&str> = None; + let mut raw_mode = RawModeGuard::new().context("failed to initialize raw input mode")?; loop { - print_flag_page(&pages[usize::from(page)], page).context("failed to print flag page")?; + raw_mode + .disable() + .context("failed to disable raw mode for rendering")?; + let filter_lower = filter.to_ascii_lowercase(); + let filtered_indices = filter_flag_indices(&filter_lower, &flags); + let num_pages = filtered_indices.len().div_ceil(flags_per_page).max(1); + page = page.min(num_pages - 1); - let mut opts: Vec<&str> = ::VARIANTS.into(); - opts.extend(["next", "n", "prev", "p"]); + print_flag_page(&filtered_indices, page, &filter, hint) + .context("failed to print flag page")?; + hint = None; - println!("Enter '[n]ext' to go to the next page and '[p]rev' to go to the previous page."); - let selection = literal_input( - format!("Which {preset_default_colored} do you want to use? "), - &opts[..], - Preset::Rainbow.as_ref(), - false, - color_mode, - ) - .context("failed to ask for choice input") - .context("failed to select preset")?; - if selection == "next" || selection == "n" { - page = (page + 1) % num_pages; - } else if selection == "prev" || selection == "p" { + raw_mode + .enable() + .context("failed to enable raw mode for key input")?; + let event = event::read().context("failed to read keyboard event")?; + let Event::Key(key) = event else { + continue; + }; + if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + continue; + } + + match key.code { + KeyCode::Enter => { + let selection = flags + .iter() + .find(|(_, _, name)| *name == filter_lower) + .map(|(preset, _, _)| *preset) + .or_else(|| filtered_indices.first().map(|&idx| flags[idx].0)); + preset = selection.unwrap_or(Preset::Rainbow); + break; + }, + KeyCode::Left | KeyCode::Up => { page = (page + num_pages - 1) % num_pages; - } else { - preset = selection.parse().expect("selected preset should be valid"); + }, + KeyCode::Right | KeyCode::Down => { + page = (page + 1) % num_pages; + }, + KeyCode::Backspace => { + filter.pop(); + page = 0; + }, + KeyCode::Esc => { + filter.clear(); + page = 0; + }, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + raw_mode + .disable() + .context("failed to disable raw mode before interrupting")?; + println!(); + return Err(anyhow::anyhow!("interrupted by user")); + }, + KeyCode::Char(c) + if !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + filter.push(c); + page = 0; + }, + _ => {}, + } + } + raw_mode + .disable() + .context("failed to disable raw mode after preset selection")?; + debug!(?preset, "selected preset"); color_profile = preset.color_profile(); update_title( @@ -581,9 +721,14 @@ fn create_config( ) .expect("coloring text with selected preset should not fail"), ); - break; - } - } + printc( + format!( + "Which {preset_default_colored} do you want to use? {}\n", + preset.as_ref() + ), + color_mode, + ) + .context("failed to print preset selection summary")?; ////////////////////////////// // 4. Dim/lighten colors diff --git a/hyfetch/main.py b/hyfetch/main.py index 0df0a1a0..a5862f1a 100755 --- a/hyfetch/main.py +++ b/hyfetch/main.py @@ -7,6 +7,7 @@ import importlib.util import json import os import random +import sys import traceback from itertools import permutations, islice from math import ceil @@ -126,62 +127,169 @@ def create_config() -> Config: ############################## # 3. Choose preset # Create flags = [[lines]] - flags = [] + flag_entries = [] spacing = max(max(len(k) for k in PRESETS.keys()), 20) for name, preset in PRESETS.items(): flag = preset.color_text(' ' * spacing, foreground=False) - flags.append([name.center(spacing), flag, flag, flag]) + flag_entries.append((name, [name.center(spacing), flag, flag, flag], name.lower())) # Calculate flags per row - flags_per_row = term_size()[0] // (spacing + 2) + flags_per_row = max(1, term_size()[0] // (spacing + 2)) row_per_page = max(1, (term_size()[1] - 13) // 5) - num_pages = ceil(len(flags) / (flags_per_row * row_per_page)) + flags_per_page = flags_per_row * row_per_page - # Create pages - pages = [] - for i in range(num_pages): - page = [] - for j in range(row_per_page): - page.append(flags[:flags_per_row]) - flags = flags[flags_per_row:] - if not flags: - break - pages.append(page) + def filter_flag_indices(query: str) -> list[int]: + if not query: + return list(range(len(flag_entries))) - def print_flag_page(page: list[list[list[str]]], page_num: int): + matched = [] + for idx, (_, _, lower_name) in enumerate(flag_entries): + pos = lower_name.find(query) + if pos == -1: + continue + matched.append((0 if lower_name.startswith(query) else 1, pos, idx)) + matched.sort() + return [idx for _, _, idx in matched] + + def print_flag_page(filtered_indices: list[int], page_num: int, text_filter: str, hint: str | None): + num_pages = max(1, ceil(len(filtered_indices) / flags_per_page)) clear_screen(title) print_title_prompt("Let's choose a flag!") printc('Available flag presets:') print(f'Page: {page_num + 1} of {num_pages}') print() - for i in page: - print_flag_row(i) - print() + + start = page_num * flags_per_page + end = min(start + flags_per_page, len(filtered_indices)) + + if start >= end: + print('No presets matched this filter.') + print() + else: + current = filtered_indices[start:end] + for i in range(0, len(current), flags_per_row): + row = [flag_entries[idx][1] for idx in current[i:i + flags_per_row]] + print_flag_row(row) + print() + + tmp = PRESETS['rainbow'].set_light_dl_def(light_dark).color_text('preset') + print('Use arrow keys to go to the previous/next page. Type to filter and press Enter to select.') + printc(f'Which {tmp} do you want to use? (default: rainbow)') + print(f'> {text_filter}', end='', flush=True) + if hint: + print(f'\n{hint}', end='') def print_flag_row(current: list[list[str]]): [printc(' '.join(line)) for line in zip(*current)] print() - page = 0 - while True: - print_flag_page(pages[page], page) + def select_preset_prompt_toolkit() -> str: + from prompt_toolkit import PromptSession + from prompt_toolkit.application.current import get_app + from prompt_toolkit.formatted_text import ANSI + from prompt_toolkit.key_binding import KeyBindings - tmp = PRESETS['rainbow'].set_light_dl_def(light_dark).color_text('preset') - opts = list(PRESETS.keys()) - if page < num_pages - 1: - opts.append('next') - if page > 0: - opts.append('prev') - print("Enter 'next' to go to the next page and 'prev' to go to the previous page.") - preset = literal_input(f'Which {tmp} do you want to use? ', opts, 'rainbow', show_ops=False) - if preset == 'next': - page += 1 - elif preset == 'prev': - page -= 1 - else: - _prs = PRESETS[preset] - update_title('Selected flag', _prs.set_light_dl_def(light_dark).color_text(preset)) - break + page_num = 0 + prompt_header = PRESETS['rainbow'].set_light_dl_def(light_dark).color_text('preset') + kb = KeyBindings() + last_filter = '' + + def current_num_pages() -> int: + text = get_app().current_buffer.text.lower().strip() + indices = filter_flag_indices(text) + return max(1, ceil(len(indices) / flags_per_page)) + + @kb.add('left') + @kb.add('up') + def _prev_page(event): + nonlocal page_num + page_num = (page_num + current_num_pages() - 1) % current_num_pages() + event.app.invalidate() + + @kb.add('right') + @kb.add('down') + def _next_page(event): + nonlocal page_num + page_num = (page_num + 1) % current_num_pages() + event.app.invalidate() + + @kb.add('escape') + def _clear_filter(event): + nonlocal page_num + event.current_buffer.text = '' + event.current_buffer.cursor_position = 0 + page_num = 0 + event.app.invalidate() + + def build_preset_lines(text_filter: str): + nonlocal page_num + filtered_indices = filter_flag_indices(text_filter.lower().strip()) + num_pages = max(1, ceil(len(filtered_indices) / flags_per_page)) + page_num = min(page_num, num_pages - 1) + + lines = [ + color(f"&a{option_counter}. Let's choose a flag!&r"), + "Available flag presets:", + f"Page: {page_num + 1} of {num_pages}", + "", + ] + + start = page_num * flags_per_page + end = min(start + flags_per_page, len(filtered_indices)) + visible_row_count = 0 + if start >= end: + lines.append("No presets matched this filter.") + lines.append("") + visible_row_count = 0 + else: + current = filtered_indices[start:end] + for i in range(0, len(current), flags_per_row): + row = [flag_entries[idx][1] for idx in current[i:i + flags_per_row]] + lines.extend(' '.join(line) for line in zip(*row)) + lines.append("") + visible_row_count += 1 + + # Keep prompt at a fixed vertical position by padding to a full page height. + missing_rows = max(0, row_per_page - visible_row_count) + lines.extend([""] * (missing_rows * 5)) + + lines.append( + "Use arrow keys to go to the previous/next page. Type to filter and press Enter to select." + ) + lines.append(f"Which {prompt_header} do you want to use? (default: rainbow)") + return lines + + def prompt_message(): + nonlocal last_filter + app = get_app() + text_filter = app.current_buffer.text + last_filter = text_filter + + lines = build_preset_lines(text_filter) + return ANSI('\n'.join(lines) + '\n> ') + + clear_screen(title) + session = PromptSession(key_bindings=kb) + try: + selection = session.prompt(prompt_message).strip().lower() + except KeyboardInterrupt: + clear_screen(title) + for line in build_preset_lines(last_filter): + print(line) + print(f"> {last_filter}") + raise + + filtered_indices = filter_flag_indices(selection) + if selection in PRESETS: + return selection + if filtered_indices: + return flag_entries[filtered_indices[0]][0] + return 'rainbow' + + preset = select_preset_prompt_toolkit() + + _prs = PRESETS[preset] + update_title('Selected flag', _prs.set_light_dl_def(light_dark).color_text(preset)) ############################# # 4. Dim/lighten colors diff --git a/pyproject.toml b/pyproject.toml index c700d2a7..94d7212b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ classifiers = [ dependencies = [ # Universal dependencies 'typing_extensions; python_version < "3.8"', + 'prompt_toolkit>=3.0.36', # Windows dependencies 'psutil ; platform_system=="Windows"',