Added connected devices. Improved logging. UI fixes
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user