[+] Live filter preset selection (#482)

* [+] Live filter preset selection

* [F] Resolve Copilot issues
This commit is contained in:
thea
2026-04-07 11:09:42 +10:00
committed by GitHub
parent 809b9c34f4
commit e78e60da3d
5 changed files with 361 additions and 107 deletions
Generated
+35
View File
@@ -115,8 +115,11 @@ dependencies = [
"bitflags", "bitflags",
"crossterm_winapi", "crossterm_winapi",
"document-features", "document-features",
"mio",
"parking_lot", "parking_lot",
"rustix", "rustix",
"signal-hook",
"signal-hook-mio",
"winapi", "winapi",
] ]
@@ -411,6 +414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [ dependencies = [
"libc", "libc",
"log",
"wasi", "wasi",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
@@ -703,6 +707,37 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" 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]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.1" version = "1.15.1"
+1 -1
View File
@@ -15,7 +15,7 @@ ansi_colours = { workspace = true, features = [] }
anstream = { workspace = true, features = [], optional = true } anstream = { workspace = true, features = [], optional = true }
anyhow = { workspace = true, features = ["std"] } anyhow = { workspace = true, features = ["std"] }
bpaf = { workspace = true, features = [] } bpaf = { workspace = true, features = [] }
crossterm = { workspace = true, features = [] } crossterm = { workspace = true, features = ["events"] }
deranged = { workspace = true, features = ["serde", "std"] } deranged = { workspace = true, features = ["serde", "std"] }
directories = { workspace = true, features = [] } directories = { workspace = true, features = [] }
enterpolation = { workspace = true, features = ["bspline", "std"] } enterpolation = { workspace = true, features = ["bspline", "std"] }
+193 -48
View File
@@ -2,7 +2,7 @@ use std::borrow::Cow;
use std::cmp; use std::cmp;
use std::fmt::Write as _; use std::fmt::Write as _;
use std::fs::{self, File}; 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;
use std::iter::zip; use std::iter::zip;
use std::num::NonZeroU8; use std::num::NonZeroU8;
@@ -10,6 +10,8 @@ use std::path::{Path, PathBuf};
use aho_corasick::AhoCorasick; use aho_corasick::AhoCorasick;
use anyhow::{Context as _, Result}; 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 deranged::RangedU8;
use enterpolation::bspline::BSpline; use enterpolation::bspline::BSpline;
use enterpolation::{Curve as _, Generator as _}; use enterpolation::{Curve as _, Generator as _};
@@ -453,6 +455,40 @@ fn create_config(
////////////////////////////// //////////////////////////////
// 3. Choose preset // 3. Choose preset
struct RawModeGuard {
enabled: bool,
}
impl RawModeGuard {
fn new() -> Result<Self> {
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 // Create flag lines
let mut flags = Vec::with_capacity(Preset::COUNT); let mut flags = Vec::with_capacity(Preset::COUNT);
let spacing = { let spacing = {
@@ -479,7 +515,11 @@ fn create_config(
name = preset.as_ref(), name = preset.as_ref(),
spacing = usize::from(spacing) 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 // 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; let rows_per_page = (term_h.saturating_sub(13) / 5).clamp(1, u8::MAX.into()) as u8;
(flags_per_row, rows_per_page) (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 fn filter_flag_indices(query: &str, flags: &[(Preset, [String; 4], String)]) -> Vec<usize> {
let mut pages = Vec::with_capacity(usize::from(num_pages)); if query.is_empty() {
for flags in flags.chunks(usize::from( return (0..flags.len()).collect();
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);
}
pages.push(page);
} }
let print_flag_page = |page, page_num: u8| -> Result<()> { let mut matched = flags
clear_screen(Some(&title), color_mode, debug_mode).context("failed to clear screen")?; .iter()
print_title_prompt(option_counter, "Let's choose a flag!"); .enumerate()
println!("Available flag presets:\nPage: {page_num} of {num_pages}\n", page_num = page_num + 1); .filter_map(|(idx, (_, _, preset_name))| {
for &row in page { let position = preset_name.find(query)?;
print_flag_row(row, color_mode).context("failed to print flag row")?; Some((idx, preset_name.starts_with(query), position))
} })
println!(); .collect::<Vec<_>>();
Ok(())
};
fn print_flag_row(row: &[[String; 4]], color_mode: AnsiMode) -> Result<()> { // 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()
}
fn print_flag_row(row: &[&[String; 4]], color_mode: AnsiMode) -> Result<()> {
for i in 0..4 { for i in 0..4 {
let mut line = Vec::new(); let mut line = Vec::new();
for flag in row { for flag in row {
@@ -539,32 +573,138 @@ fn create_config(
) )
.expect("coloring text with default preset should not fail"); .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.");
} else {
for row in filtered_indices[start..end].chunks(usize::from(flags_per_row)) {
let row = row
.iter()
.map(|&idx| &flags[idx].1)
.collect::<Vec<&[String; 4]>>();
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 the up/down 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 preset: Preset;
let color_profile; 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 { 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> = <Preset as VariantNames>::VARIANTS.into(); print_flag_page(&filtered_indices, page, &filter, hint)
opts.extend(["next", "n", "prev", "p"]); .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."); raw_mode
let selection = literal_input( .enable()
format!("Which {preset_default_colored} do you want to use? "), .context("failed to enable raw mode for key input")?;
&opts[..], let event = event::read().context("failed to read keyboard event")?;
Preset::Rainbow.as_ref(), let Event::Key(key) = event else {
false, continue;
color_mode, };
) if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
.context("failed to ask for choice input") continue;
.context("failed to select preset")?; }
if selection == "next" || selection == "n" {
page = (page + 1) % num_pages; match key.code {
} else if selection == "prev" || selection == "p" { 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::Up => {
page = (page + num_pages - 1) % num_pages; page = (page + num_pages - 1) % num_pages;
} else { },
preset = selection.parse().expect("selected preset should be valid"); 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"); debug!(?preset, "selected preset");
color_profile = preset.color_profile(); color_profile = preset.color_profile();
update_title( update_title(
@@ -581,9 +721,14 @@ fn create_config(
) )
.expect("coloring text with selected preset should not fail"), .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 // 4. Dim/lighten colors
+115 -42
View File
@@ -7,6 +7,7 @@ import importlib.util
import json import json
import os import os
import random import random
import sys
import traceback import traceback
from itertools import permutations, islice from itertools import permutations, islice
from math import ceil from math import ceil
@@ -126,62 +127,134 @@ def create_config() -> Config:
############################## ##############################
# 3. Choose preset # 3. Choose preset
# Create flags = [[lines]] # Create flags = [[lines]]
flags = [] flag_entries = []
spacing = max(max(len(k) for k in PRESETS.keys()), 20) spacing = max(max(len(k) for k in PRESETS.keys()), 20)
for name, preset in PRESETS.items(): for name, preset in PRESETS.items():
flag = preset.color_text(' ' * spacing, foreground=False) 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 # 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) 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 def filter_flag_indices(query: str) -> list[int]:
pages = [] if not query:
for i in range(num_pages): return list(range(len(flag_entries)))
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 print_flag_page(page: list[list[list[str]]], page_num: int): matched = []
clear_screen(title) for idx, (_, _, lower_name) in enumerate(flag_entries):
print_title_prompt("Let's choose a flag!") pos = lower_name.find(query)
printc('Available flag presets:') if pos == -1:
print(f'Page: {page_num + 1} of {num_pages}') continue
print() matched.append((0 if lower_name.startswith(query) else 1, pos, idx))
for i in page: matched.sort()
print_flag_row(i) return [idx for _, _, idx in matched]
print()
def print_flag_row(current: list[list[str]]): def select_preset_prompt_toolkit() -> str:
[printc(' '.join(line)) for line in zip(*current)] from prompt_toolkit import PromptSession
print() from prompt_toolkit.application.current import get_app
from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.key_binding import KeyBindings
page = 0 page_num = 0
while True: prompt_header = PRESETS['rainbow'].set_light_dl_def(light_dark).color_text('preset')
print_flag_page(pages[page], page) kb = KeyBindings()
last_filter = ''
tmp = PRESETS['rainbow'].set_light_dl_def(light_dark).color_text('preset') def current_num_pages() -> int:
opts = list(PRESETS.keys()) text = get_app().current_buffer.text.lower().strip()
if page < num_pages - 1: indices = filter_flag_indices(text)
opts.append('next') return max(1, ceil(len(indices) / flags_per_page))
if page > 0:
opts.append('prev') @kb.add('up')
print("Enter 'next' to go to the next page and 'prev' to go to the previous page.") def _prev_page(event):
preset = literal_input(f'Which {tmp} do you want to use? ', opts, 'rainbow', show_ops=False) nonlocal page_num
if preset == 'next': page_num = (page_num + current_num_pages() - 1) % current_num_pages()
page += 1 event.app.invalidate()
elif preset == 'prev':
page -= 1 @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.")
visible_row_count = 0.2
else: 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([""] * round(missing_rows * 5))
lines.append(
"Use the up/down 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] _prs = PRESETS[preset]
update_title('Selected flag', _prs.set_light_dl_def(light_dark).color_text(preset)) update_title('Selected flag', _prs.set_light_dl_def(light_dark).color_text(preset))
break
############################# #############################
# 4. Dim/lighten colors # 4. Dim/lighten colors
+1
View File
@@ -26,6 +26,7 @@ classifiers = [
dependencies = [ dependencies = [
# Universal dependencies # Universal dependencies
'typing_extensions; python_version < "3.8"', 'typing_extensions; python_version < "3.8"',
'prompt_toolkit>=3.0.36',
# Windows dependencies # Windows dependencies
'psutil ; platform_system=="Windows"', 'psutil ; platform_system=="Windows"',