Added connected devices. Improved logging. UI fixes

This commit is contained in:
Ultradesu
2026-06-10 23:30:03 +01:00
parent bcee68eb4e
commit 02a396c146
25 changed files with 2540 additions and 314 deletions
+17
View File
@@ -64,6 +64,15 @@ context = "queue"
key_sequence = "shift-j"
command = "GoToRelease"
[[keymaps]]
key_sequence = "shift-p"
command = "AddToPlaylist"
[[keymaps]]
key_sequence = "n"
command = "NewPlaylist"
context = "playlists"
[[keymaps]]
key_sequence = "j"
command = "MoveDown"
@@ -180,6 +189,10 @@ command = "ToggleLike"
key_sequence = "shift-l"
command = "Logout"
[[keymaps]]
key_sequence = "shift-d"
command = "OpenDevices"
[[keymaps]]
key_sequence = "v"
command = "ToggleViewMode"
@@ -187,3 +200,7 @@ command = "ToggleViewMode"
[[keymaps]]
key_sequence = ":"
command = "OpenCommandLine"
[[keymaps]]
key_sequence = "/"
command = "OpenSearch"
+35 -12
View File
@@ -133,11 +133,11 @@ impl Keymap {
}
}
/// All bindings as (keys, description, context) for the help view.
pub fn help_entries(&self) -> Vec<(String, String, KeyContext)> {
/// All bindings as (formatted keys, action, context) for the help view.
pub fn help_entries(&self) -> Vec<(String, Action, KeyContext)> {
self.bindings
.iter()
.map(|b| (self.format_keys(&b.keys), b.action.describe(), b.context))
.map(|b| (self.format_keys(&b.keys), b.action.clone(), b.context))
.collect()
}
@@ -231,14 +231,38 @@ fn normalize(key: KeyCombination) -> KeyCombination {
/// 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', ' => ';',
'й' => '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', 'б' => ',', ' => '.',
'я' => 'z',
'ч' => 'x',
'с' => 'c',
'м' => 'v',
'и' => 'b',
'т' => 'n',
'ь' => 'm',
'б' => ',',
'ю' => '.',
'ё' => '`',
_ => return None,
})
@@ -417,8 +441,7 @@ mod tests {
#[test]
fn shift_symbol_normalizes() {
let mut km = keymap_from(DEFAULT_KEYMAP);
let question_with_shift =
KeyCombination::new(KeyCode::Char('?'), KeyModifiers::SHIFT);
let question_with_shift = KeyCombination::new(KeyCode::Char('?'), KeyModifiers::SHIFT);
assert_eq!(
km.resolve(question_with_shift, KeyContext::Library),
KeyResolution::Action(Action::ToggleHelp)
+89 -27
View File
@@ -13,6 +13,9 @@ pub const LOG_CAPACITY: usize = 10_000;
#[derive(Debug, Clone)]
pub struct LogEntry {
/// Monotonic id; the Logs-tab cursor anchors to it, so appends never
/// move the selection.
pub seq: u64,
pub level: tracing::Level,
/// HH:MM:SS, UTC (same clock as the log file).
pub time: String,
@@ -20,13 +23,29 @@ pub struct LogEntry {
pub message: String,
}
/// What the Logs tab renders: a window of entries around the cursor.
pub struct LogView {
/// Oldest-first window of entries.
pub entries: Vec<LogEntry>,
/// Row index of the cursor within `entries`.
pub cursor_row: Option<usize>,
/// Total entries matching the level filter.
pub matched: usize,
/// How far the cursor is from the newest matching entry.
pub from_end: usize,
}
#[derive(Default)]
pub struct LogBuffer {
entries: Mutex<VecDeque<LogEntry>>,
next_seq: std::sync::atomic::AtomicU64,
}
impl LogBuffer {
fn push(&self, entry: LogEntry) {
fn push(&self, mut entry: LogEntry) {
entry.seq = self
.next_seq
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if entries.len() == LOG_CAPACITY {
entries.pop_front();
@@ -34,35 +53,75 @@ impl LogBuffer {
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(
/// Move the cursor `delta` steps among entries matching the filter
/// (negative = older). `current = None` starts from the newest. Returns
/// the new anchor and whether it is the newest matching entry.
pub fn move_selection(
&self,
max_level: tracing::Level,
skip: usize,
take: usize,
) -> (Vec<LogEntry>, usize) {
current: Option<u64>,
delta: isize,
) -> Option<(u64, bool)> {
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());
}
let seqs: Vec<u64> = entries
.iter()
.filter(|e| e.level <= max_level)
.map(|e| e.seq)
.collect();
if seqs.is_empty() {
return None;
}
let index = current
.and_then(|seq| seqs.iter().position(|s| *s == seq))
.unwrap_or(seqs.len() - 1);
let new = (index as i128 + delta as i128).clamp(0, seqs.len() as i128 - 1) as usize;
Some((seqs[new], new == seqs.len() - 1))
}
/// The anchored entry (or the newest matching one when `None`).
pub fn entry_at(&self, max_level: tracing::Level, selected: Option<u64>) -> Option<LogEntry> {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
match selected {
Some(seq) => entries
.iter()
.find(|e| e.seq == seq && e.level <= max_level)
.cloned(),
None => entries.iter().rev().find(|e| e.level <= max_level).cloned(),
}
}
/// Window of up to `visible` entries with the cursor kept centered.
/// Only the window is cloned; the scan is cheap level comparisons.
pub fn view(
&self,
max_level: tracing::Level,
selected: Option<u64>,
visible: usize,
) -> LogView {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let matched: Vec<&LogEntry> = entries.iter().filter(|e| e.level <= max_level).collect();
let total = matched.len();
if total == 0 || visible == 0 {
return LogView {
entries: Vec::new(),
cursor_row: None,
matched: total,
from_end: 0,
};
}
let cursor_index = selected
.and_then(|seq| matched.iter().position(|e| e.seq == seq))
.unwrap_or(total - 1);
let start = cursor_index
.saturating_sub(visible / 2)
.min(total.saturating_sub(visible));
let end = (start + visible).min(total);
LogView {
entries: matched[start..end].iter().map(|e| (*e).clone()).collect(),
cursor_row: Some(cursor_index - start),
matched: total,
from_end: total - 1 - cursor_index,
}
out.reverse();
(out, matched)
}
}
@@ -86,6 +145,7 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for MemoryLayer {
event.record(&mut MessageVisitor { out: &mut message });
let metadata = event.metadata();
self.buffer.push(LogEntry {
seq: 0, // assigned in push()
level: *metadata.level(),
time: hms_now(),
target: metadata.target().to_string(),
@@ -158,7 +218,9 @@ pub fn init() -> Result<()> {
Ok(())
}
Err(err) => {
tracing_subscriber::registry().with(memory_layer(buffer)).init();
tracing_subscriber::registry()
.with(memory_layer(buffer))
.init();
tracing::warn!(%err, "log file unavailable, in-app logs only");
Err(err)
}
+47
View File
@@ -2,7 +2,54 @@ pub mod keymap;
pub mod logging;
use directories::ProjectDirs;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
pub fn project_dirs() -> Option<ProjectDirs> {
ProjectDirs::from("", "", "furumi")
}
pub fn device_id_path() -> Option<PathBuf> {
project_dirs().map(|dirs| dirs.config_dir().join("device_id"))
}
pub fn load_or_create_device_id() -> String {
if let Some(path) = device_id_path() {
if let Ok(raw) = fs::read_to_string(&path) {
let id = raw.trim();
if valid_device_id(id) {
return id.to_string();
}
}
let id = generate_device_id();
if let Some(parent) = path.parent() {
if let Err(err) = fs::create_dir_all(parent) {
tracing::warn!(path = %parent.display(), %err, "failed to create config directory");
return id;
}
}
if let Err(err) = fs::write(&path, &id) {
tracing::warn!(path = %path.display(), %err, "failed to persist device id");
}
return id;
}
generate_device_id()
}
fn valid_device_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
}
fn generate_device_id() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("tui-{nanos:x}-{:x}", std::process::id())
}