This commit is contained in:
thea 2026-03-08 22:06:17 +08:00 committed by GitHub
commit 78039b1965
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 375 additions and 86 deletions

35
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

View File

@ -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<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
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<usize> {
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::<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<()> {
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::<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 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> = <Preset as VariantNames>::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

View File

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

View File

@ -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"',