This commit is contained in:
Ultradesu
2026-06-10 16:11:09 +01:00
commit 39b955b6e7
31 changed files with 11526 additions and 0 deletions
+180
View File
@@ -0,0 +1,180 @@
# Default keybindings for furumi-cli.
#
# To customize, copy entries into <config dir>/furumi/keymap.toml
# (~/.config/furumi/keymap.toml on Linux/macOS). A user binding replaces the
# default binding with the same key sequence and context.
#
# key_sequence: space-separated chords, e.g. "g g" or "ctrl-a x".
# Modifiers: ctrl-, alt-, shift-, cmd-. Uppercase letters: "shift-g".
# command: an Action name, optionally with parameters:
# command = { SeekForward = { seconds = 30 } }
# context: optional view filter — global (default), library, search,
# playlists, queue, devices.
[[keymaps]]
key_sequence = "q"
command = "Quit"
[[keymaps]]
key_sequence = "ctrl-c"
command = "Quit"
[[keymaps]]
key_sequence = "?"
command = "ToggleHelp"
[[keymaps]]
key_sequence = "tab"
command = "NextTab"
[[keymaps]]
key_sequence = "backtab"
command = "PrevTab"
[[keymaps]]
key_sequence = "1"
command = { GoToTab = 0 }
[[keymaps]]
key_sequence = "2"
command = { GoToTab = 1 }
[[keymaps]]
key_sequence = "3"
command = { GoToTab = 2 }
[[keymaps]]
key_sequence = "4"
command = { GoToTab = 3 }
[[keymaps]]
key_sequence = "a"
command = "QueueAddNext"
[[keymaps]]
key_sequence = "shift-a"
command = "QueueAddLast"
[[keymaps]]
key_sequence = "j"
command = "MoveDown"
[[keymaps]]
key_sequence = "down"
command = "MoveDown"
[[keymaps]]
key_sequence = "k"
command = "MoveUp"
[[keymaps]]
key_sequence = "up"
command = "MoveUp"
[[keymaps]]
key_sequence = "h"
command = "MoveLeft"
[[keymaps]]
key_sequence = "left"
command = "MoveLeft"
[[keymaps]]
key_sequence = "l"
command = "MoveRight"
[[keymaps]]
key_sequence = "right"
command = "MoveRight"
[[keymaps]]
key_sequence = "pageup"
command = "PageUp"
[[keymaps]]
key_sequence = "pagedown"
command = "PageDown"
[[keymaps]]
key_sequence = "ctrl-u"
command = "PageUp"
[[keymaps]]
key_sequence = "ctrl-d"
command = "PageDown"
[[keymaps]]
key_sequence = "g g"
command = "SelectFirst"
[[keymaps]]
key_sequence = "shift-g"
command = "SelectLast"
[[keymaps]]
key_sequence = "enter"
command = "Select"
[[keymaps]]
key_sequence = "esc"
command = "Back"
[[keymaps]]
key_sequence = "backspace"
command = "Back"
[[keymaps]]
key_sequence = "space"
command = "PlayPause"
[[keymaps]]
key_sequence = "n"
command = "NextTrack"
[[keymaps]]
key_sequence = "p"
command = "PrevTrack"
[[keymaps]]
key_sequence = "."
command = { SeekForward = { seconds = 10 } }
[[keymaps]]
key_sequence = ","
command = { SeekBackward = { seconds = 10 } }
[[keymaps]]
key_sequence = "+"
command = "VolumeUp"
[[keymaps]]
key_sequence = "="
command = "VolumeUp"
[[keymaps]]
key_sequence = "-"
command = "VolumeDown"
[[keymaps]]
key_sequence = "s"
command = "ToggleShuffle"
[[keymaps]]
key_sequence = "r"
command = "CycleRepeat"
[[keymaps]]
key_sequence = "x"
command = "ToggleLike"
[[keymaps]]
key_sequence = "shift-l"
command = "Logout"
[[keymaps]]
key_sequence = "v"
command = "ToggleViewMode"
[[keymaps]]
key_sequence = ":"
command = "OpenCommandLine"
+484
View File
@@ -0,0 +1,484 @@
use std::{fs, path::PathBuf, str::FromStr};
use anyhow::{Context as _, Result, bail};
use crokey::{KeyCombination, KeyCombinationFormat, key};
use crossterm::event::{KeyCode, KeyModifiers};
use serde::Deserialize;
use crate::app::action::Action;
const DEFAULT_KEYMAP: &str = include_str!("default_keymap.toml");
/// Input context a binding applies to. `Global` bindings work everywhere;
/// view-specific bindings shadow global ones for the same key sequence.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KeyContext {
#[default]
Global,
Library,
Search,
Playlists,
Queue,
Devices,
}
impl KeyContext {
pub fn label(self) -> &'static str {
match self {
KeyContext::Global => "global",
KeyContext::Library => "library",
KeyContext::Search => "search",
KeyContext::Playlists => "playlists",
KeyContext::Queue => "queue",
KeyContext::Devices => "devices",
}
}
}
#[derive(Debug, Clone)]
pub struct Binding {
pub keys: Vec<KeyCombination>,
pub action: Action,
pub context: KeyContext,
}
#[derive(Debug, Deserialize)]
struct RawBinding {
key_sequence: String,
command: Action,
#[serde(default)]
context: KeyContext,
}
#[derive(Debug, Default, Deserialize)]
struct KeymapFile {
#[serde(default)]
keymaps: Vec<RawBinding>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum KeyResolution {
Action(Action),
/// The pressed keys are a prefix of a longer sequence; the formatted
/// pending chord is returned for display in the status bar.
Pending(String),
Unmatched,
}
pub struct Keymap {
bindings: Vec<Binding>,
pending: Vec<KeyCombination>,
format: KeyCombinationFormat,
}
impl Keymap {
/// Load defaults merged with the user's keymap.toml. A broken user file
/// must not brick the app: it is ignored and reported as a warning.
pub fn load() -> (Self, Option<String>) {
let mut bindings =
parse_bindings(DEFAULT_KEYMAP).expect("embedded default keymap must parse");
let mut warning = None;
if let Some(path) = user_keymap_path() {
if path.exists() {
match fs::read_to_string(&path)
.map_err(anyhow::Error::from)
.and_then(|text| parse_bindings(&text))
{
Ok(user) => merge(&mut bindings, user),
Err(err) => {
warning = Some(format!(
"{} ignored: {err:#}; using default keybindings",
path.display()
));
}
}
}
}
let keymap = Self {
bindings,
pending: Vec::new(),
format: KeyCombinationFormat::default(),
};
(keymap, warning)
}
/// Feed one key combination; returns an action, a pending-chord state, or
/// nothing. Esc clears a pending chord instead of resolving.
pub fn resolve(&mut self, key: KeyCombination, context: KeyContext) -> KeyResolution {
let key = self.localize(normalize(key));
if key == key!(esc) && !self.pending.is_empty() {
self.pending.clear();
return KeyResolution::Unmatched;
}
self.pending.push(key);
match self.lookup(context) {
Lookup::Exact(action) => {
self.pending.clear();
KeyResolution::Action(action)
}
Lookup::Prefix => KeyResolution::Pending(self.format_pending()),
Lookup::Nothing => {
let retry = self.pending.len() > 1;
self.pending.clear();
if retry {
// The aborted chord's last key may start a new sequence.
self.resolve(key, context)
} else {
KeyResolution::Unmatched
}
}
}
}
/// All bindings as (keys, description, context) for the help view.
pub fn help_entries(&self) -> Vec<(String, String, KeyContext)> {
self.bindings
.iter()
.map(|b| (self.format_keys(&b.keys), b.action.describe(), b.context))
.collect()
}
fn lookup(&self, context: KeyContext) -> Lookup {
let mut exact_ctx: Option<&Binding> = None;
let mut exact_global: Option<&Binding> = None;
let mut has_prefix = false;
for b in &self.bindings {
if b.context != KeyContext::Global && b.context != context {
continue;
}
if b.keys.len() < self.pending.len() || b.keys[..self.pending.len()] != self.pending {
continue;
}
if b.keys.len() == self.pending.len() {
if b.context == KeyContext::Global {
exact_global.get_or_insert(b);
} else {
exact_ctx.get_or_insert(b);
}
} else {
has_prefix = true;
}
}
// An exact match fires immediately even if a longer sequence shares
// the prefix — don't bind both "g" and "g g".
if let Some(b) = exact_ctx.or(exact_global) {
Lookup::Exact(b.action.clone())
} else if has_prefix {
Lookup::Prefix
} else {
Lookup::Nothing
}
}
/// Layout fallback (vim langmap style): a Cyrillic key that no binding
/// uses directly is translated to the Latin key in the same physical
/// position (ЙЦУКЕН ↔ QWERTY), so bindings work in the Russian layout.
/// Text input is unaffected — this runs only inside keymap resolution.
fn localize(&self, key: KeyCombination) -> KeyCombination {
let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes else {
return key;
};
let lower = c.to_lowercase().next().unwrap_or(c);
let Some(latin) = qwerty_equivalent(lower) else {
return key;
};
// A binding that mentions the Cyrillic key directly wins.
if self.bindings.iter().any(|b| b.keys.contains(&key)) {
return key;
}
let mapped = if c.is_uppercase() || key.modifiers.contains(KeyModifiers::SHIFT) {
KeyCode::Char(latin.to_ascii_uppercase())
} else {
KeyCode::Char(latin)
};
normalize(KeyCombination::new(mapped, key.modifiers))
}
fn format_keys(&self, keys: &[KeyCombination]) -> String {
keys.iter()
.map(|k| self.format.to_string(*k))
.collect::<Vec<_>>()
.join(" ")
}
fn format_pending(&self) -> String {
self.format_keys(&self.pending)
}
}
enum Lookup {
Exact(Action),
Prefix,
Nothing,
}
/// Terminals report SHIFT alongside symbol keys ('?', '+', ...) inconsistently.
/// Letters (of any alphabet) keep SHIFT (that is how "shift-g" works);
/// symbols drop it so a "?" binding matches everywhere.
fn normalize(key: KeyCombination) -> KeyCombination {
if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes {
if !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) {
return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT);
}
}
key
}
/// The Latin character on the same physical key in the standard ЙЦУКЕН
/// layout (lowercase in, lowercase out).
fn qwerty_equivalent(c: char) -> Option<char> {
Some(match c {
'й' => 'q', 'ц' => 'w', 'у' => 'e', 'к' => 'r', 'е' => 't',
'н' => 'y', 'г' => 'u', 'ш' => 'i', 'щ' => 'o', 'з' => 'p',
'х' => '[', 'ъ' => ']',
'ф' => 'a', 'ы' => 's', 'в' => 'd', 'а' => 'f', 'п' => 'g',
'р' => 'h', 'о' => 'j', 'л' => 'k', 'д' => 'l', 'ж' => ';',
'э' => '\'',
'я' => 'z', 'ч' => 'x', 'с' => 'c', 'м' => 'v', 'и' => 'b',
'т' => 'n', 'ь' => 'm', 'б' => ',', 'ю' => '.',
'ё' => '`',
_ => return None,
})
}
pub fn user_keymap_path() -> Option<PathBuf> {
crate::config::project_dirs().map(|dirs| dirs.config_dir().join("keymap.toml"))
}
fn parse_bindings(text: &str) -> Result<Vec<Binding>> {
let file: KeymapFile = toml::from_str(text).context("invalid TOML")?;
file.keymaps
.into_iter()
.map(|raw| {
let keys = parse_sequence(&raw.key_sequence)
.with_context(|| format!("bad key_sequence {:?}", raw.key_sequence))?;
Ok(Binding {
keys,
action: raw.command,
context: raw.context,
})
})
.collect()
}
fn parse_sequence(s: &str) -> Result<Vec<KeyCombination>> {
let keys: Vec<KeyCombination> = s
.split_whitespace()
.map(parse_chord)
.collect::<Result<_>>()?;
if keys.is_empty() {
bail!("empty key sequence");
}
Ok(keys)
}
fn parse_chord(chord: &str) -> Result<KeyCombination> {
// crokey only parses single-byte characters; non-ASCII keys (Cyrillic
// bindings) are built directly.
let mut chars = chord.chars();
if let (Some(c), None) = (chars.next(), chars.next()) {
if !c.is_ascii() {
let modifiers = if c.is_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
return Ok(KeyCombination::new(KeyCode::Char(c), modifiers));
}
}
KeyCombination::from_str(chord)
.map_err(|e| anyhow::anyhow!("{e}"))
.map(normalize)
}
fn merge(bindings: &mut Vec<Binding>, user: Vec<Binding>) {
for b in user {
bindings.retain(|d| !(d.keys == b.keys && d.context == b.context));
bindings.push(b);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crokey::key;
fn keymap_from(toml: &str) -> Keymap {
Keymap {
bindings: parse_bindings(toml).unwrap(),
pending: Vec::new(),
format: KeyCombinationFormat::default(),
}
}
#[test]
fn default_keymap_parses() {
let bindings = parse_bindings(DEFAULT_KEYMAP).unwrap();
assert!(bindings.len() > 20);
}
#[test]
fn single_key_resolves() {
let mut km = keymap_from(DEFAULT_KEYMAP);
assert_eq!(
km.resolve(key!(q), KeyContext::Library),
KeyResolution::Action(Action::Quit)
);
}
#[test]
fn chord_sequence_resolves() {
let mut km = keymap_from(DEFAULT_KEYMAP);
assert!(matches!(
km.resolve(key!(g), KeyContext::Library),
KeyResolution::Pending(_)
));
assert_eq!(
km.resolve(key!(g), KeyContext::Library),
KeyResolution::Action(Action::SelectFirst)
);
}
#[test]
fn aborted_chord_retries_last_key() {
let mut km = keymap_from(DEFAULT_KEYMAP);
km.resolve(key!(g), KeyContext::Library);
assert_eq!(
km.resolve(key!(q), KeyContext::Library),
KeyResolution::Action(Action::Quit)
);
}
#[test]
fn esc_clears_pending_chord() {
let mut km = keymap_from(DEFAULT_KEYMAP);
km.resolve(key!(g), KeyContext::Library);
assert_eq!(
km.resolve(key!(esc), KeyContext::Library),
KeyResolution::Unmatched
);
// Esc with no pending chord is a normal binding (Back).
assert_eq!(
km.resolve(key!(esc), KeyContext::Library),
KeyResolution::Action(Action::Back)
);
}
#[test]
fn context_binding_shadows_global() {
let mut km = keymap_from(
r#"
[[keymaps]]
key_sequence = "n"
command = "NextTrack"
[[keymaps]]
key_sequence = "n"
command = "MoveDown"
context = "search"
"#,
);
assert_eq!(
km.resolve(key!(n), KeyContext::Search),
KeyResolution::Action(Action::MoveDown)
);
assert_eq!(
km.resolve(key!(n), KeyContext::Library),
KeyResolution::Action(Action::NextTrack)
);
}
#[test]
fn user_binding_overrides_default() {
let mut bindings = parse_bindings(DEFAULT_KEYMAP).unwrap();
let user = parse_bindings(
r#"
[[keymaps]]
key_sequence = "q"
command = "Back"
"#,
)
.unwrap();
merge(&mut bindings, user);
let mut km = Keymap {
bindings,
pending: Vec::new(),
format: KeyCombinationFormat::default(),
};
assert_eq!(
km.resolve(key!(q), KeyContext::Library),
KeyResolution::Action(Action::Back)
);
}
#[test]
fn shift_symbol_normalizes() {
let mut km = keymap_from(DEFAULT_KEYMAP);
let question_with_shift =
KeyCombination::new(KeyCode::Char('?'), KeyModifiers::SHIFT);
assert_eq!(
km.resolve(question_with_shift, KeyContext::Library),
KeyResolution::Action(Action::ToggleHelp)
);
}
#[test]
fn russian_layout_maps_to_physical_keys() {
let mut km = keymap_from(DEFAULT_KEYMAP);
// physical J → 'о' in ЙЦУКЕН
let o = KeyCombination::new(KeyCode::Char('о'), KeyModifiers::NONE);
assert_eq!(
km.resolve(o, KeyContext::Library),
KeyResolution::Action(Action::MoveDown)
);
// physical Shift+G → 'П'
let cap_pe = KeyCombination::new(KeyCode::Char('П'), KeyModifiers::SHIFT);
assert_eq!(
km.resolve(cap_pe, KeyContext::Library),
KeyResolution::Action(Action::SelectLast)
);
// chord: 'п п' = physical "g g"
let pe = KeyCombination::new(KeyCode::Char('п'), KeyModifiers::NONE);
assert!(matches!(
km.resolve(pe, KeyContext::Library),
KeyResolution::Pending(_)
));
assert_eq!(
km.resolve(pe, KeyContext::Library),
KeyResolution::Action(Action::SelectFirst)
);
// punctuation positions: 'ю' sits on the '.' key (SeekForward)
let yu = KeyCombination::new(KeyCode::Char('ю'), KeyModifiers::NONE);
assert_eq!(
km.resolve(yu, KeyContext::Library),
KeyResolution::Action(Action::SeekForward { seconds: 10 })
);
}
#[test]
fn explicit_cyrillic_binding_wins_over_layout_fallback() {
let mut km = keymap_from(
r#"
[[keymaps]]
key_sequence = "о"
command = "Quit"
"#,
);
let o = KeyCombination::new(KeyCode::Char('о'), KeyModifiers::NONE);
assert_eq!(
km.resolve(o, KeyContext::Library),
KeyResolution::Action(Action::Quit)
);
}
#[test]
fn parameterized_command_parses() {
let mut km = keymap_from(DEFAULT_KEYMAP);
let dot: KeyCombination = ".".parse().unwrap();
assert_eq!(
km.resolve(dot, KeyContext::Library),
KeyResolution::Action(Action::SeekForward { seconds: 10 })
);
}
}
+172
View File
@@ -0,0 +1,172 @@
use std::collections::VecDeque;
use std::fs;
use std::sync::{Arc, Mutex, OnceLock};
use anyhow::{Context as _, Result};
use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::prelude::*;
/// Ring-buffer capacity for the in-app Logs tab. Bounded so a player left
/// running for days cannot grow memory; ~10k entries ≈ a few MB worst case.
pub const LOG_CAPACITY: usize = 10_000;
#[derive(Debug, Clone)]
pub struct LogEntry {
pub level: tracing::Level,
/// HH:MM:SS, UTC (same clock as the log file).
pub time: String,
pub target: String,
pub message: String,
}
#[derive(Default)]
pub struct LogBuffer {
entries: Mutex<VecDeque<LogEntry>>,
}
impl LogBuffer {
fn push(&self, entry: LogEntry) {
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if entries.len() == LOG_CAPACITY {
entries.pop_front();
}
entries.push_back(entry);
}
pub fn len(&self) -> usize {
self.entries.lock().unwrap_or_else(|e| e.into_inner()).len()
}
/// Window for the UI, newest-last: entries at most `max_level` verbose,
/// skipping `skip` newest matches and returning up to `take`. Also
/// returns the total number of matching entries (for scroll clamping).
/// Only the visible window is cloned, so rendering stays O(buffer scan)
/// with cheap comparisons even at full capacity.
pub fn window(
&self,
max_level: tracing::Level,
skip: usize,
take: usize,
) -> (Vec<LogEntry>, usize) {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let mut matched = 0usize;
let mut out = Vec::with_capacity(take);
for entry in entries.iter().rev() {
if entry.level > max_level {
continue;
}
matched += 1;
if matched > skip && out.len() < take {
out.push(entry.clone());
}
}
out.reverse();
(out, matched)
}
}
static BUFFER: OnceLock<Arc<LogBuffer>> = OnceLock::new();
pub fn buffer() -> Option<Arc<LogBuffer>> {
BUFFER.get().cloned()
}
struct MemoryLayer {
buffer: Arc<LogBuffer>,
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for MemoryLayer {
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
let mut message = String::new();
event.record(&mut MessageVisitor { out: &mut message });
let metadata = event.metadata();
self.buffer.push(LogEntry {
level: *metadata.level(),
time: hms_now(),
target: metadata.target().to_string(),
message,
});
}
}
struct MessageVisitor<'a> {
out: &'a mut String,
}
impl tracing::field::Visit for MessageVisitor<'_> {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
use std::fmt::Write as _;
if field.name() == "message" {
let _ = write!(self.out, "{value:?}");
} else {
let _ = write!(self.out, " {}={:?}", field.name(), value);
}
}
}
fn hms_now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!(
"{:02}:{:02}:{:02}",
(secs / 3600) % 24,
(secs / 60) % 60,
secs % 60
)
}
/// Two sinks: the log file (filtered by RUST_LOG, default info) and the
/// in-app ring buffer for the Logs tab (our crate down to TRACE, noisy
/// dependencies capped at INFO). The buffer works even when the file can't
/// be opened — the error is returned for the status bar, logging still runs.
pub fn init() -> Result<()> {
let buffer = Arc::new(LogBuffer::default());
let _ = BUFFER.set(Arc::clone(&buffer));
let memory_layer = MemoryLayer { buffer }.with_filter(
tracing_subscriber::filter::Targets::new()
.with_default(LevelFilter::INFO)
.with_target("furumi_cli", LevelFilter::TRACE),
);
match open_log_file() {
Ok(file) => {
let file = Arc::new(file);
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let file_layer = tracing_subscriber::fmt::layer()
.with_writer(move || Arc::clone(&file))
.with_ansi(false)
.with_filter(filter);
tracing_subscriber::registry()
.with(file_layer)
.with(memory_layer)
.init();
tracing::info!(version = env!("CARGO_PKG_VERSION"), "furumi-cli starting");
Ok(())
}
Err(err) => {
tracing_subscriber::registry().with(memory_layer).init();
tracing::warn!(%err, "log file unavailable, in-app logs only");
Err(err)
}
}
}
fn open_log_file() -> Result<fs::File> {
let dirs = crate::config::project_dirs().context("cannot determine home directory")?;
let dir = dirs.cache_dir();
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let path = dir.join("furumi-cli.log");
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("opening {}", path.display()))
}
+8
View File
@@ -0,0 +1,8 @@
pub mod keymap;
pub mod logging;
use directories::ProjectDirs;
pub fn project_dirs() -> Option<ProjectDirs> {
ProjectDirs::from("", "", "furumi")
}