[+] 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"] }
+194 -49
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 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::<Vec<_>>();
// 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<()> { fn print_flag_row(row: &[&[String; 4]], color_mode: AnsiMode) -> 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<()> {
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;
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::Up => {
page = (page + num_pages - 1) % num_pages;
},
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")?;
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" {
page = (page + num_pages - 1) % num_pages;
} else {
preset = selection.parse().expect("selected preset should be valid");
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
+117 -44
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): matched = []
page.append(flags[:flags_per_row]) for idx, (_, _, lower_name) in enumerate(flag_entries):
flags = flags[flags_per_row:] pos = lower_name.find(query)
if not flags: if pos == -1:
break continue
pages.append(page) matched.append((0 if lower_name.startswith(query) else 1, pos, idx))
matched.sort()
return [idx for _, _, idx in matched]
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
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('up')
def _prev_page(event):
nonlocal page_num
page_num = (page_num + current_num_pages() - 1) % current_num_pages()
event.app.invalidate()
@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:
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> ')
def print_flag_page(page: list[list[list[str]]], page_num: int):
clear_screen(title) clear_screen(title)
print_title_prompt("Let's choose a flag!") session = PromptSession(key_bindings=kb)
printc('Available flag presets:') try:
print(f'Page: {page_num + 1} of {num_pages}') selection = session.prompt(prompt_message).strip().lower()
print() except KeyboardInterrupt:
for i in page: clear_screen(title)
print_flag_row(i) for line in build_preset_lines(last_filter):
print() print(line)
print(f"> {last_filter}")
raise
def print_flag_row(current: list[list[str]]): filtered_indices = filter_flag_indices(selection)
[printc(' '.join(line)) for line in zip(*current)] if selection in PRESETS:
print() return selection
if filtered_indices:
return flag_entries[filtered_indices[0]][0]
return 'rainbow'
page = 0 preset = select_preset_prompt_toolkit()
while True:
print_flag_page(pages[page], page)
tmp = PRESETS['rainbow'].set_light_dl_def(light_dark).color_text('preset') _prs = PRESETS[preset]
opts = list(PRESETS.keys()) update_title('Selected flag', _prs.set_light_dl_def(light_dark).color_text(preset))
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
############################# #############################
# 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"',