Added logs. MPRIS, UI changes
This commit is contained in:
Generated
+777
-51
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -1,8 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumi_cli"
|
name = "furumi_tui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "furumi"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
crokey = "1.4.0"
|
crokey = "1.4.0"
|
||||||
@@ -16,7 +20,7 @@ reqwest = { version = "0.13.4", default-features = false, features = ["json", "r
|
|||||||
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
|
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.150"
|
serde_json = "1.0.150"
|
||||||
souvlaki = "0.8.3"
|
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
|
||||||
stream-download = { version = "0.24.1", default-features = false, features = ["reqwest-rustls", "temp-storage"] }
|
stream-download = { version = "0.24.1", default-features = false, features = ["reqwest-rustls", "temp-storage"] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||||
|
|||||||
+18
-4
@@ -24,7 +24,7 @@ pub enum ApiError {
|
|||||||
pub fn http_client() -> reqwest::Client {
|
pub fn http_client() -> reqwest::Client {
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.user_agent(format!(
|
.user_agent(format!(
|
||||||
"furumi-cli/{} ({})",
|
"furumi-tui/{} ({})",
|
||||||
env!("CARGO_PKG_VERSION"),
|
env!("CARGO_PKG_VERSION"),
|
||||||
std::env::consts::OS
|
std::env::consts::OS
|
||||||
))
|
))
|
||||||
@@ -35,7 +35,7 @@ pub fn http_client() -> reqwest::Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn device_name() -> String {
|
pub fn device_name() -> String {
|
||||||
format!("furumi-cli ({})", std::env::consts::OS)
|
format!("furumi-tui ({})", std::env::consts::OS)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -395,8 +395,22 @@ impl ApiClient {
|
|||||||
}
|
}
|
||||||
request
|
request
|
||||||
})
|
})
|
||||||
.await?;
|
.await;
|
||||||
parse_response(response).await
|
let response = match response {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(%err, %method, path, "api request failed");
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let status = response.status();
|
||||||
|
let result = parse_response(response).await;
|
||||||
|
if let Err(err) = &result {
|
||||||
|
tracing::warn!(%err, %status, %method, path, "api response error");
|
||||||
|
} else {
|
||||||
|
tracing::debug!(%status, %method, path, "api ok");
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a request with a fresh bearer token; on 401, refresh once and
|
/// Send a request with a fresh bearer token; on 401, refresh once and
|
||||||
|
|||||||
+5
-1
@@ -180,11 +180,15 @@ pub fn spawn_sso_exchange(form: &mut LoginForm, runtime: &Runtime, code: String)
|
|||||||
fn login_event(result: Result<auth::AuthSession, client::ApiError>) -> AppEvent {
|
fn login_event(result: Result<auth::AuthSession, client::ApiError>) -> AppEvent {
|
||||||
match result {
|
match result {
|
||||||
Ok(session) => {
|
Ok(session) => {
|
||||||
|
tracing::info!(user = %session.user.name, server = %session.server_base_url, "signed in");
|
||||||
if let Err(err) = auth::save_session(&session) {
|
if let Err(err) = auth::save_session(&session) {
|
||||||
tracing::warn!(%err, "failed to persist credentials");
|
tracing::warn!(%err, "failed to persist credentials");
|
||||||
}
|
}
|
||||||
AppEvent::LoginSucceeded(Box::new(session))
|
AppEvent::LoginSucceeded(Box::new(session))
|
||||||
}
|
}
|
||||||
Err(err) => AppEvent::LoginFailed(err.to_string()),
|
Err(err) => {
|
||||||
|
tracing::warn!(%err, "login failed");
|
||||||
|
AppEvent::LoginFailed(err.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -702,6 +702,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
global.artists.extend(page.items);
|
global.artists.extend(page.items);
|
||||||
}
|
}
|
||||||
AppEvent::ArtistsLoaded(Err(message)) => {
|
AppEvent::ArtistsLoaded(Err(message)) => {
|
||||||
|
tracing::warn!(%message, "artists page load failed");
|
||||||
state.global.loading = false;
|
state.global.loading = false;
|
||||||
state.global.error = Some(message.clone());
|
state.global.error = Some(message.clone());
|
||||||
state.status_message = Some(message);
|
state.status_message = Some(message);
|
||||||
@@ -709,14 +710,20 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
AppEvent::ArtistViewLoaded { id, result } => {
|
AppEvent::ArtistViewLoaded { id, result } => {
|
||||||
let entry = match result {
|
let entry = match result {
|
||||||
Ok(detail) => state::Loadable::Ready(detail),
|
Ok(detail) => state::Loadable::Ready(detail),
|
||||||
Err(message) => state::Loadable::Failed(message),
|
Err(message) => {
|
||||||
|
tracing::warn!(artist = id, %message, "artist view load failed");
|
||||||
|
state::Loadable::Failed(message)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
state.artist_views.insert(id, entry);
|
state.artist_views.insert(id, entry);
|
||||||
}
|
}
|
||||||
AppEvent::ReleaseViewLoaded { id, result } => {
|
AppEvent::ReleaseViewLoaded { id, result } => {
|
||||||
let entry = match result {
|
let entry = match result {
|
||||||
Ok(detail) => state::Loadable::Ready(detail),
|
Ok(detail) => state::Loadable::Ready(detail),
|
||||||
Err(message) => state::Loadable::Failed(message),
|
Err(message) => {
|
||||||
|
tracing::warn!(release = id, %message, "release view load failed");
|
||||||
|
state::Loadable::Failed(message)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
state.release_views.insert(id, entry);
|
state.release_views.insert(id, entry);
|
||||||
}
|
}
|
||||||
@@ -770,6 +777,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
push_state_now(state, runtime);
|
push_state_now(state, runtime);
|
||||||
}
|
}
|
||||||
AppEvent::Player(player::PlayerEvent::Failed(message)) => {
|
AppEvent::Player(player::PlayerEvent::Failed(message)) => {
|
||||||
|
tracing::error!(%message, "playback failed");
|
||||||
state.player.playing = false;
|
state.player.playing = false;
|
||||||
state.player.paused = false;
|
state.player.paused = false;
|
||||||
state.status_message = Some(message);
|
state.status_message = Some(message);
|
||||||
|
|||||||
+41
-1
@@ -167,6 +167,36 @@ pub struct PlaylistsTab {
|
|||||||
pub opened: Option<OpenedPlaylist>,
|
pub opened: Option<OpenedPlaylist>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Severity steps for the Logs tab filter, cycled with the view-toggle key.
|
||||||
|
pub const LOG_LEVELS: [tracing::Level; 5] = [
|
||||||
|
tracing::Level::ERROR,
|
||||||
|
tracing::Level::WARN,
|
||||||
|
tracing::Level::INFO,
|
||||||
|
tracing::Level::DEBUG,
|
||||||
|
tracing::Level::TRACE,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The Logs tab: a live view over the in-memory ring buffer.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LogsTab {
|
||||||
|
/// Index into LOG_LEVELS; entries more verbose than this are hidden.
|
||||||
|
pub level_index: usize,
|
||||||
|
/// Stick to the newest entries as they arrive.
|
||||||
|
pub follow: bool,
|
||||||
|
/// When not following: how many (filtered) entries back from the end.
|
||||||
|
pub scroll_from_end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LogsTab {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
level_index: 2,
|
||||||
|
follow: true,
|
||||||
|
scroll_from_end: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Command line (`:`), vim-style. Lives on the Main screen status bar.
|
/// Command line (`:`), vim-style. Lives on the Main screen status bar.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct Cmdline {
|
pub struct Cmdline {
|
||||||
@@ -271,10 +301,17 @@ pub enum Tab {
|
|||||||
Playlists,
|
Playlists,
|
||||||
Queue,
|
Queue,
|
||||||
Devices,
|
Devices,
|
||||||
|
Logs,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tab {
|
impl Tab {
|
||||||
pub const ALL: [Tab; 4] = [Tab::Global, Tab::Playlists, Tab::Queue, Tab::Devices];
|
pub const ALL: [Tab; 5] = [
|
||||||
|
Tab::Global,
|
||||||
|
Tab::Playlists,
|
||||||
|
Tab::Queue,
|
||||||
|
Tab::Devices,
|
||||||
|
Tab::Logs,
|
||||||
|
];
|
||||||
|
|
||||||
pub fn title(self) -> &'static str {
|
pub fn title(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -282,6 +319,7 @@ impl Tab {
|
|||||||
Tab::Playlists => "Playlists",
|
Tab::Playlists => "Playlists",
|
||||||
Tab::Queue => "Queue",
|
Tab::Queue => "Queue",
|
||||||
Tab::Devices => "Devices",
|
Tab::Devices => "Devices",
|
||||||
|
Tab::Logs => "Logs",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +345,7 @@ impl Tab {
|
|||||||
Tab::Playlists => KeyContext::Playlists,
|
Tab::Playlists => KeyContext::Playlists,
|
||||||
Tab::Queue => KeyContext::Queue,
|
Tab::Queue => KeyContext::Queue,
|
||||||
Tab::Devices => KeyContext::Devices,
|
Tab::Devices => KeyContext::Devices,
|
||||||
|
Tab::Logs => KeyContext::Logs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,6 +438,7 @@ pub struct AppState {
|
|||||||
/// Liked track ids, for the ♥ markers everywhere tracks are shown.
|
/// Liked track ids, for the ♥ markers everywhere tracks are shown.
|
||||||
pub likes: std::collections::HashSet<i64>,
|
pub likes: std::collections::HashSet<i64>,
|
||||||
pub likes_loaded: bool,
|
pub likes_loaded: bool,
|
||||||
|
pub logs: LogsTab,
|
||||||
pub cmdline: Cmdline,
|
pub cmdline: Cmdline,
|
||||||
pub search: SearchState,
|
pub search: SearchState,
|
||||||
/// Shared image cache keyed by `art::cache_key(url, w, h)`; reused by
|
/// Shared image cache keyed by `art::cache_key(url, w, h)`; reused by
|
||||||
|
|||||||
+43
-4
@@ -142,8 +142,16 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
Action::ToggleViewMode => {
|
Action::ToggleViewMode => {
|
||||||
if state.active_tab == Tab::Global {
|
match state.active_tab {
|
||||||
state.global.view = state.global.view.toggle();
|
Tab::Global => state.global.view = state.global.view.toggle(),
|
||||||
|
// On the Logs tab the same key cycles the severity filter.
|
||||||
|
Tab::Logs => {
|
||||||
|
state.logs.level_index =
|
||||||
|
(state.logs.level_index + 1) % super::state::LOG_LEVELS.len();
|
||||||
|
state.logs.scroll_from_end = 0;
|
||||||
|
state.logs.follow = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -204,7 +212,7 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
|||||||
.queue
|
.queue
|
||||||
.get(state.player.queue_pos)
|
.get(state.player.queue_pos)
|
||||||
.cloned(),
|
.cloned(),
|
||||||
Tab::Devices => None,
|
Tab::Devices | Tab::Logs => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +405,9 @@ fn viewport_lines() -> isize {
|
|||||||
/// lines.
|
/// lines.
|
||||||
fn page_step(state: &AppState) -> isize {
|
fn page_step(state: &AppState) -> isize {
|
||||||
let lines = viewport_lines();
|
let lines = viewport_lines();
|
||||||
|
if state.active_tab != Tab::Global {
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
let tile_rows = (lines / TILE_HEIGHT as isize).max(1);
|
let tile_rows = (lines / TILE_HEIGHT as isize).max(1);
|
||||||
match state.global.stack.last() {
|
match state.global.stack.last() {
|
||||||
None => match state.global.view {
|
None => match state.global.view {
|
||||||
@@ -419,6 +430,20 @@ fn page_step(state: &AppState) -> isize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
||||||
|
if state.active_tab == Tab::Logs {
|
||||||
|
let total = crate::config::logging::buffer().map_or(0, |b| b.len());
|
||||||
|
let logs = &mut state.logs;
|
||||||
|
if dy < 0 {
|
||||||
|
logs.follow = false;
|
||||||
|
logs.scroll_from_end = (logs.scroll_from_end + dy.unsigned_abs()).min(total);
|
||||||
|
} else if dy > 0 {
|
||||||
|
logs.scroll_from_end = logs.scroll_from_end.saturating_sub(dy as usize);
|
||||||
|
if logs.scroll_from_end == 0 {
|
||||||
|
logs.follow = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if state.active_tab == Tab::Playlists {
|
if state.active_tab == Tab::Playlists {
|
||||||
let len = playlists_view_len(state);
|
let len = playlists_view_len(state);
|
||||||
if len == 0 {
|
if len == 0 {
|
||||||
@@ -565,6 +590,16 @@ fn current_view_len(state: &AppState) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn jump_selection(state: &mut AppState, first: bool) {
|
fn jump_selection(state: &mut AppState, first: bool) {
|
||||||
|
if state.active_tab == Tab::Logs {
|
||||||
|
if first {
|
||||||
|
state.logs.follow = false;
|
||||||
|
state.logs.scroll_from_end = crate::config::logging::buffer().map_or(0, |b| b.len());
|
||||||
|
} else {
|
||||||
|
state.logs.follow = true;
|
||||||
|
state.logs.scroll_from_end = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if state.active_tab != Tab::Global && state.active_tab != Tab::Playlists {
|
if state.active_tab != Tab::Global && state.active_tab != Tab::Playlists {
|
||||||
return not_yet(state, "Navigation in this view");
|
return not_yet(state, "Navigation in this view");
|
||||||
}
|
}
|
||||||
@@ -737,6 +772,10 @@ fn reset_tab(state: &mut AppState, tab: Tab) {
|
|||||||
state.global.stack.clear();
|
state.global.stack.clear();
|
||||||
}
|
}
|
||||||
Tab::Playlists => state.playlists.opened = None,
|
Tab::Playlists => state.playlists.opened = None,
|
||||||
|
Tab::Logs => {
|
||||||
|
state.logs.follow = true;
|
||||||
|
state.logs.scroll_from_end = 0;
|
||||||
|
}
|
||||||
Tab::Queue | Tab::Devices => {}
|
Tab::Queue | Tab::Devices => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -797,7 +836,7 @@ mod tests {
|
|||||||
fn tab_cycling_wraps() {
|
fn tab_cycling_wraps() {
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
update(&mut state, Action::PrevTab);
|
update(&mut state, Action::PrevTab);
|
||||||
assert_eq!(state.active_tab, Tab::Devices);
|
assert_eq!(state.active_tab, Tab::Logs);
|
||||||
update(&mut state, Action::NextTab);
|
update(&mut state, Action::NextTab);
|
||||||
assert_eq!(state.active_tab, Tab::Global);
|
assert_eq!(state.active_tab, Tab::Global);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Default keybindings for furumi-cli.
|
# Default keybindings for furumi.
|
||||||
#
|
#
|
||||||
# To customize, copy entries into <config dir>/furumi/keymap.toml
|
# To customize, copy entries into <config dir>/furumi/keymap.toml
|
||||||
# (~/.config/furumi/keymap.toml on Linux/macOS). A user binding replaces the
|
# (~/.config/furumi/keymap.toml on Linux/macOS). A user binding replaces the
|
||||||
@@ -47,6 +47,10 @@ command = { GoToTab = 2 }
|
|||||||
key_sequence = "4"
|
key_sequence = "4"
|
||||||
command = { GoToTab = 3 }
|
command = { GoToTab = 3 }
|
||||||
|
|
||||||
|
[[keymaps]]
|
||||||
|
key_sequence = "5"
|
||||||
|
command = { GoToTab = 4 }
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = "a"
|
key_sequence = "a"
|
||||||
command = "QueueAddNext"
|
command = "QueueAddNext"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub enum KeyContext {
|
|||||||
Playlists,
|
Playlists,
|
||||||
Queue,
|
Queue,
|
||||||
Devices,
|
Devices,
|
||||||
|
Logs,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeyContext {
|
impl KeyContext {
|
||||||
@@ -32,6 +33,7 @@ impl KeyContext {
|
|||||||
KeyContext::Playlists => "playlists",
|
KeyContext::Playlists => "playlists",
|
||||||
KeyContext::Queue => "queue",
|
KeyContext::Queue => "queue",
|
||||||
KeyContext::Devices => "devices",
|
KeyContext::Devices => "devices",
|
||||||
|
KeyContext::Logs => "logs",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-8
@@ -129,11 +129,17 @@ fn hms_now() -> String {
|
|||||||
pub fn init() -> Result<()> {
|
pub fn init() -> Result<()> {
|
||||||
let buffer = Arc::new(LogBuffer::default());
|
let buffer = Arc::new(LogBuffer::default());
|
||||||
let _ = BUFFER.set(Arc::clone(&buffer));
|
let _ = BUFFER.set(Arc::clone(&buffer));
|
||||||
let memory_layer = MemoryLayer { buffer }.with_filter(
|
|
||||||
tracing_subscriber::filter::Targets::new()
|
fn memory_layer<S>(buffer: Arc<LogBuffer>) -> impl tracing_subscriber::Layer<S>
|
||||||
.with_default(LevelFilter::INFO)
|
where
|
||||||
.with_target("furumi_cli", LevelFilter::TRACE),
|
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
|
||||||
);
|
{
|
||||||
|
MemoryLayer { buffer }.with_filter(
|
||||||
|
tracing_subscriber::filter::Targets::new()
|
||||||
|
.with_default(LevelFilter::INFO)
|
||||||
|
.with_target(env!("CARGO_CRATE_NAME"), LevelFilter::TRACE),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
match open_log_file() {
|
match open_log_file() {
|
||||||
Ok(file) => {
|
Ok(file) => {
|
||||||
@@ -146,13 +152,13 @@ pub fn init() -> Result<()> {
|
|||||||
.with_filter(filter);
|
.with_filter(filter);
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(file_layer)
|
.with(file_layer)
|
||||||
.with(memory_layer)
|
.with(memory_layer(buffer))
|
||||||
.init();
|
.init();
|
||||||
tracing::info!(version = env!("CARGO_PKG_VERSION"), "furumi-cli starting");
|
tracing::info!(version = env!("CARGO_PKG_VERSION"), "furumi starting");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing_subscriber::registry().with(memory_layer).init();
|
tracing_subscriber::registry().with(memory_layer(buffer)).init();
|
||||||
tracing::warn!(%err, "log file unavailable, in-app logs only");
|
tracing::warn!(%err, "log file unavailable, in-app logs only");
|
||||||
Err(err)
|
Err(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -88,7 +88,7 @@ pub fn run_on_main_thread(
|
|||||||
fn create_controls() -> Option<MediaControls> {
|
fn create_controls() -> Option<MediaControls> {
|
||||||
let config = PlatformConfig {
|
let config = PlatformConfig {
|
||||||
display_name: "Furumi",
|
display_name: "Furumi",
|
||||||
dbus_name: "cy.hexor.furumi_cli",
|
dbus_name: "cy.hexor.furumi",
|
||||||
hwnd: None,
|
hwnd: None,
|
||||||
};
|
};
|
||||||
if cfg!(windows) {
|
if cfg!(windows) {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Alignment, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph};
|
||||||
|
|
||||||
|
use super::theme;
|
||||||
|
use crate::app::state::{AppState, LOG_LEVELS};
|
||||||
|
use crate::config::logging;
|
||||||
|
|
||||||
|
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
let logs = &state.logs;
|
||||||
|
let level = LOG_LEVELS[logs.level_index];
|
||||||
|
let mode = if logs.follow { "follow" } else { "scroll" };
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(format!(" Logs — {level}+ · {mode} "))
|
||||||
|
.title_style(theme::header())
|
||||||
|
.border_style(theme::dim());
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let Some(buffer) = logging::buffer() else {
|
||||||
|
return centered(frame, inner, "in-app log buffer unavailable");
|
||||||
|
};
|
||||||
|
|
||||||
|
let visible = usize::from(inner.height.max(1));
|
||||||
|
let skip = if logs.follow { 0 } else { logs.scroll_from_end };
|
||||||
|
let (entries, matched) = buffer.window(level, skip, visible);
|
||||||
|
if entries.is_empty() {
|
||||||
|
return centered(frame, inner, "no log entries at this level yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (row_index, entry) in entries.iter().enumerate() {
|
||||||
|
let row = Rect {
|
||||||
|
x: inner.x,
|
||||||
|
y: inner.y + row_index as u16,
|
||||||
|
width: inner.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled(format!("{} ", entry.time), theme::dim()),
|
||||||
|
level_span(entry.level),
|
||||||
|
Span::styled(format!(" {}: ", short_target(&entry.target)), theme::dim()),
|
||||||
|
Span::raw(entry.message.clone()),
|
||||||
|
]);
|
||||||
|
frame.render_widget(Paragraph::new(line), row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer hint with position info while scrolled back.
|
||||||
|
if !logs.follow && inner.height > 1 {
|
||||||
|
let footer = Rect {
|
||||||
|
y: inner.y + inner.height - 1,
|
||||||
|
height: 1,
|
||||||
|
..inner
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::styled(
|
||||||
|
format!(" ↑{skip} of {matched} · shift-g: follow · v: level "),
|
||||||
|
theme::tab_active(),
|
||||||
|
))
|
||||||
|
.alignment(Alignment::Right),
|
||||||
|
footer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centered(frame: &mut Frame, area: Rect, text: &str) {
|
||||||
|
if area.height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let middle = Rect { y: area.y + area.height / 2, height: 1, ..area };
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::styled(text.to_string(), theme::dim()))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
middle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn level_span(level: tracing::Level) -> Span<'static> {
|
||||||
|
match level {
|
||||||
|
tracing::Level::ERROR => {
|
||||||
|
Span::styled("ERROR", Style::new().fg(Color::Red).add_modifier(Modifier::BOLD))
|
||||||
|
}
|
||||||
|
tracing::Level::WARN => Span::styled("WARN ", Style::new().fg(Color::Yellow)),
|
||||||
|
tracing::Level::INFO => Span::styled("INFO ", theme::accent()),
|
||||||
|
tracing::Level::DEBUG => Span::styled("DEBUG", theme::dim()),
|
||||||
|
tracing::Level::TRACE => Span::styled("TRACE", theme::dim()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `furumi_tui::app::update` → `app::update` — the crate prefix is noise.
|
||||||
|
fn short_target(target: &str) -> &str {
|
||||||
|
target
|
||||||
|
.split_once("::")
|
||||||
|
.map_or(target, |(_, rest)| rest)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod art;
|
pub mod art;
|
||||||
mod global;
|
mod global;
|
||||||
mod login;
|
mod login;
|
||||||
|
mod logs;
|
||||||
mod playlists;
|
mod playlists;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
|
|||||||
Tab::Playlists => playlists::draw(frame, main_area, state),
|
Tab::Playlists => playlists::draw(frame, main_area, state),
|
||||||
Tab::Queue => draw_queue(frame, main_area, state),
|
Tab::Queue => draw_queue(frame, main_area, state),
|
||||||
Tab::Devices => draw_main(frame, main_area, state),
|
Tab::Devices => draw_main(frame, main_area, state),
|
||||||
|
Tab::Logs => logs::draw(frame, main_area, state),
|
||||||
}
|
}
|
||||||
draw_status(frame, status_area, state);
|
draw_status(frame, status_area, state);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user