dap-tui: ratatui device-sync dashboard
Full rewrite of dap-sync as a Rust TUI on ratatui: - Tokyo Night theme, gradient title rule, rounded panels - Devices sidebar with live mount status + last-sync age - Dashboard / Sync / Logs / Config tabs - Live sync engine: rclone mirror, NFC-aware diff, rsync progress parsing, podkit + mlove (fatsort) finalize - Abort-safe subprocess handling, persisted sync state
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+1955
File diff suppressed because it is too large
Load Diff
+20
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "dap-tui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Device sync for portable players — TUI edition (ratatui)"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ratatui = "0.30"
|
||||||
|
toml = "0.8"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
chrono = "0.4"
|
||||||
|
unicode-normalization = "0.1"
|
||||||
|
dirs = "6"
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
opt-level = 3
|
||||||
|
strip = true
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Sebastian
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# dap-tui
|
||||||
|
|
||||||
|
A terminal UI for device sync of portable music players, rewritten from scratch
|
||||||
|
in Rust on top of [ratatui](https://ratatui.rs/).
|
||||||
|
|
||||||
|
It mirrors the StorageBox masters library into your local music directory, then
|
||||||
|
syncs a device profile — the same pipeline as the Python `dap-sync`, now as a
|
||||||
|
living dashboard instead of a batch CLI.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- **Tokyo Night** palette, rounded panels, gradient title rule, status & key
|
||||||
|
bars.
|
||||||
|
- **Devices sidebar** — live mount status (green dot = mounted), firmware tags,
|
||||||
|
last-sync age per device.
|
||||||
|
- **4 tabs** — Dashboard (device + library cards, source-chain diagram), Sync
|
||||||
|
(live step pipeline + progress gauge), Logs (color-coded, follow mode),
|
||||||
|
Config (syntax-coloured TOML).
|
||||||
|
- **Live sync engine** — streams `rclone` and `rsync` output, parses progress,
|
||||||
|
NFC-aware diffing for macOS→FAT32/Android devices, optional `fatsort`
|
||||||
|
finalize for Mlove devices.
|
||||||
|
- **Abort-safe** — `a` kills the running child process cleanly.
|
||||||
|
- Reads the existing `~/.config/dap-sync/config.toml` (compatible with
|
||||||
|
`dap-sync`), persists last-sync state to `~/.local/share/dap-sync/sync-state.toml`.
|
||||||
|
|
||||||
|
## Install / run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
./target/release/dap-tui # uses ~/.config/dap-sync/config.toml
|
||||||
|
./target/release/dap-tui --config /path/to/config.toml
|
||||||
|
./target/release/dap-tui --skip-mirror # skip the StorageBox → local mirror step
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keybindings
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| ------------ | ---------------------------- |
|
||||||
|
| `↑`/`↓` `j`/`k` | move selection |
|
||||||
|
| `s` / `Enter`| sync selected device |
|
||||||
|
| `a` | abort running sync |
|
||||||
|
| `Tab` / `1-4`| switch tab |
|
||||||
|
| `r` | refresh device mounts |
|
||||||
|
| `f` | toggle log follow |
|
||||||
|
| `u` / `d` | scroll logs |
|
||||||
|
| `x` | clear logs |
|
||||||
|
| `?` / `h` | help overlay |
|
||||||
|
| `q` / `Esc` | quit |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`dap-tui` reads the standard `dap-sync` config format:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[paths]
|
||||||
|
local_music_dir = "~/Music"
|
||||||
|
local_hoerspiele_dir = "~/hoerspiele"
|
||||||
|
|
||||||
|
[storagebox]
|
||||||
|
source = "storagebox:masters"
|
||||||
|
exclude = ["/Music/**", ".DS_Store", ".localized"]
|
||||||
|
|
||||||
|
[[devices]]
|
||||||
|
name = "iPod Rockbox"
|
||||||
|
firmware = "rockbox"
|
||||||
|
mount_point = "/media/sebastian/IPOD"
|
||||||
|
music_folder = "Music"
|
||||||
|
```
|
||||||
|
|
||||||
|
Firmware types: `apple` (podkit command), `rockbox`, `android` (rsync),
|
||||||
|
`mlove` (rsync + `fatsort`). Devices with `content_type = "hoerspiele"` sync
|
||||||
|
against the hoerspiele library.
|
||||||
|
|
||||||
|
## How the sync pipeline works
|
||||||
|
|
||||||
|
1. **Mirror** — `rclone sync` (or local `rsync`) pulls the StorageBox source
|
||||||
|
into the local library.
|
||||||
|
2. **Diff** — the device is scanned and compared to the source with
|
||||||
|
NFC-normalised paths, size, and 2-second-rounded mtimes (FAT32 friendly).
|
||||||
|
3. **Sync** — stale files are removed first, then `rsync --files-from` copies
|
||||||
|
only what changed, with live per-file progress.
|
||||||
|
4. **Finalize** (mlove) — unmounts the volume and runs `fatsort` via
|
||||||
|
`sudo -n` to keep FAT directory order stable. If passwordless sudo is not
|
||||||
|
available the step is reported as a warning and the commands are printed.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test # parser + config tests
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
+286
@@ -0,0 +1,286 @@
|
|||||||
|
use crate::config::{Config, Device};
|
||||||
|
use crate::engine::{Engine, EngineEvent, LogLevel};
|
||||||
|
use crate::ui;
|
||||||
|
use anyhow::Result;
|
||||||
|
use ratatui::crossterm::event::{
|
||||||
|
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
|
||||||
|
};
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Tab {
|
||||||
|
Dashboard = 0,
|
||||||
|
Sync = 1,
|
||||||
|
Logs = 2,
|
||||||
|
Config = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tab {
|
||||||
|
pub const ALL: [Tab; 4] = [Tab::Dashboard, Tab::Sync, Tab::Logs, Tab::Config];
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Tab::Dashboard => "Dashboard",
|
||||||
|
Tab::Sync => "Sync",
|
||||||
|
Tab::Logs => "Logs",
|
||||||
|
Tab::Config => "Config",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn number(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Tab::Dashboard => 1,
|
||||||
|
Tab::Sync => 2,
|
||||||
|
Tab::Logs => 3,
|
||||||
|
Tab::Config => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
pub config: Config,
|
||||||
|
pub engine: Engine,
|
||||||
|
pub skip_mirror: bool,
|
||||||
|
|
||||||
|
pub tab: Tab,
|
||||||
|
pub selected: usize,
|
||||||
|
pub sidebar_scroll: usize,
|
||||||
|
|
||||||
|
pub logs: Vec<crate::engine::LogEntry>,
|
||||||
|
pub log_follow: bool,
|
||||||
|
pub log_scroll: usize,
|
||||||
|
|
||||||
|
pub frame: u64,
|
||||||
|
pub should_quit: bool,
|
||||||
|
pub show_help: bool,
|
||||||
|
pub status_msg: Option<(String, LogLevel)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new(config: Config, skip_mirror: bool) -> Result<App> {
|
||||||
|
let engine = Engine::spawn();
|
||||||
|
Ok(App {
|
||||||
|
config,
|
||||||
|
engine,
|
||||||
|
skip_mirror,
|
||||||
|
tab: Tab::Dashboard,
|
||||||
|
selected: 0,
|
||||||
|
sidebar_scroll: 0,
|
||||||
|
logs: Vec::new(),
|
||||||
|
log_follow: true,
|
||||||
|
log_scroll: 0,
|
||||||
|
frame: 0,
|
||||||
|
should_quit: false,
|
||||||
|
show_help: false,
|
||||||
|
status_msg: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_device(&self) -> Option<&Device> {
|
||||||
|
self.config.devices.get(self.selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spinner(&self) -> &'static str {
|
||||||
|
const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
FRAMES[(self.frame as usize / 3) % FRAMES.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_syncing(&self) -> bool {
|
||||||
|
self.engine.running()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_mounts(&mut self) {
|
||||||
|
for d in self.config.devices.iter_mut() {
|
||||||
|
d.mounted = d
|
||||||
|
.mount_point
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.exists())
|
||||||
|
.unwrap_or(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_engine(&mut self) {
|
||||||
|
while let Ok(ev) = self.engine.events.try_recv() {
|
||||||
|
match ev {
|
||||||
|
EngineEvent::Log(entry) => {
|
||||||
|
let is_error = matches!(entry.level, LogLevel::Error);
|
||||||
|
self.logs.push(entry);
|
||||||
|
if self.logs.len() > 2000 {
|
||||||
|
let overflow = self.logs.len() - 2000;
|
||||||
|
self.logs.drain(0..overflow);
|
||||||
|
}
|
||||||
|
if is_error {
|
||||||
|
self.status_msg = Some((
|
||||||
|
"see Logs tab (3) for details".to_string(),
|
||||||
|
LogLevel::Error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EngineEvent::Changed => {}
|
||||||
|
EngineEvent::Finished { ok, summary } => {
|
||||||
|
let level = if ok { LogLevel::Success } else { LogLevel::Error };
|
||||||
|
self.status_msg = Some((summary.clone(), level));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_sync(&mut self) {
|
||||||
|
let Some(device) = self.selected_device().cloned() else {
|
||||||
|
self.status_msg = Some(("no device selected".to_string(), LogLevel::Warn));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self.is_syncing() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.status_msg = None;
|
||||||
|
let cfg = Arc::new(self.config.clone());
|
||||||
|
self.engine
|
||||||
|
.start_sync(device, cfg, self.skip_mirror);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn abort_sync(&mut self) {
|
||||||
|
if self.is_syncing() {
|
||||||
|
self.engine.abort_sync();
|
||||||
|
self.status_msg = Some(("aborting…".to_string(), LogLevel::Warn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_key(&mut self, key: KeyEvent) {
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.show_help {
|
||||||
|
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?')) {
|
||||||
|
self.show_help = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
|
||||||
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.should_quit = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('?') | KeyCode::Char('h') => self.show_help = true,
|
||||||
|
KeyCode::Char('j') | KeyCode::Down => self.move_selection(1),
|
||||||
|
KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1),
|
||||||
|
KeyCode::Char('g') => {
|
||||||
|
self.selected = 0;
|
||||||
|
self.sidebar_scroll = 0;
|
||||||
|
}
|
||||||
|
KeyCode::Char('G') => {
|
||||||
|
let n = self.config.devices.len().saturating_sub(1);
|
||||||
|
self.selected = n;
|
||||||
|
self.ensure_selection_visible();
|
||||||
|
}
|
||||||
|
KeyCode::Char('s') | KeyCode::Enter => self.start_sync(),
|
||||||
|
KeyCode::Char('a') => self.abort_sync(),
|
||||||
|
KeyCode::Char('r') => {
|
||||||
|
self.refresh_mounts();
|
||||||
|
self.status_msg = Some(("devices refreshed".to_string(), LogLevel::Info));
|
||||||
|
}
|
||||||
|
KeyCode::Char('f') => {
|
||||||
|
self.log_follow = !self.log_follow;
|
||||||
|
self.log_scroll = 0;
|
||||||
|
}
|
||||||
|
KeyCode::Char('x') => {
|
||||||
|
self.logs.clear();
|
||||||
|
self.log_scroll = 0;
|
||||||
|
}
|
||||||
|
KeyCode::Char('u') => self.scroll_logs(-10),
|
||||||
|
KeyCode::Char('d') => self.scroll_logs(10),
|
||||||
|
KeyCode::Tab => self.tab = next_tab(self.tab),
|
||||||
|
KeyCode::BackTab => self.tab = prev_tab(self.tab),
|
||||||
|
KeyCode::Char('1') => self.tab = Tab::Dashboard,
|
||||||
|
KeyCode::Char('2') => self.tab = Tab::Sync,
|
||||||
|
KeyCode::Char('3') => self.tab = Tab::Logs,
|
||||||
|
KeyCode::Char('4') => self.tab = Tab::Config,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_selection(&mut self, delta: i32) {
|
||||||
|
if self.config.devices.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let n = self.config.devices.len();
|
||||||
|
self.selected = ((self.selected as i32 + delta).rem_euclid(n as i32)) as usize;
|
||||||
|
self.ensure_selection_visible();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_selection_visible(&mut self) {
|
||||||
|
const VISIBLE: usize = 12;
|
||||||
|
if self.selected < self.sidebar_scroll {
|
||||||
|
self.sidebar_scroll = self.selected;
|
||||||
|
}
|
||||||
|
if self.selected >= self.sidebar_scroll + VISIBLE {
|
||||||
|
self.sidebar_scroll = self.selected + 1 - VISIBLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_logs(&mut self, delta: i32) {
|
||||||
|
self.log_follow = false;
|
||||||
|
let max = self.logs.len().saturating_sub(1) as i32;
|
||||||
|
self.log_scroll = ((self.log_scroll as i32 + delta).clamp(0, max)) as usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tick(&mut self) {
|
||||||
|
self.frame += 1;
|
||||||
|
if self.frame % 30 == 0 {
|
||||||
|
self.refresh_mounts();
|
||||||
|
}
|
||||||
|
if self.is_syncing() {
|
||||||
|
let aborted = self.engine.abort.load(Ordering::Relaxed);
|
||||||
|
if let Ok(mut s) = self.engine.state.lock() {
|
||||||
|
s.aborting = aborted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(&mut self) -> Result<()> {
|
||||||
|
let mut terminal = ratatui::init();
|
||||||
|
let res = (|| -> Result<()> {
|
||||||
|
loop {
|
||||||
|
self.drain_engine();
|
||||||
|
self.tick();
|
||||||
|
terminal.draw(|f| ui::draw(f, self))?;
|
||||||
|
if self.should_quit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if event::poll(Duration::from_millis(40))? {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
self.on_key(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
ratatui::restore();
|
||||||
|
res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_tab(t: Tab) -> Tab {
|
||||||
|
Tab::ALL[(t as usize + 1) % Tab::ALL.len()]
|
||||||
|
}
|
||||||
|
fn prev_tab(t: Tab) -> Tab {
|
||||||
|
Tab::ALL[(t as usize + Tab::ALL.len() - 1) % Tab::ALL.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn relative_ago(dt: chrono::DateTime<chrono::Local>) -> String {
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
let secs = (now - dt).num_seconds().max(0);
|
||||||
|
if secs < 5 {
|
||||||
|
"just now".to_string()
|
||||||
|
} else if secs < 60 {
|
||||||
|
format!("{secs}s ago")
|
||||||
|
} else if secs < 3600 {
|
||||||
|
format!("{}m ago", secs / 60)
|
||||||
|
} else if secs < 86400 {
|
||||||
|
format!("{}h ago", secs / 3600)
|
||||||
|
} else {
|
||||||
|
format!("{}d ago", secs / 86400)
|
||||||
|
}
|
||||||
|
}
|
||||||
+381
@@ -0,0 +1,381 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::{DateTime, Local};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub const DEFAULT_CONFIG_FILE: &str = "~/.config/dap-sync/config.toml";
|
||||||
|
pub const DEFAULT_SYNC_COMMAND: &str = "podkit sync -c main";
|
||||||
|
pub const DEFAULT_STORAGEBOX_SOURCE: &str = "storagebox:masters";
|
||||||
|
pub const DEFAULT_STORAGEBOX_HOERSPIELE_SOURCE: &str = "storagebox:backup/hoerspiele";
|
||||||
|
pub const DEFAULT_EXCLUDES: &[&str] = &["/Music/**", ".DS_Store", ".localized"];
|
||||||
|
|
||||||
|
fn expand_home(s: &str) -> PathBuf {
|
||||||
|
if let Some(rest) = s.strip_prefix("~/") {
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
return home.join(rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathBuf::from(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct RawPaths {
|
||||||
|
local_music_dir: Option<String>,
|
||||||
|
local_hoerspiele_dir: Option<String>,
|
||||||
|
output_dir: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct RawStoragebox {
|
||||||
|
source: Option<String>,
|
||||||
|
hoerspiele_source: Option<String>,
|
||||||
|
exclude: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct RawSync {
|
||||||
|
command: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct RawDevice {
|
||||||
|
name: Option<String>,
|
||||||
|
firmware: Option<String>,
|
||||||
|
content_type: Option<String>,
|
||||||
|
sync_command: Option<String>,
|
||||||
|
mount_point: Option<String>,
|
||||||
|
music_folder: Option<String>,
|
||||||
|
cache_dir: Option<String>,
|
||||||
|
preserve_fat_order: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct RawConfig {
|
||||||
|
paths: RawPaths,
|
||||||
|
storagebox: RawStoragebox,
|
||||||
|
sync: RawSync,
|
||||||
|
devices: Vec<RawDevice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Firmware {
|
||||||
|
Apple,
|
||||||
|
Rockbox,
|
||||||
|
Android,
|
||||||
|
Mlove,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Firmware {
|
||||||
|
pub fn from_str(s: &str) -> Firmware {
|
||||||
|
match s.trim().to_lowercase().as_str() {
|
||||||
|
"rockbox" => Firmware::Rockbox,
|
||||||
|
"android" => Firmware::Android,
|
||||||
|
"mlove" => Firmware::Mlove,
|
||||||
|
_ => Firmware::Apple,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Firmware::Apple => "apple",
|
||||||
|
Firmware::Rockbox => "rockbox",
|
||||||
|
Firmware::Android => "android",
|
||||||
|
Firmware::Mlove => "mlove",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Device {
|
||||||
|
pub name: String,
|
||||||
|
pub firmware: Firmware,
|
||||||
|
pub content_type: String,
|
||||||
|
pub sync_command: String,
|
||||||
|
pub mount_point: Option<PathBuf>,
|
||||||
|
pub music_folder: String,
|
||||||
|
pub preserve_fat_order: bool,
|
||||||
|
pub mounted: bool,
|
||||||
|
pub last_sync: Option<DateTime<Local>>,
|
||||||
|
pub last_status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Device {
|
||||||
|
pub fn mount_str(&self) -> String {
|
||||||
|
match &self.mount_point {
|
||||||
|
Some(p) => p.display().to_string(),
|
||||||
|
None => "—".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub config_file: PathBuf,
|
||||||
|
pub local_music_dir: PathBuf,
|
||||||
|
pub local_hoerspiele_dir: PathBuf,
|
||||||
|
pub output_dir: PathBuf,
|
||||||
|
pub storagebox_source: String,
|
||||||
|
pub storagebox_hoerspiele_source: String,
|
||||||
|
pub storagebox_excludes: Vec<String>,
|
||||||
|
pub devices: Vec<Device>,
|
||||||
|
pub raw_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
fn refresh_mounts(&mut self) {
|
||||||
|
for d in self.devices.iter_mut() {
|
||||||
|
d.mounted = d
|
||||||
|
.mount_point
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.exists())
|
||||||
|
.unwrap_or(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_state(&mut self, state: &StateStore) {
|
||||||
|
for d in self.devices.iter_mut() {
|
||||||
|
if let Some(rec) = state.records.get(&d.name) {
|
||||||
|
d.last_sync = rec.last_sync;
|
||||||
|
d.last_status = rec.status.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StateStore {
|
||||||
|
pub records: BTreeMap<String, StateRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StateRecord {
|
||||||
|
pub last_sync: Option<DateTime<Local>>,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_config_path() -> PathBuf {
|
||||||
|
expand_home(DEFAULT_CONFIG_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(config_path: Option<String>) -> Result<Config> {
|
||||||
|
let path = match config_path {
|
||||||
|
Some(p) => expand_home(&p),
|
||||||
|
None => default_config_path(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cfg = if path.exists() {
|
||||||
|
let text = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("failed to read config at {}", path.display()))?;
|
||||||
|
let raw: RawConfig = toml::from_str(&text)
|
||||||
|
.with_context(|| format!("failed to parse config at {}", path.display()))?;
|
||||||
|
let local_music = raw
|
||||||
|
.paths
|
||||||
|
.local_music_dir
|
||||||
|
.map(|s| expand_home(&s))
|
||||||
|
.unwrap_or_else(|| expand_home("~/Music"));
|
||||||
|
let local_hoerspiele = raw
|
||||||
|
.paths
|
||||||
|
.local_hoerspiele_dir
|
||||||
|
.map(|s| expand_home(&s))
|
||||||
|
.unwrap_or_else(|| expand_home("~/hoerspiele"));
|
||||||
|
let output_dir = raw
|
||||||
|
.paths
|
||||||
|
.output_dir
|
||||||
|
.map(|s| expand_home(&s))
|
||||||
|
.unwrap_or_else(|| expand_home("~/.local/share/dap-sync"));
|
||||||
|
let sync_cmd = raw
|
||||||
|
.sync
|
||||||
|
.command
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| DEFAULT_SYNC_COMMAND.to_string());
|
||||||
|
|
||||||
|
let mut devices = Vec::new();
|
||||||
|
for rd in raw.devices.iter().filter(|d| d.name.is_some()) {
|
||||||
|
let name = rd.name.clone().unwrap();
|
||||||
|
let firmware = Firmware::from_str(rd.firmware.as_deref().unwrap_or("apple"));
|
||||||
|
let content_type = rd
|
||||||
|
.content_type
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "music".to_string())
|
||||||
|
.trim()
|
||||||
|
.to_lowercase();
|
||||||
|
let sync_command = rd
|
||||||
|
.sync_command
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| sync_cmd.clone());
|
||||||
|
let mount_point = rd
|
||||||
|
.mount_point
|
||||||
|
.clone()
|
||||||
|
.map(|s| expand_home(&s));
|
||||||
|
let music_folder = rd
|
||||||
|
.music_folder
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
match firmware {
|
||||||
|
Firmware::Mlove => "Hörspiele".to_string(),
|
||||||
|
_ => "Music".to_string(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let preserve_fat_order = rd
|
||||||
|
.preserve_fat_order
|
||||||
|
.unwrap_or_else(|| firmware == Firmware::Mlove);
|
||||||
|
|
||||||
|
devices.push(Device {
|
||||||
|
name,
|
||||||
|
firmware,
|
||||||
|
content_type,
|
||||||
|
sync_command,
|
||||||
|
mount_point,
|
||||||
|
music_folder,
|
||||||
|
preserve_fat_order,
|
||||||
|
mounted: false,
|
||||||
|
last_sync: None,
|
||||||
|
last_status: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Config {
|
||||||
|
config_file: path.clone(),
|
||||||
|
local_music_dir: local_music,
|
||||||
|
local_hoerspiele_dir: local_hoerspiele,
|
||||||
|
output_dir,
|
||||||
|
storagebox_source: raw
|
||||||
|
.storagebox
|
||||||
|
.source
|
||||||
|
.unwrap_or_else(|| DEFAULT_STORAGEBOX_SOURCE.to_string()),
|
||||||
|
storagebox_hoerspiele_source: raw
|
||||||
|
.storagebox
|
||||||
|
.hoerspiele_source
|
||||||
|
.unwrap_or_else(|| DEFAULT_STORAGEBOX_HOERSPIELE_SOURCE.to_string()),
|
||||||
|
storagebox_excludes: raw
|
||||||
|
.storagebox
|
||||||
|
.exclude
|
||||||
|
.map(|v| v.clone())
|
||||||
|
.unwrap_or_else(|| DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect()),
|
||||||
|
devices,
|
||||||
|
raw_text: text,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Config {
|
||||||
|
config_file: path.clone(),
|
||||||
|
local_music_dir: expand_home("~/Music"),
|
||||||
|
local_hoerspiele_dir: expand_home("~/hoerspiele"),
|
||||||
|
output_dir: expand_home("~/.local/share/dap-sync"),
|
||||||
|
storagebox_source: DEFAULT_STORAGEBOX_SOURCE.to_string(),
|
||||||
|
storagebox_hoerspiele_source: DEFAULT_STORAGEBOX_HOERSPIELE_SOURCE.to_string(),
|
||||||
|
storagebox_excludes: DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect(),
|
||||||
|
devices: Vec::new(),
|
||||||
|
raw_text: "# no config file found\n".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg.refresh_mounts();
|
||||||
|
let store = load_state(&cfg.output_dir);
|
||||||
|
cfg.apply_state(&store);
|
||||||
|
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_file(output_dir: &Path) -> PathBuf {
|
||||||
|
output_dir.join("sync-state.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_state(output_dir: &Path) -> StateStore {
|
||||||
|
let file = state_file(output_dir);
|
||||||
|
let mut records = BTreeMap::new();
|
||||||
|
if let Ok(text) = fs::read_to_string(&file) {
|
||||||
|
if let Ok(table) = text.parse::<toml::Table>() {
|
||||||
|
for (name, val) in table.iter() {
|
||||||
|
if let Some(sub) = val.as_table() {
|
||||||
|
let last_sync = sub
|
||||||
|
.get("last_sync")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||||
|
.map(|dt| dt.with_timezone(&Local));
|
||||||
|
let status = sub
|
||||||
|
.get("status")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
records.insert(name.clone(), StateRecord { last_sync, status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StateStore { records }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_state(
|
||||||
|
output_dir: &Path,
|
||||||
|
name: &str,
|
||||||
|
last_sync: DateTime<Local>,
|
||||||
|
status: &str,
|
||||||
|
) {
|
||||||
|
let file = state_file(output_dir);
|
||||||
|
let mut table: toml::Table = fs::read_to_string(&file)
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.parse::<toml::Table>().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut sub = toml::map::Map::new();
|
||||||
|
sub.insert("last_sync".into(), toml::Value::String(last_sync.to_rfc3339()));
|
||||||
|
sub.insert("status".into(), toml::Value::String(status.to_string()));
|
||||||
|
table.insert(name.into(), toml::Value::Table(sub));
|
||||||
|
|
||||||
|
if let Some(parent) = file.parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Ok(text) = toml::to_string(&table) {
|
||||||
|
let _ = fs::write(&file, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expands_home() {
|
||||||
|
let p = expand_home("~/foo/bar");
|
||||||
|
assert_eq!(p, dirs::home_dir().unwrap().join("foo/bar"));
|
||||||
|
assert_eq!(expand_home("/abs/path"), PathBuf::from("/abs/path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_devices_from_config() {
|
||||||
|
let dir = std::env::temp_dir().join("dap-tui-config-test");
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let path = dir.join("config.toml");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"
|
||||||
|
[paths]
|
||||||
|
local_music_dir = "~/Music"
|
||||||
|
|
||||||
|
[storagebox]
|
||||||
|
source = "storagebox:masters"
|
||||||
|
exclude = [".DS_Store"]
|
||||||
|
|
||||||
|
[[devices]]
|
||||||
|
name = "Test iPod"
|
||||||
|
firmware = "rockbox"
|
||||||
|
mount_point = "/media/ipod"
|
||||||
|
music_folder = "Music"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let cfg = load(Some(path.display().to_string())).unwrap();
|
||||||
|
assert_eq!(cfg.devices.len(), 1);
|
||||||
|
let d = &cfg.devices[0];
|
||||||
|
assert_eq!(d.name, "Test iPod");
|
||||||
|
assert_eq!(d.firmware.label(), "rockbox");
|
||||||
|
assert_eq!(d.music_folder, "Music");
|
||||||
|
assert_eq!(cfg.storagebox_excludes, vec![".DS_Store"]);
|
||||||
|
assert_eq!(cfg.local_music_dir, dirs::home_dir().unwrap().join("Music"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+776
@@ -0,0 +1,776 @@
|
|||||||
|
use crate::config::{self, Config, Device, Firmware};
|
||||||
|
use crate::progress::{parse_rclone_stats, parse_rsync_line, RsyncLine};
|
||||||
|
use chrono::Local;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{mpsc, Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LogLevel {
|
||||||
|
Info,
|
||||||
|
Transfer,
|
||||||
|
Warn,
|
||||||
|
Error,
|
||||||
|
Success,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LogEntry {
|
||||||
|
pub level: LogLevel,
|
||||||
|
pub msg: String,
|
||||||
|
pub ts: chrono::DateTime<Local>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum StepStatus {
|
||||||
|
Pending,
|
||||||
|
Active,
|
||||||
|
Done,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StepState {
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub status: StepStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ProgressInfo {
|
||||||
|
pub pct: u16,
|
||||||
|
pub done: String,
|
||||||
|
pub total: String,
|
||||||
|
pub speed: String,
|
||||||
|
pub eta: String,
|
||||||
|
pub current: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SyncState {
|
||||||
|
pub running: bool,
|
||||||
|
pub aborting: bool,
|
||||||
|
pub device: Option<String>,
|
||||||
|
pub steps: Vec<StepState>,
|
||||||
|
pub mirror: ProgressInfo,
|
||||||
|
pub device_prog: ProgressInfo,
|
||||||
|
pub result: Option<bool>,
|
||||||
|
pub summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SyncState {
|
||||||
|
fn default() -> Self {
|
||||||
|
SyncState {
|
||||||
|
running: false,
|
||||||
|
aborting: false,
|
||||||
|
device: None,
|
||||||
|
steps: Vec::new(),
|
||||||
|
mirror: ProgressInfo::default(),
|
||||||
|
device_prog: ProgressInfo::default(),
|
||||||
|
result: None,
|
||||||
|
summary: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum EngineEvent {
|
||||||
|
Log(LogEntry),
|
||||||
|
Changed,
|
||||||
|
Finished {
|
||||||
|
ok: bool,
|
||||||
|
summary: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WorkerCtx {
|
||||||
|
state: Arc<Mutex<SyncState>>,
|
||||||
|
tx: mpsc::Sender<EngineEvent>,
|
||||||
|
abort: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerCtx {
|
||||||
|
fn log(&self, level: LogLevel, msg: impl Into<String>) {
|
||||||
|
let _ = self.tx.send(EngineEvent::Log(LogEntry {
|
||||||
|
level,
|
||||||
|
msg: msg.into(),
|
||||||
|
ts: Local::now(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn changed(&self) {
|
||||||
|
let _ = self.tx.send(EngineEvent::Changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_step(&self, idx: usize, status: StepStatus, detail: impl Into<String>) {
|
||||||
|
if let Ok(mut s) = self.state.lock() {
|
||||||
|
if let Some(step) = s.steps.get_mut(idx) {
|
||||||
|
step.status = status;
|
||||||
|
step.detail = detail.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_mirror(&self, p: crate::progress::RcloneStats) {
|
||||||
|
if let Ok(mut s) = self.state.lock() {
|
||||||
|
s.mirror.pct = p.pct;
|
||||||
|
s.mirror.done = p.done;
|
||||||
|
s.mirror.total = p.total;
|
||||||
|
s.mirror.speed = p.speed;
|
||||||
|
s.mirror.eta = p.eta;
|
||||||
|
}
|
||||||
|
self.changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_device_progress(&self, done: u64, total: u64, speed: &str, eta: &str, current: &str) {
|
||||||
|
if let Ok(mut s) = self.state.lock() {
|
||||||
|
let p = &mut s.device_prog;
|
||||||
|
p.done = done.to_string();
|
||||||
|
p.total = total.to_string();
|
||||||
|
p.speed = speed.to_string();
|
||||||
|
p.eta = eta.to_string();
|
||||||
|
if !current.is_empty() {
|
||||||
|
p.current = current.to_string();
|
||||||
|
}
|
||||||
|
if total > 0 {
|
||||||
|
p.pct = ((done as f64 / total as f64) * 100.0) as u16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Engine {
|
||||||
|
pub state: Arc<Mutex<SyncState>>,
|
||||||
|
pub events: mpsc::Receiver<EngineEvent>,
|
||||||
|
tx: mpsc::Sender<EngineEvent>,
|
||||||
|
pub abort: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Engine {
|
||||||
|
pub fn spawn() -> Engine {
|
||||||
|
let (tx, events) = mpsc::channel();
|
||||||
|
Engine {
|
||||||
|
state: Arc::new(Mutex::new(SyncState::default())),
|
||||||
|
events,
|
||||||
|
tx,
|
||||||
|
abort: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn running(&self) -> bool {
|
||||||
|
self.state.lock().map(|s| s.running).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_sync(&self, device: Device, config: Arc<Config>, skip_mirror: bool) {
|
||||||
|
if self.running() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.abort.store(false, Ordering::Relaxed);
|
||||||
|
let ctx = Arc::new(WorkerCtx {
|
||||||
|
state: self.state.clone(),
|
||||||
|
tx: self.tx.clone(),
|
||||||
|
abort: self.abort.clone(),
|
||||||
|
});
|
||||||
|
thread::spawn(move || run_sync(config, device, skip_mirror, ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn abort_sync(&self) {
|
||||||
|
self.abort.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sync pipeline ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn build_steps(device: &Device) -> Vec<StepState> {
|
||||||
|
let mut steps = vec![
|
||||||
|
StepState {
|
||||||
|
title: "Mirror StorageBox".to_string(),
|
||||||
|
detail: "refresh local library".to_string(),
|
||||||
|
status: StepStatus::Pending,
|
||||||
|
},
|
||||||
|
StepState {
|
||||||
|
title: format!("Sync {}", device.name),
|
||||||
|
detail: format!("{} · {}", device.firmware.label(), device.content_type),
|
||||||
|
status: StepStatus::Pending,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if device.firmware == Firmware::Mlove {
|
||||||
|
steps.push(StepState {
|
||||||
|
title: "Finalize FAT".to_string(),
|
||||||
|
detail: "unmount + fatsort".to_string(),
|
||||||
|
status: StepStatus::Pending,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
steps
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_sync(config: Arc<Config>, device: Device, skip_mirror: bool, ctx: Arc<WorkerCtx>) {
|
||||||
|
let name = device.name.clone();
|
||||||
|
{
|
||||||
|
let mut s = ctx.state.lock().unwrap();
|
||||||
|
*s = SyncState::default();
|
||||||
|
s.running = true;
|
||||||
|
s.device = Some(name.clone());
|
||||||
|
s.steps = build_steps(&device);
|
||||||
|
s.summary = "Starting sync…".to_string();
|
||||||
|
}
|
||||||
|
ctx.changed();
|
||||||
|
ctx.log(LogLevel::Info, format!("── Starting sync for {}", name));
|
||||||
|
|
||||||
|
let (ok, summary) = match run_pipeline(&config, &device, skip_mirror, &ctx) {
|
||||||
|
Ok(()) => (true, "Sync complete".to_string()),
|
||||||
|
Err(e) => {
|
||||||
|
ctx.log(LogLevel::Error, e.clone());
|
||||||
|
(false, e)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut s = ctx.state.lock().unwrap();
|
||||||
|
s.running = false;
|
||||||
|
s.aborting = false;
|
||||||
|
s.result = Some(ok);
|
||||||
|
s.summary = summary.clone();
|
||||||
|
for step in s.steps.iter_mut() {
|
||||||
|
if step.status == StepStatus::Active {
|
||||||
|
step.status = if ok { StepStatus::Done } else { StepStatus::Error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.log(
|
||||||
|
if ok { LogLevel::Success } else { LogLevel::Error },
|
||||||
|
if ok { format!("✓ {summary}") } else { format!("✗ {summary}") },
|
||||||
|
);
|
||||||
|
ctx.changed();
|
||||||
|
let _ = ctx.tx.send(EngineEvent::Finished {
|
||||||
|
ok,
|
||||||
|
summary: summary.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
config::save_state(&config.output_dir, &name, Local::now(), if ok { "ok" } else { "error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_pipeline(
|
||||||
|
config: &Config,
|
||||||
|
device: &Device,
|
||||||
|
skip_mirror: bool,
|
||||||
|
ctx: &WorkerCtx,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let steps = build_steps(device);
|
||||||
|
|
||||||
|
if !skip_mirror {
|
||||||
|
ctx.set_step(0, StepStatus::Active, "refreshing local library…");
|
||||||
|
run_mirror(config, device, ctx)?;
|
||||||
|
ctx.set_step(0, StepStatus::Done, "local library refreshed");
|
||||||
|
} else {
|
||||||
|
ctx.set_step(0, StepStatus::Done, "skipped (--skip-mirror)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.abort.load(Ordering::Relaxed) {
|
||||||
|
return Err("aborted".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.set_step(1, StepStatus::Active, "connecting to device…");
|
||||||
|
match device.firmware {
|
||||||
|
Firmware::Apple => run_podkit(config, device, ctx)?,
|
||||||
|
_ => run_rsync_device(config, device, ctx)?,
|
||||||
|
}
|
||||||
|
ctx.set_step(1, StepStatus::Done, "device synced");
|
||||||
|
|
||||||
|
if device.firmware == Firmware::Mlove {
|
||||||
|
if ctx.abort.load(Ordering::Relaxed) {
|
||||||
|
return Err("aborted".to_string());
|
||||||
|
}
|
||||||
|
ctx.set_step(2, StepStatus::Active, "unmounting + running fatsort…");
|
||||||
|
if let Err(e) = finalize_fat(device, ctx) {
|
||||||
|
ctx.log(LogLevel::Warn, format!("finalize skipped: {e}"));
|
||||||
|
ctx.set_step(
|
||||||
|
2,
|
||||||
|
StepStatus::Error,
|
||||||
|
"device synced, but eject/fatsort needs to run manually",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ctx.set_step(2, StepStatus::Done, "device ejected & sorted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = steps;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── step 1: mirror StorageBox → local library ────────────────────────────────
|
||||||
|
|
||||||
|
fn run_mirror(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), String> {
|
||||||
|
let (source, local_dir, label) = if device.content_type == "hoerspiele" {
|
||||||
|
(
|
||||||
|
config.storagebox_hoerspiele_source.clone(),
|
||||||
|
config.local_hoerspiele_dir.clone(),
|
||||||
|
"Hörspiele",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
config.storagebox_source.clone(),
|
||||||
|
config.local_music_dir.clone(),
|
||||||
|
"Music",
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::create_dir_all(&local_dir).map_err(|e| format!("cannot create {}: {e}", local_dir.display()))?;
|
||||||
|
|
||||||
|
let src_path = PathBuf::from(&source);
|
||||||
|
if src_path.is_absolute() && src_path.is_dir() {
|
||||||
|
ctx.log(
|
||||||
|
LogLevel::Info,
|
||||||
|
format!("Mirroring {label} from local source {} → {}", source, local_dir.display()),
|
||||||
|
);
|
||||||
|
let args = vec![
|
||||||
|
"-a".to_string(),
|
||||||
|
"--delete".to_string(),
|
||||||
|
"--info=progress2,name1".to_string(),
|
||||||
|
"--exclude=.DS_Store".to_string(),
|
||||||
|
"--exclude=.localized".to_string(),
|
||||||
|
format!("{}/", source),
|
||||||
|
local_dir.display().to_string(),
|
||||||
|
];
|
||||||
|
return stream(ctx, "rsync", &args, |line| {
|
||||||
|
if let Some(l) = parse_rsync_line(line) {
|
||||||
|
match l {
|
||||||
|
RsyncLine::Progress(p) => {
|
||||||
|
if let Ok(mut s) = ctx.state.lock() {
|
||||||
|
s.mirror.pct = p.pct;
|
||||||
|
s.mirror.done = p.done.to_string();
|
||||||
|
s.mirror.total = p.total.to_string();
|
||||||
|
s.mirror.speed = p.speed.clone();
|
||||||
|
s.mirror.eta = p.eta.clone();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
RsyncLine::Total(_) | RsyncLine::File(_) => true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.log(
|
||||||
|
LogLevel::Info,
|
||||||
|
format!("Mirroring {label} via rclone {} → {}", source, local_dir.display()),
|
||||||
|
);
|
||||||
|
let mut args = vec![
|
||||||
|
"sync".to_string(),
|
||||||
|
source.clone(),
|
||||||
|
local_dir.display().to_string(),
|
||||||
|
];
|
||||||
|
for pat in &config.storagebox_excludes {
|
||||||
|
args.push("--exclude".to_string());
|
||||||
|
args.push(pat.clone());
|
||||||
|
}
|
||||||
|
args.extend(
|
||||||
|
[
|
||||||
|
"--create-empty-src-dirs",
|
||||||
|
"--stats",
|
||||||
|
"1s",
|
||||||
|
"--stats-one-line",
|
||||||
|
"--stats-log-level",
|
||||||
|
"NOTICE",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
stream(ctx, "rclone", &args, |line| {
|
||||||
|
if let Some(p) = parse_rclone_stats(line) {
|
||||||
|
ctx.set_mirror(p);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── step 2: device sync ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn run_podkit(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), String> {
|
||||||
|
let cmd = device
|
||||||
|
.sync_command
|
||||||
|
.replace("{local_music_dir}", &config.local_music_dir.display().to_string());
|
||||||
|
ctx.log(LogLevel::Info, format!("Running podkit: {cmd}"));
|
||||||
|
stream(ctx, "sh", &["-c".to_string(), cmd], |line| {
|
||||||
|
ctx.log(LogLevel::Info, line);
|
||||||
|
true // consumed — avoid double-logging in the outer loop
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_music_dir(base: &Path) -> HashMap<String, (u64, i64)> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
let mut stack = vec![base.to_path_buf()];
|
||||||
|
while let Some(dir) = stack.pop() {
|
||||||
|
let entries = match fs::read_dir(&dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_s = name.to_string_lossy().to_string();
|
||||||
|
if name_s.starts_with('.') || name_s.starts_with("._") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
stack.push(path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rel = match path.strip_prefix(base) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let key = rel
|
||||||
|
.components()
|
||||||
|
.map(|c| c.as_os_str().to_string_lossy().nfc().collect::<String>())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
if let Ok(meta) = entry.metadata() {
|
||||||
|
let size = meta.len();
|
||||||
|
let mtime = meta
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let mtime = mtime.div_euclid(2) * 2; // FAT32 2s resolution
|
||||||
|
map.insert(key, (size, mtime));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_rsync_device(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), String> {
|
||||||
|
let mount = device
|
||||||
|
.mount_point
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "mount_point is not set for this device".to_string())?;
|
||||||
|
if !mount.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"device not found at {} — connect & mount it, then try again",
|
||||||
|
mount.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (src_dir, label) = if device.content_type == "hoerspiele" {
|
||||||
|
(config.local_hoerspiele_dir.clone(), "Hörspiele")
|
||||||
|
} else {
|
||||||
|
(config.local_music_dir.clone(), "Music")
|
||||||
|
};
|
||||||
|
let dest = mount.join(&device.music_folder);
|
||||||
|
fs::create_dir_all(&dest).map_err(|e| format!("cannot create {}: {e}", dest.display()))?;
|
||||||
|
|
||||||
|
ctx.log(LogLevel::Info, format!("Scanning local {label} library…"));
|
||||||
|
let src_map = scan_music_dir(&src_dir);
|
||||||
|
ctx.log(LogLevel::Info, format!("Scanning device {}…", dest.display()));
|
||||||
|
let dst_map = scan_music_dir(&dest);
|
||||||
|
|
||||||
|
let mut to_copy: Vec<String> = Vec::new();
|
||||||
|
for (k, (size, mtime)) in &src_map {
|
||||||
|
let changed = dst_map
|
||||||
|
.get(k)
|
||||||
|
.map(|(dsize, dmtime)| dsize != size || dmtime != mtime)
|
||||||
|
.unwrap_or(true);
|
||||||
|
if changed {
|
||||||
|
to_copy.push(k.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if device.preserve_fat_order {
|
||||||
|
to_copy.sort();
|
||||||
|
}
|
||||||
|
let to_delete: Vec<String> = dst_map.keys().filter(|k| !src_map.contains_key(*k)).cloned().collect();
|
||||||
|
|
||||||
|
ctx.log(
|
||||||
|
LogLevel::Info,
|
||||||
|
format!(
|
||||||
|
"{} source files · {} to copy · {} to remove",
|
||||||
|
src_map.len(),
|
||||||
|
to_copy.len(),
|
||||||
|
to_delete.len()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if to_copy.is_empty() && to_delete.is_empty() {
|
||||||
|
ctx.log(LogLevel::Success, "Everything up to date.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !to_delete.is_empty() {
|
||||||
|
remove_stale(&dest, &to_delete);
|
||||||
|
ctx.log(LogLevel::Info, format!("Removed {} stale file(s).", to_delete.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !to_copy.is_empty() {
|
||||||
|
let tmp = write_file_list(&to_copy)?;
|
||||||
|
let res = {
|
||||||
|
let args = vec![
|
||||||
|
"-t".to_string(),
|
||||||
|
"--files-from".to_string(),
|
||||||
|
tmp.clone(),
|
||||||
|
"--progress".to_string(),
|
||||||
|
"--exclude=.DS_Store".to_string(),
|
||||||
|
"--exclude=._*".to_string(),
|
||||||
|
format!("{}/", src_dir.display()),
|
||||||
|
dest.display().to_string(),
|
||||||
|
];
|
||||||
|
let total = to_copy.len() as u64;
|
||||||
|
stream(ctx, "rsync", &args, |line| {
|
||||||
|
match parse_rsync_line(line) {
|
||||||
|
Some(RsyncLine::Progress(p)) => {
|
||||||
|
let done = if p.done > 0 { p.done } else { total };
|
||||||
|
ctx.set_device_progress(done, total, &p.speed, &p.eta, "");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Some(RsyncLine::Total(_)) => true,
|
||||||
|
Some(RsyncLine::File(path)) => {
|
||||||
|
ctx.set_device_progress(0, total, "", "", &path);
|
||||||
|
ctx.log(LogLevel::Transfer, path);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let _ = fs::remove_file(&tmp);
|
||||||
|
res?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_stale(dest: &Path, to_delete: &[String]) {
|
||||||
|
let mut actual: HashMap<String, PathBuf> = HashMap::new();
|
||||||
|
let mut stack = vec![dest.to_path_buf()];
|
||||||
|
while let Some(dir) = stack.pop() {
|
||||||
|
let entries = match fs::read_dir(&dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_s = name.to_string_lossy().to_string();
|
||||||
|
if name_s.starts_with('.') || name_s.starts_with("._") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
stack.push(path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(rel) = path.strip_prefix(dest) {
|
||||||
|
let key = rel
|
||||||
|
.components()
|
||||||
|
.map(|c| c.as_os_str().to_string_lossy().nfc().collect::<String>())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
actual.insert(key, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key in to_delete {
|
||||||
|
if let Some(p) = actual.get(key) {
|
||||||
|
if p.exists() {
|
||||||
|
let _ = fs::remove_file(p);
|
||||||
|
if let Some(parent) = p.parent() {
|
||||||
|
let _ = fs::remove_dir(parent); // remove if now empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_file_list(items: &[String]) -> Result<String, String> {
|
||||||
|
let path = std::env::temp_dir().join(format!("dap-tui-files-{}.txt", std::process::id()));
|
||||||
|
let content = items.iter().map(|s| s.as_str()).collect::<Vec<_>>().join("\n");
|
||||||
|
fs::write(&path, content).map_err(|e| format!("cannot write file list: {e}"))?;
|
||||||
|
Ok(path.display().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mlove finalize ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn capture(cmd: &str, args: &[&str]) -> Result<(i32, String), String> {
|
||||||
|
let out = Command::new(cmd)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("cannot run {cmd}: {e}"))?;
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
Ok((out.status.code().unwrap_or(-1), text))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize_fat(device: &Device, ctx: &WorkerCtx) -> Result<(), String> {
|
||||||
|
let mount = device
|
||||||
|
.mount_point
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "mount_point is required to run fatsort".to_string())?;
|
||||||
|
|
||||||
|
let target = resolve_fatsort_target(mount)?;
|
||||||
|
|
||||||
|
ctx.log(LogLevel::Info, format!("Unmounting {mount_display}", mount_display = mount.display()));
|
||||||
|
let (code, detail) = capture("sudo", &["-n", "umount", mount.to_str().unwrap_or("")])?;
|
||||||
|
if code != 0 {
|
||||||
|
ctx.log(
|
||||||
|
LogLevel::Warn,
|
||||||
|
format!(
|
||||||
|
"sudo -n umount failed (exit {code}) {detail} — run manually: sudo umount {mount_display}",
|
||||||
|
mount_display = mount.display()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.log(LogLevel::Info, format!("Running fatsort on {target}…"));
|
||||||
|
let (code, detail) = capture("sudo", &["-n", "fatsort", &target])?;
|
||||||
|
if code != 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"fatsort failed (exit {code}) {detail} — run manually: sudo fatsort {target}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_fatsort_target(mount: &Path) -> Result<String, String> {
|
||||||
|
if !mount.is_dir() {
|
||||||
|
return Ok(mount.display().to_string());
|
||||||
|
}
|
||||||
|
let (code, out) = capture(
|
||||||
|
"findmnt",
|
||||||
|
&["-n", "-o", "SOURCE", "-T", mount.to_str().unwrap_or("")],
|
||||||
|
)?;
|
||||||
|
if code != 0 || out.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"could not resolve a block device for {}",
|
||||||
|
mount.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── subprocess plumbing ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Run a command, streaming its output lines to `on_line`. Returns Err on
|
||||||
|
/// failure or abort. Lines returned `true` from `on_line` are treated as
|
||||||
|
/// consumed (progress); unconsumed lines are logged.
|
||||||
|
fn stream<F>(
|
||||||
|
ctx: &WorkerCtx,
|
||||||
|
cmd: &str,
|
||||||
|
args: &[String],
|
||||||
|
mut on_line: F,
|
||||||
|
) -> Result<(), String>
|
||||||
|
where
|
||||||
|
F: FnMut(&str) -> bool,
|
||||||
|
{
|
||||||
|
let mut child = Command::new(cmd)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("could not start {cmd}: {e}"))?;
|
||||||
|
|
||||||
|
let mut stdout = child.stdout.take().unwrap();
|
||||||
|
let mut stderr = child.stderr.take().unwrap();
|
||||||
|
|
||||||
|
let (err_tx, err_rx) = mpsc::channel::<String>();
|
||||||
|
let err_handle = thread::spawn(move || {
|
||||||
|
let mut buf = [0u8; 8192];
|
||||||
|
let mut partial: Vec<u8> = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stderr.read(&mut buf) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
partial.extend_from_slice(&buf[..n]);
|
||||||
|
let mut pos = 0;
|
||||||
|
for (i, b) in partial.iter().enumerate() {
|
||||||
|
if *b == b'\n' || *b == b'\r' {
|
||||||
|
let line = String::from_utf8_lossy(&partial[pos..i]).trim().to_string();
|
||||||
|
if !line.is_empty() {
|
||||||
|
let _ = err_tx.send(line);
|
||||||
|
}
|
||||||
|
pos = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
partial.drain(0..pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !partial.is_empty() {
|
||||||
|
let line = String::from_utf8_lossy(&partial).trim().to_string();
|
||||||
|
if !line.is_empty() {
|
||||||
|
let _ = err_tx.send(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut buf = [0u8; 8192];
|
||||||
|
let mut partial: Vec<u8> = Vec::new();
|
||||||
|
loop {
|
||||||
|
if ctx.abort.load(Ordering::Relaxed) {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
return Err("aborted".to_string());
|
||||||
|
}
|
||||||
|
match stdout.read(&mut buf) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Err(_) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
partial.extend_from_slice(&buf[..n]);
|
||||||
|
let mut pos = 0;
|
||||||
|
for (i, b) in partial.iter().enumerate() {
|
||||||
|
if *b == b'\n' || *b == b'\r' {
|
||||||
|
let line = String::from_utf8_lossy(&partial[pos..i]).trim().to_string();
|
||||||
|
if !line.is_empty() && !on_line(&line) {
|
||||||
|
ctx.log(LogLevel::Info, line);
|
||||||
|
}
|
||||||
|
pos = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
partial.drain(0..pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !partial.is_empty() {
|
||||||
|
let line = String::from_utf8_lossy(&partial).trim().to_string();
|
||||||
|
if !line.is_empty() && !on_line(&line) {
|
||||||
|
ctx.log(LogLevel::Info, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = err_handle.join();
|
||||||
|
for line in err_rx.try_iter() {
|
||||||
|
if !line.is_empty() && !on_line(&line) {
|
||||||
|
let lower = line.to_lowercase();
|
||||||
|
let level = if lower.contains("error") || lower.contains("failed") {
|
||||||
|
LogLevel::Error
|
||||||
|
} else {
|
||||||
|
LogLevel::Info
|
||||||
|
};
|
||||||
|
ctx.log(level, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = child.wait().map_err(|e| format!("{cmd} failed: {e}"))?;
|
||||||
|
if ctx.abort.load(Ordering::Relaxed) {
|
||||||
|
return Err("aborted".to_string());
|
||||||
|
}
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"{cmd} failed (exit {})",
|
||||||
|
status.code().unwrap_or(-1)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
mod app;
|
||||||
|
mod config;
|
||||||
|
mod engine;
|
||||||
|
mod progress;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
fn print_help() {
|
||||||
|
println!("dap-tui — device sync TUI (ratatui)");
|
||||||
|
println!();
|
||||||
|
println!("Usage: dap-tui [OPTIONS]");
|
||||||
|
println!();
|
||||||
|
println!("Options:");
|
||||||
|
println!(" --config <PATH> use an alternate config file");
|
||||||
|
println!(" -s, --skip-mirror skip the StorageBox → local mirror step");
|
||||||
|
println!(" -V, --version print version");
|
||||||
|
println!(" -h, --help show this help");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let mut config_path: Option<String> = None;
|
||||||
|
let mut skip_mirror = false;
|
||||||
|
|
||||||
|
let mut i = 1;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"-h" | "--help" => {
|
||||||
|
print_help();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
"-V" | "--version" => {
|
||||||
|
println!("dap-tui {}", app::VERSION);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
"--config" => {
|
||||||
|
i += 1;
|
||||||
|
if let Some(p) = args.get(i) {
|
||||||
|
config_path = Some(p.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"-s" | "--skip-mirror" => skip_mirror = true,
|
||||||
|
other if other.starts_with("--config=") => {
|
||||||
|
config_path = Some(other[9..].to_string());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cfg = config::load(config_path)?;
|
||||||
|
let mut app = app::App::new(cfg, skip_mirror)?;
|
||||||
|
app.run()
|
||||||
|
}
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
/// Parsers for rclone and rsync progress output. Hand-rolled, no regex dep.
|
||||||
|
|
||||||
|
const AUDIO_EXTS: &[&str] = &[".m4a", ".mp3", ".aac", ".ogg", ".wav", ".wv", ".ape", ".flac", ".m4b"];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum RsyncLine {
|
||||||
|
/// Total file count announced by rsync ("Transfer starting: N files")
|
||||||
|
Total(u64),
|
||||||
|
/// A per-file progress update
|
||||||
|
Progress(RsyncProgress),
|
||||||
|
/// A transferred audio file path (current file being handled)
|
||||||
|
File(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct RsyncProgress {
|
||||||
|
pub done: u64,
|
||||||
|
pub total: u64,
|
||||||
|
pub pct: u16,
|
||||||
|
pub speed: String,
|
||||||
|
pub eta: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ends_with_audio_ext(s: &str) -> bool {
|
||||||
|
let lower = s.to_ascii_lowercase();
|
||||||
|
AUDIO_EXTS.iter().any(|e| lower.ends_with(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_u64(s: &str) -> Option<u64> {
|
||||||
|
s.chars()
|
||||||
|
.filter(|c| c.is_ascii_digit())
|
||||||
|
.collect::<String>()
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_pct(s: &str) -> Option<u16> {
|
||||||
|
for tok in s.split_whitespace() {
|
||||||
|
if tok.ends_with('%') && tok.len() > 1 {
|
||||||
|
if let Some(p) = tok[..tok.len() - 1].parse::<u16>().ok() {
|
||||||
|
return Some(p.min(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_speed(s: &str) -> String {
|
||||||
|
for tok in s.split_whitespace() {
|
||||||
|
if tok.ends_with("/s") && tok.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
|
||||||
|
return tok.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_eta(s: &str) -> String {
|
||||||
|
if let Some(idx) = s.find("ETA") {
|
||||||
|
let rest = s[idx + 3..].trim();
|
||||||
|
if !rest.is_empty() {
|
||||||
|
return rest.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a single rsync progress line.
|
||||||
|
pub fn parse_rsync_line(line: &str) -> Option<RsyncLine> {
|
||||||
|
let line = line.trim();
|
||||||
|
|
||||||
|
if let Some(rest) = line.strip_prefix("Transfer starting:") {
|
||||||
|
if let Some(n) = parse_u64(rest) {
|
||||||
|
return Some(RsyncLine::Total(n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-file progress: " 512 100% 1.23MB/s 0:00:01 (xfer#12, to-check=38/200)"
|
||||||
|
if line.contains("xfer#") || line.contains("xfr#") || line.contains("to-check=") {
|
||||||
|
let mut done = 0;
|
||||||
|
let mut total = 0;
|
||||||
|
if let Some(idx) = line.find('#') {
|
||||||
|
// take the integer immediately after '#'
|
||||||
|
let rest: String = line[idx + 1..]
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_ascii_digit())
|
||||||
|
.collect();
|
||||||
|
if let Ok(n) = rest.parse::<u64>() {
|
||||||
|
done = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(idx) = line.find("to-check=") {
|
||||||
|
let rest = &line[idx + 9..];
|
||||||
|
let mut it = rest.split(|c: char| c == '/' || c == ' ' || c == ')');
|
||||||
|
let _remaining = it.next().and_then(parse_u64);
|
||||||
|
if let Some(b) = it.next().and_then(parse_u64) {
|
||||||
|
total = b; // "to-check=remaining/total" → total is second value
|
||||||
|
}
|
||||||
|
} else if let Some(idx) = line.find("to-chk=") {
|
||||||
|
let rest = &line[idx + 7..];
|
||||||
|
let mut it = rest.split(|c: char| c == '/' || c == ' ' || c == ')');
|
||||||
|
let _remaining = it.next().and_then(parse_u64);
|
||||||
|
if let Some(b) = it.next().and_then(parse_u64) {
|
||||||
|
total = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pct = find_pct(line).unwrap_or(0);
|
||||||
|
return Some(RsyncLine::Progress(RsyncProgress {
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
pct,
|
||||||
|
speed: find_speed(line),
|
||||||
|
eta: find_eta(line),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain audio file path line (transferred file being processed).
|
||||||
|
if ends_with_audio_ext(line) {
|
||||||
|
// Skip stats-looking lines that just happen to end in an audio ext.
|
||||||
|
if find_pct(line).is_none() && !line.starts_with(' ') {
|
||||||
|
return Some(RsyncLine::File(line.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct RcloneStats {
|
||||||
|
pub done: String,
|
||||||
|
pub total: String,
|
||||||
|
pub pct: u16,
|
||||||
|
pub speed: String,
|
||||||
|
pub eta: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a rclone `--stats-one-line` "Transferred:" line.
|
||||||
|
pub fn parse_rclone_stats(line: &str) -> Option<RcloneStats> {
|
||||||
|
let line = line.trim();
|
||||||
|
if !line.contains(" / ") || !line.contains('%') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let body = line
|
||||||
|
.split_once(':')
|
||||||
|
.map(|(_, rest)| rest.trim())
|
||||||
|
.unwrap_or(line);
|
||||||
|
if !body.contains(" / ") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut parts = body.split(',');
|
||||||
|
let sizes = parts.next()?.trim();
|
||||||
|
let (done, total) = sizes.split_once('/')?;
|
||||||
|
let done = done.trim().to_string();
|
||||||
|
let total = total.trim().to_string();
|
||||||
|
|
||||||
|
let pct = parts
|
||||||
|
.next()
|
||||||
|
.and_then(|p| {
|
||||||
|
let p = p.trim().trim_end_matches('%');
|
||||||
|
p.parse::<u16>().ok()
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
|
.min(100);
|
||||||
|
|
||||||
|
let speed = parts.next().map(|s| s.trim().to_string()).unwrap_or_default();
|
||||||
|
|
||||||
|
let eta = parts
|
||||||
|
.next()
|
||||||
|
.map(|s| s.trim().trim_start_matches("ETA").trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Some(RcloneStats { done, total, pct, speed, eta })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_rclone_stats_line() {
|
||||||
|
let s = parse_rclone_stats(
|
||||||
|
"Transferred: 512.000 MiB / 1.000 GiB, 50%, 25.00 MiB/s, ETA 20s",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(s.done, "512.000 MiB");
|
||||||
|
assert_eq!(s.total, "1.000 GiB");
|
||||||
|
assert_eq!(s.pct, 50);
|
||||||
|
assert_eq!(s.speed, "25.00 MiB/s");
|
||||||
|
assert_eq!(s.eta, "20s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_stats_line() {
|
||||||
|
assert!(parse_rclone_stats("Elapsed time: 5.0s").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_rsync_progress() {
|
||||||
|
let line = " 512 100% 1.23MB/s 0:00:01 (xfer#12, to-check=38/200)";
|
||||||
|
match parse_rsync_line(line) {
|
||||||
|
Some(RsyncLine::Progress(p)) => {
|
||||||
|
assert_eq!(p.done, 12);
|
||||||
|
assert_eq!(p.total, 200);
|
||||||
|
assert_eq!(p.pct, 100);
|
||||||
|
assert_eq!(p.speed, "1.23MB/s");
|
||||||
|
}
|
||||||
|
other => panic!("expected progress, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_rsync_total() {
|
||||||
|
match parse_rsync_line("Transfer starting: 150 files") {
|
||||||
|
Some(RsyncLine::Total(n)) => assert_eq!(n, 150),
|
||||||
|
other => panic!("expected total, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_rsync_file_line() {
|
||||||
|
match parse_rsync_line("Artist - Album/track1.mp3") {
|
||||||
|
Some(RsyncLine::File(p)) => assert_eq!(p, "Artist - Album/track1.mp3"),
|
||||||
|
other => panic!("expected file, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
use crate::app::{relative_ago, App, Tab};
|
||||||
|
use crate::engine::{LogLevel, StepStatus};
|
||||||
|
use crate::ui::theme;
|
||||||
|
use crate::ui::truncate;
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
|
|
||||||
|
// ── title bar ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn draw_title(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::styled(
|
||||||
|
" dap-tui ",
|
||||||
|
Style::default()
|
||||||
|
.fg(theme::BG)
|
||||||
|
.bg(theme::ACCENT)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("device sync", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
"StorageBox → local → player",
|
||||||
|
Style::default().fg(theme::DIM),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut right_spans = vec![
|
||||||
|
Span::styled(
|
||||||
|
if app.is_syncing() {
|
||||||
|
format!("{} syncing", app.spinner())
|
||||||
|
} else {
|
||||||
|
"● ready".to_string()
|
||||||
|
},
|
||||||
|
Style::default().fg(if app.is_syncing() { theme::CYAN } else { theme::GREEN }),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
truncate(
|
||||||
|
&app.config.config_file.display().to_string(),
|
||||||
|
(area.width.max(9) - 8) as usize,
|
||||||
|
),
|
||||||
|
Style::default().fg(theme::DIM),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// pad to full width
|
||||||
|
let left_w: usize = spans.iter().map(|s| s.width()).sum();
|
||||||
|
let right_w: usize = right_spans.iter().map(|s| s.width()).sum();
|
||||||
|
let total = area.width as usize;
|
||||||
|
let pad = total.saturating_sub(left_w + right_w);
|
||||||
|
spans.push(Span::raw(" ".repeat(pad)));
|
||||||
|
spans.append(&mut right_spans);
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG_ALT)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── gradient rule ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn draw_rule(f: &mut Frame, area: Rect, _app: &App) {
|
||||||
|
let mut spans = Vec::with_capacity(area.width as usize);
|
||||||
|
let colors = [theme::ACCENT, theme::CYAN, theme::MAGENTA, theme::DIM];
|
||||||
|
for i in 0..area.width {
|
||||||
|
let t = i as f32 / area.width.max(1) as f32;
|
||||||
|
let idx = if t < 0.25 {
|
||||||
|
0
|
||||||
|
} else if t < 0.5 {
|
||||||
|
1
|
||||||
|
} else if t < 0.75 {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
3
|
||||||
|
};
|
||||||
|
spans.push(Span::styled("─", Style::default().fg(colors[idx])));
|
||||||
|
}
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sidebar ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn draw_sidebar(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::RIGHT)
|
||||||
|
.border_style(Style::default().fg(theme::DIM))
|
||||||
|
.style(Style::default().bg(theme::BG_ALT))
|
||||||
|
.title(Span::styled(
|
||||||
|
" DEVICES ",
|
||||||
|
Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
|
||||||
|
let [head, list, foot] =
|
||||||
|
Layout::vertical([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)])
|
||||||
|
.areas(inner);
|
||||||
|
|
||||||
|
let mounted = app.config.devices.iter().filter(|d| d.mounted).count();
|
||||||
|
let syncing = app.engine.state.lock().map(|s| s.running).unwrap_or(false);
|
||||||
|
|
||||||
|
let head_line = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{} devices", app.config.devices.len()),
|
||||||
|
Style::default().fg(theme::FG),
|
||||||
|
),
|
||||||
|
Span::raw(" · "),
|
||||||
|
Span::styled(
|
||||||
|
format!("{mounted} mounted"),
|
||||||
|
Style::default().fg(if mounted > 0 { theme::GREEN } else { theme::DIM }),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(head_line).style(Style::default().bg(theme::BG_ALT)),
|
||||||
|
head,
|
||||||
|
);
|
||||||
|
|
||||||
|
draw_device_list(f, list, app, syncing);
|
||||||
|
|
||||||
|
let foot_line = Line::from(vec![
|
||||||
|
Span::styled("s sync", Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::styled(" · ", Style::default().fg(theme::DIM)),
|
||||||
|
Span::styled("r refresh", Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD)),
|
||||||
|
if app.skip_mirror {
|
||||||
|
Span::styled(" · mirror off", Style::default().fg(theme::YELLOW))
|
||||||
|
} else {
|
||||||
|
Span::styled("", Style::default())
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(foot_line).style(Style::default().bg(theme::BG_ALT)),
|
||||||
|
foot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_device_list(f: &mut Frame, area: Rect, app: &App, syncing: bool) {
|
||||||
|
if app.config.devices.is_empty() {
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(Span::styled(
|
||||||
|
" no devices configured",
|
||||||
|
Style::default().fg(theme::DIM),
|
||||||
|
)))
|
||||||
|
.style(Style::default().bg(theme::BG_ALT)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let item_h: u16 = 2;
|
||||||
|
let visible = (area.height / item_h) as usize;
|
||||||
|
let start = app.sidebar_scroll.min(app.config.devices.len());
|
||||||
|
|
||||||
|
let rows = Layout::vertical(
|
||||||
|
(0..visible)
|
||||||
|
.map(|_| Constraint::Length(item_h))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let sync_device = if syncing {
|
||||||
|
app.engine.state.lock().unwrap().device.clone()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
for (i, row) in rows.iter().enumerate() {
|
||||||
|
let idx = start + i;
|
||||||
|
let Some(device) = app.config.devices.get(idx) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let selected = idx == app.selected;
|
||||||
|
|
||||||
|
let row_style = if selected {
|
||||||
|
Style::default()
|
||||||
|
.bg(theme::ACCENT)
|
||||||
|
.fg(theme::BG)
|
||||||
|
} else {
|
||||||
|
Style::default().bg(theme::BG_ALT).fg(theme::FG)
|
||||||
|
};
|
||||||
|
let mut spans: Vec<Span> = Vec::new();
|
||||||
|
|
||||||
|
// status icon
|
||||||
|
let is_this = sync_device.as_ref().map(|d| d == &device.name).unwrap_or(false);
|
||||||
|
if is_this {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
app.spinner().to_string(),
|
||||||
|
Style::default().fg(theme::BG),
|
||||||
|
));
|
||||||
|
} else if device.mounted {
|
||||||
|
spans.push(Span::styled("●", Style::default().fg(if selected { theme::BG } else { theme::GREEN })));
|
||||||
|
} else {
|
||||||
|
spans.push(Span::styled("○", Style::default().fg(theme::DIM)));
|
||||||
|
}
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
|
|
||||||
|
// selection marker + name
|
||||||
|
let marker = if selected { "▸ " } else { " " };
|
||||||
|
let name = truncate(&device.name, row.width.max(1) as usize - 8);
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!("{marker}{name}"),
|
||||||
|
Style::default().add_modifier(if selected { Modifier::BOLD } else { Modifier::default() }),
|
||||||
|
));
|
||||||
|
|
||||||
|
f.render_widget(Paragraph::new(Line::from(spans)).style(row_style), *row);
|
||||||
|
|
||||||
|
// second line
|
||||||
|
let sub = if let Some(last) = device.last_sync {
|
||||||
|
format!(
|
||||||
|
" {} · {} · {}",
|
||||||
|
device.firmware.label(),
|
||||||
|
device.content_type,
|
||||||
|
relative_ago(last)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(" {} · {}", device.firmware.label(), device.content_type)
|
||||||
|
};
|
||||||
|
let sub_style = if selected {
|
||||||
|
Style::default().bg(theme::ACCENT).fg(theme::BG)
|
||||||
|
} else {
|
||||||
|
Style::default().bg(theme::BG_ALT).fg(theme::DIM)
|
||||||
|
};
|
||||||
|
let row2 = Rect {
|
||||||
|
y: row.y + 1,
|
||||||
|
..*row
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(Span::styled(truncate(&sub, row.width.max(1) as usize), sub_style)))
|
||||||
|
.style(sub_style),
|
||||||
|
row2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tab bar ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn draw_tabbar(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let mut spans: Vec<Span> = Vec::new();
|
||||||
|
for (i, tab) in Tab::ALL.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
|
}
|
||||||
|
let active = app.tab == *tab;
|
||||||
|
let syncing_badge = *tab == Tab::Sync && app.is_syncing();
|
||||||
|
let text = format!(" {} {} ", tab.number(), tab.label());
|
||||||
|
if active {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
text,
|
||||||
|
Style::default()
|
||||||
|
.bg(theme::ACCENT)
|
||||||
|
.fg(theme::BG)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
text,
|
||||||
|
Style::default().bg(theme::BG_ALT).fg(theme::DIM),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if syncing_badge {
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
|
spans.push(Span::styled(
|
||||||
|
app.spinner().to_string(),
|
||||||
|
Style::default().fg(theme::CYAN),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let rest: u16 = area
|
||||||
|
.width
|
||||||
|
.saturating_sub(spans.iter().map(|s| s.width() as u16).sum());
|
||||||
|
if rest > 0 {
|
||||||
|
spans.push(Span::raw(" ".repeat(rest as usize)));
|
||||||
|
}
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── status bar ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let (msg, style) = status_text(app);
|
||||||
|
let left_spans: Vec<Span> = vec![
|
||||||
|
Span::styled(" ", Style::default()),
|
||||||
|
Span::styled(msg, style),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mounted = app.config.devices.iter().filter(|d| d.mounted).count();
|
||||||
|
let now = chrono::Local::now().format("%H:%M:%S").to_string();
|
||||||
|
let right_spans = vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{} devices · {mounted} mounted", app.config.devices.len()),
|
||||||
|
Style::default().fg(theme::DIM),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
format!("v{} · {now}", crate::app::VERSION),
|
||||||
|
Style::default().fg(theme::DIM),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let total = area.width as usize;
|
||||||
|
let lw: usize = left_spans.iter().map(|s| s.width()).sum();
|
||||||
|
let rw: usize = right_spans.iter().map(|s| s.width()).sum();
|
||||||
|
let pad = total.saturating_sub(lw + rw);
|
||||||
|
let mut all = left_spans;
|
||||||
|
all.push(Span::raw(" ".repeat(pad)));
|
||||||
|
all.extend(right_spans);
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(all)).style(Style::default().bg(theme::BG_ALT)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_text(app: &App) -> (String, Style) {
|
||||||
|
if let Some((msg, level)) = &app.status_msg {
|
||||||
|
let style = match level {
|
||||||
|
LogLevel::Error => Style::default().fg(theme::RED).add_modifier(Modifier::BOLD),
|
||||||
|
LogLevel::Warn => Style::default().fg(theme::YELLOW),
|
||||||
|
_ => Style::default().fg(theme::GREEN),
|
||||||
|
};
|
||||||
|
return (format!(" {msg}"), style);
|
||||||
|
}
|
||||||
|
if app.is_syncing() {
|
||||||
|
let dev = app
|
||||||
|
.engine
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.device
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default();
|
||||||
|
return (
|
||||||
|
format!(" {} Syncing {dev}…", app.spinner()),
|
||||||
|
Style::default().fg(theme::CYAN),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(" Ready".to_string(), Style::default().fg(theme::DIM))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── key bar ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn draw_keys(f: &mut Frame, area: Rect, _app: &App) {
|
||||||
|
let keys: &[(&str, &str)] = &[
|
||||||
|
("↑↓/jk", "nav"),
|
||||||
|
("s", "sync"),
|
||||||
|
("a", "abort"),
|
||||||
|
("Tab/1-4", "tab"),
|
||||||
|
("r", "refresh"),
|
||||||
|
("f", "follow"),
|
||||||
|
("?", "help"),
|
||||||
|
("q", "quit"),
|
||||||
|
];
|
||||||
|
let mut spans: Vec<Span> = Vec::new();
|
||||||
|
let mut first = true;
|
||||||
|
for (key, label) in keys {
|
||||||
|
if !first {
|
||||||
|
spans.push(Span::styled(" ", Style::default().fg(theme::DIM)));
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!(" {key} "),
|
||||||
|
Style::default().fg(theme::ACCENT).bg(theme::BG_ALT).add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
|
spans.push(Span::styled(*label, Style::default().fg(theme::DIM)));
|
||||||
|
}
|
||||||
|
let rest = area.width.saturating_sub(spans.iter().map(|s| s.width() as u16).sum());
|
||||||
|
if rest > 0 {
|
||||||
|
spans.push(Span::raw(" ".repeat(rest as usize)));
|
||||||
|
}
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(spans))
|
||||||
|
.style(Style::default().bg(theme::BG))
|
||||||
|
.alignment(Alignment::Left),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── shared helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn step_icon(status: StepStatus, app: &App) -> Span<'static> {
|
||||||
|
match status {
|
||||||
|
StepStatus::Done => Span::styled("●", Style::default().fg(theme::GREEN)),
|
||||||
|
StepStatus::Active => Span::styled(
|
||||||
|
app.spinner().to_string(),
|
||||||
|
Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
StepStatus::Pending => Span::styled("○", Style::default().fg(theme::DIM)),
|
||||||
|
StepStatus::Error => Span::styled("✗", Style::default().fg(theme::RED).add_modifier(Modifier::BOLD)),
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
pub mod components;
|
||||||
|
pub mod tabs;
|
||||||
|
pub mod theme;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Style, Stylize};
|
||||||
|
use ratatui::widgets::{Block, Borders, Clear};
|
||||||
|
|
||||||
|
use theme::{BG, DIM};
|
||||||
|
|
||||||
|
pub fn truncate(s: &str, max: usize) -> String {
|
||||||
|
if s.chars().count() <= max {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
|
||||||
|
out.push('…');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw(f: &mut Frame, app: &App) {
|
||||||
|
let area = f.area();
|
||||||
|
f.render_widget(Block::default().style(Style::default().bg(BG)), area);
|
||||||
|
|
||||||
|
let v = Layout::vertical([
|
||||||
|
Constraint::Length(2), // title
|
||||||
|
Constraint::Length(1), // gradient rule
|
||||||
|
Constraint::Min(0), // body
|
||||||
|
Constraint::Length(1), // status
|
||||||
|
Constraint::Length(1), // keys
|
||||||
|
]);
|
||||||
|
let [title, rule, body, status, keys] = v.areas(area);
|
||||||
|
|
||||||
|
components::draw_title(f, title, app);
|
||||||
|
components::draw_rule(f, rule, app);
|
||||||
|
|
||||||
|
let [side, main] = Layout::horizontal([Constraint::Length(34), Constraint::Min(0)]).areas(body);
|
||||||
|
components::draw_sidebar(f, side, app);
|
||||||
|
draw_main(f, main, app);
|
||||||
|
|
||||||
|
components::draw_status(f, status, app);
|
||||||
|
components::draw_keys(f, keys, app);
|
||||||
|
|
||||||
|
if app.show_help {
|
||||||
|
draw_help(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_main(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let [tabbar, content] =
|
||||||
|
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
|
||||||
|
components::draw_tabbar(f, tabbar, app);
|
||||||
|
match app.tab {
|
||||||
|
crate::app::Tab::Dashboard => tabs::dashboard::draw(f, content, app),
|
||||||
|
crate::app::Tab::Sync => tabs::sync::draw(f, content, app),
|
||||||
|
crate::app::Tab::Logs => tabs::logs::draw(f, content, app),
|
||||||
|
crate::app::Tab::Config => tabs::config_view::draw(f, content, app),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_help(f: &mut Frame) {
|
||||||
|
let area = f.area();
|
||||||
|
let box_w = 58;
|
||||||
|
let box_h = 16;
|
||||||
|
let x = (area.width.saturating_sub(box_w)) / 2;
|
||||||
|
let y = (area.height.saturating_sub(box_h)) / 2;
|
||||||
|
let rect = Rect::new(
|
||||||
|
x.max(0),
|
||||||
|
y.max(0),
|
||||||
|
box_w.min(area.width),
|
||||||
|
box_h.min(area.height),
|
||||||
|
);
|
||||||
|
f.render_widget(Clear, rect);
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(crate::ui::theme::ACCENT))
|
||||||
|
.style(Style::default().bg(crate::ui::theme::PANEL_BG))
|
||||||
|
.title(" KEYBINDINGS ".cyan().bold());
|
||||||
|
let lines = [
|
||||||
|
" ↑↓ / j k move selection",
|
||||||
|
" s / Enter sync selected device",
|
||||||
|
" a abort running sync",
|
||||||
|
" Tab / 1-4 switch tab",
|
||||||
|
" r refresh devices",
|
||||||
|
" f toggle log follow",
|
||||||
|
" u / d scroll logs",
|
||||||
|
" x clear logs",
|
||||||
|
" ? / h this help",
|
||||||
|
" q / Esc quit",
|
||||||
|
"",
|
||||||
|
" ← → (todo: device nav)",
|
||||||
|
];
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
spans.push(ratatui::text::Line::raw(""));
|
||||||
|
}
|
||||||
|
let styled = if line.contains('/') {
|
||||||
|
ratatui::text::Span::styled(*line, ratatui::style::Style::default().fg(crate::ui::theme::FG))
|
||||||
|
} else {
|
||||||
|
ratatui::text::Span::styled(*line, ratatui::style::Style::default().fg(DIM))
|
||||||
|
};
|
||||||
|
spans.push(ratatui::text::Line::from(styled));
|
||||||
|
}
|
||||||
|
f.render_widget(ratatui::widgets::Paragraph::new(ratatui::text::Text::from(spans)).block(block), rect);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use crate::app::App;
|
||||||
|
use crate::ui::theme;
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, BorderType, Paragraph};
|
||||||
|
|
||||||
|
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let [head, body] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
|
||||||
|
|
||||||
|
let path = app.config.config_file.display().to_string();
|
||||||
|
let head_line = Line::from(vec![
|
||||||
|
Span::styled(" config", Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(path.clone(), Style::default().fg(theme::DIM)),
|
||||||
|
]);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(head_line).style(Style::default().bg(theme::BG)),
|
||||||
|
head,
|
||||||
|
);
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(theme::DIM))
|
||||||
|
.style(Style::default().bg(theme::PANEL_BG));
|
||||||
|
let inner = block.inner(body);
|
||||||
|
f.render_widget(block, body);
|
||||||
|
|
||||||
|
let lines: Vec<Line> = app
|
||||||
|
.config
|
||||||
|
.raw_text
|
||||||
|
.lines()
|
||||||
|
.map(syntax_line)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let content_h = inner.height.saturating_sub(1) as usize;
|
||||||
|
let offset = lines.len().saturating_sub(content_h);
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(lines)
|
||||||
|
.scroll((offset as u16, 0))
|
||||||
|
.style(Style::default().bg(theme::PANEL_BG)),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn syntax_line(raw: &str) -> Line<'static> {
|
||||||
|
let line = raw;
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Line::raw("");
|
||||||
|
}
|
||||||
|
if trimmed.starts_with('#') {
|
||||||
|
return Line::from(Span::styled(line.to_string(), Style::default().fg(theme::DIM)));
|
||||||
|
}
|
||||||
|
if trimmed.starts_with('[') {
|
||||||
|
return Line::from(Span::styled(
|
||||||
|
line.to_string(),
|
||||||
|
Style::default().fg(theme::MAGENTA).add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(idx) = line.find('=') {
|
||||||
|
let key = line[..idx].trim_end();
|
||||||
|
let value = line[idx + 1..].trim_start();
|
||||||
|
let value_style = if value.starts_with('"') {
|
||||||
|
Style::default().fg(theme::GREEN)
|
||||||
|
} else if value.starts_with('[') {
|
||||||
|
Style::default().fg(theme::CYAN)
|
||||||
|
} else if value.starts_with("true") || value.starts_with("false") {
|
||||||
|
Style::default().fg(theme::YELLOW)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(theme::ORANGE)
|
||||||
|
};
|
||||||
|
return Line::from(vec![
|
||||||
|
Span::styled(format!("{key}"), Style::default().fg(theme::ACCENT)),
|
||||||
|
Span::styled(" = ", Style::default().fg(theme::DIM)),
|
||||||
|
Span::styled(value.to_string(), value_style),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Line::from(Span::styled(line.to_string(), Style::default().fg(theme::FG)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
use crate::app::{relative_ago, App};
|
||||||
|
use crate::ui::theme;
|
||||||
|
use crate::ui::truncate;
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, BorderType, Paragraph};
|
||||||
|
|
||||||
|
fn card(title: &str) -> Block<'static> {
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(theme::DIM))
|
||||||
|
.style(Style::default().bg(theme::PANEL_BG))
|
||||||
|
.title(Span::styled(
|
||||||
|
format!(" {title} "),
|
||||||
|
Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kv<'a>(label: &str, value: String, value_style: Style) -> Line<'a> {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(" {label:<12}"),
|
||||||
|
Style::default().fg(theme::DIM),
|
||||||
|
),
|
||||||
|
Span::styled(value, value_style),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn firmware_badge(fw: &str) -> Span<'static> {
|
||||||
|
let color = match fw {
|
||||||
|
"rockbox" => theme::MAGENTA,
|
||||||
|
"android" => theme::CYAN,
|
||||||
|
"mlove" => theme::ORANGE,
|
||||||
|
_ => theme::GREEN,
|
||||||
|
};
|
||||||
|
Span::styled(
|
||||||
|
format!(" [{fw}] "),
|
||||||
|
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let Some(device) = app.selected_device() else {
|
||||||
|
draw_empty(f, area);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let [top, chain] = Layout::vertical([Constraint::Min(8), Constraint::Length(4)]).areas(area);
|
||||||
|
let [left, right] =
|
||||||
|
Layout::horizontal([Constraint::Percentage(55), Constraint::Percentage(45)]).areas(top);
|
||||||
|
|
||||||
|
// ── device card ──
|
||||||
|
let dev_block = card("DEVICE");
|
||||||
|
let inner = dev_block.inner(left);
|
||||||
|
f.render_widget(dev_block, left);
|
||||||
|
|
||||||
|
let status = if device.mounted {
|
||||||
|
Span::styled("● mounted", Style::default().fg(theme::GREEN))
|
||||||
|
} else {
|
||||||
|
Span::styled("○ not mounted", Style::default().fg(theme::RED))
|
||||||
|
};
|
||||||
|
let fw = device.firmware.label().to_string();
|
||||||
|
let last = match device.last_sync {
|
||||||
|
Some(dt) => {
|
||||||
|
if device.last_status == "ok" {
|
||||||
|
format!("{} (ok)", relative_ago(dt))
|
||||||
|
} else {
|
||||||
|
format!("{} (error)", relative_ago(dt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => "never".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
truncate(&device.name, inner.width.saturating_sub(4) as usize),
|
||||||
|
Style::default().fg(theme::FG).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
firmware_badge(&fw),
|
||||||
|
]),
|
||||||
|
Line::raw(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" status", Style::default().fg(theme::DIM)),
|
||||||
|
Span::raw(" "),
|
||||||
|
status.clone(),
|
||||||
|
]),
|
||||||
|
kv("firmware", fw.clone(), Style::default().fg(theme::FG)),
|
||||||
|
kv("content", device.content_type.clone(), Style::default().fg(theme::FG)),
|
||||||
|
kv("mount", device.mount_str(), Style::default().fg(theme::CYAN)),
|
||||||
|
kv("folder", device.music_folder.clone(), Style::default().fg(theme::CYAN)),
|
||||||
|
kv("last sync", last, Style::default().fg(theme::YELLOW)),
|
||||||
|
];
|
||||||
|
f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner);
|
||||||
|
|
||||||
|
// ── library card ──
|
||||||
|
let lib_block = card("LIBRARY");
|
||||||
|
let inner = lib_block.inner(right);
|
||||||
|
f.render_widget(lib_block, right);
|
||||||
|
|
||||||
|
let excludes = app.config.storagebox_excludes.len();
|
||||||
|
let lib_lines = vec![
|
||||||
|
kv(
|
||||||
|
"music",
|
||||||
|
app.config.local_music_dir.display().to_string(),
|
||||||
|
Style::default().fg(theme::CYAN),
|
||||||
|
),
|
||||||
|
kv(
|
||||||
|
"hoerspiele",
|
||||||
|
app.config.local_hoerspiele_dir.display().to_string(),
|
||||||
|
Style::default().fg(theme::CYAN),
|
||||||
|
),
|
||||||
|
Line::raw(""),
|
||||||
|
kv(
|
||||||
|
"storagebox",
|
||||||
|
app.config.storagebox_source.clone(),
|
||||||
|
Style::default().fg(theme::FG),
|
||||||
|
),
|
||||||
|
kv(
|
||||||
|
"source hsp",
|
||||||
|
app.config.storagebox_hoerspiele_source.clone(),
|
||||||
|
Style::default().fg(theme::FG),
|
||||||
|
),
|
||||||
|
kv(
|
||||||
|
"excludes",
|
||||||
|
format!("{excludes} patterns"),
|
||||||
|
Style::default().fg(theme::DIM),
|
||||||
|
),
|
||||||
|
Line::raw(""),
|
||||||
|
kv(
|
||||||
|
"output",
|
||||||
|
app.config.output_dir.display().to_string(),
|
||||||
|
Style::default().fg(theme::DIM),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
f.render_widget(Paragraph::new(lib_lines).style(Style::default().bg(theme::PANEL_BG)), inner);
|
||||||
|
|
||||||
|
// ── source chain ──
|
||||||
|
draw_chain(f, chain, app, device.name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_chain(f: &mut Frame, area: Rect, app: &App, device_name: String) {
|
||||||
|
let block = card("SYNC PATH");
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
|
||||||
|
let w = inner.width.saturating_sub(2) as usize;
|
||||||
|
// reserve: indent(2) + 2×arrow(20) + 3×chip brackets(9)
|
||||||
|
let avail = w.saturating_sub(31);
|
||||||
|
let seg1 = avail * 40 / 100;
|
||||||
|
let seg2 = avail * 30 / 100;
|
||||||
|
let seg3 = avail.saturating_sub(seg1 + seg2);
|
||||||
|
|
||||||
|
let src = truncate(&app.config.storagebox_source, seg1.max(3));
|
||||||
|
let local = truncate(&app.config.local_music_dir.display().to_string(), seg2.max(3));
|
||||||
|
let device = truncate(&device_name, seg3.max(3));
|
||||||
|
|
||||||
|
let mut line = vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
chip(&src),
|
||||||
|
arrow("rclone"),
|
||||||
|
chip(&local),
|
||||||
|
arrow("rsync"),
|
||||||
|
chip(&device),
|
||||||
|
];
|
||||||
|
let used: usize = line.iter().map(|s| s.width()).sum();
|
||||||
|
if used < inner.width as usize {
|
||||||
|
line.push(Span::raw(" ".repeat(inner.width as usize - used)));
|
||||||
|
}
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(line)).style(Style::default().bg(theme::PANEL_BG)),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chip(text: &str) -> Span<'static> {
|
||||||
|
Span::styled(
|
||||||
|
format!(" [{text}]"),
|
||||||
|
Style::default().fg(theme::BG).bg(theme::ACCENT).add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arrow(label: &str) -> Span<'static> {
|
||||||
|
Span::styled(
|
||||||
|
format!(" ─{label}▶ "),
|
||||||
|
Style::default().fg(theme::CYAN),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_empty(f: &mut Frame, area: Rect) {
|
||||||
|
let block = card("DEVICE");
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(vec![
|
||||||
|
Line::raw(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" No devices configured.",
|
||||||
|
Style::default().fg(theme::YELLOW),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" Add [[devices]] entries to your config.toml",
|
||||||
|
Style::default().fg(theme::DIM),
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
.style(Style::default().bg(theme::PANEL_BG)),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
use crate::app::App;
|
||||||
|
use crate::engine::LogLevel;
|
||||||
|
use crate::ui::theme;
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, BorderType, Paragraph};
|
||||||
|
|
||||||
|
fn level_style(level: LogLevel) -> Style {
|
||||||
|
match level {
|
||||||
|
LogLevel::Info => Style::default().fg(theme::FG),
|
||||||
|
LogLevel::Transfer => Style::default().fg(theme::CYAN),
|
||||||
|
LogLevel::Warn => Style::default().fg(theme::YELLOW),
|
||||||
|
LogLevel::Error => Style::default().fg(theme::RED).add_modifier(Modifier::BOLD),
|
||||||
|
LogLevel::Success => Style::default().fg(theme::GREEN),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn level_tag(level: LogLevel) -> &'static str {
|
||||||
|
match level {
|
||||||
|
LogLevel::Info => "INF",
|
||||||
|
LogLevel::Transfer => "XFER",
|
||||||
|
LogLevel::Warn => "WRN",
|
||||||
|
LogLevel::Error => "ERR",
|
||||||
|
LogLevel::Success => "OK",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let [head, body] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
|
||||||
|
|
||||||
|
let follow_tag = if app.log_follow {
|
||||||
|
"follow on".to_string()
|
||||||
|
} else {
|
||||||
|
"follow off".to_string()
|
||||||
|
};
|
||||||
|
let head_line = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(" {} log entries", app.logs.len()),
|
||||||
|
Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(" · "),
|
||||||
|
Span::styled(follow_tag, Style::default().fg(theme::DIM)),
|
||||||
|
Span::raw(" · "),
|
||||||
|
Span::styled(
|
||||||
|
"f toggle · u/d scroll · x clear",
|
||||||
|
Style::default().fg(theme::DIM),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(head_line).style(Style::default().bg(theme::BG)),
|
||||||
|
head,
|
||||||
|
);
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(theme::DIM))
|
||||||
|
.style(Style::default().bg(theme::PANEL_BG));
|
||||||
|
let inner = block.inner(body);
|
||||||
|
f.render_widget(block, body);
|
||||||
|
|
||||||
|
let lines: Vec<Line> = app
|
||||||
|
.logs
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
let ts = e.ts.format("%H:%M:%S").to_string();
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(format!(" {ts} "), Style::default().fg(theme::DIM)),
|
||||||
|
Span::styled(format!("{:>4}", level_tag(e.level)), level_style(e.level)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(e.msg.clone(), level_style(e.level)),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let content_h = inner.height.saturating_sub(1) as usize;
|
||||||
|
let offset = if app.log_follow {
|
||||||
|
lines.len().saturating_sub(content_h)
|
||||||
|
} else {
|
||||||
|
app.log_scroll.min(lines.len().saturating_sub(content_h))
|
||||||
|
};
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(lines)
|
||||||
|
.scroll((offset as u16, 0))
|
||||||
|
.style(Style::default().bg(theme::PANEL_BG)),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod config_view;
|
||||||
|
pub mod dashboard;
|
||||||
|
pub mod logs;
|
||||||
|
pub mod sync;
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
use crate::app::App;
|
||||||
|
use crate::engine::{StepState, StepStatus, SyncState};
|
||||||
|
use crate::ui::components::step_icon;
|
||||||
|
use crate::ui::theme;
|
||||||
|
use crate::ui::truncate;
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, BorderType, Gauge, Paragraph};
|
||||||
|
|
||||||
|
fn panel(title: &str) -> Block<'static> {
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(theme::DIM))
|
||||||
|
.style(Style::default().bg(theme::PANEL_BG))
|
||||||
|
.title(Span::styled(
|
||||||
|
format!(" {title} "),
|
||||||
|
Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let state = app.engine.state.lock().unwrap().clone();
|
||||||
|
|
||||||
|
let header = match (&state.device, state.running) {
|
||||||
|
(Some(name), true) => {
|
||||||
|
if state.aborting {
|
||||||
|
format!(" Aborting sync → {name}")
|
||||||
|
} else {
|
||||||
|
format!(" Syncing → {name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(name), false) => format!(" Ready to sync → {name}"),
|
||||||
|
_ => " Sync".to_string(),
|
||||||
|
};
|
||||||
|
let [head, body] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(Span::styled(
|
||||||
|
header,
|
||||||
|
Style::default()
|
||||||
|
.fg(theme::FG)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)))
|
||||||
|
.style(Style::default().bg(theme::BG)),
|
||||||
|
head,
|
||||||
|
);
|
||||||
|
|
||||||
|
if !state.running {
|
||||||
|
draw_idle(f, body, &state, app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let [steps, progress] =
|
||||||
|
Layout::vertical([Constraint::Min(6), Constraint::Length(6)]).areas(body);
|
||||||
|
draw_steps(f, steps, &state, app);
|
||||||
|
draw_progress(f, progress, &state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_idle(f: &mut Frame, area: Rect, state: &SyncState, _app: &App) {
|
||||||
|
let [info, hint] = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).areas(area);
|
||||||
|
|
||||||
|
let block = panel("LAST SYNC");
|
||||||
|
let inner = block.inner(info);
|
||||||
|
f.render_widget(block, info);
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
if let Some(ok) = state.result {
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
if ok { "✓" } else { "✗" },
|
||||||
|
Style::default().fg(if ok { theme::GREEN } else { theme::RED }).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(state.summary.clone(), Style::default().fg(theme::FG)),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
"No sync has been run yet.",
|
||||||
|
Style::default().fg(theme::DIM),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
if let Some(name) = &state.device {
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("Press ", Style::default().fg(theme::DIM)),
|
||||||
|
Span::styled(" s ", Style::default().fg(theme::BG).bg(theme::ACCENT).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::styled(format!(" to sync {name}"), Style::default().fg(theme::DIM)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner);
|
||||||
|
|
||||||
|
let hint_block = panel("PIPELINE");
|
||||||
|
let hint_inner = hint_block.inner(hint);
|
||||||
|
f.render_widget(hint_block, hint);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("1 ", Style::default().fg(theme::ACCENT)),
|
||||||
|
Span::styled("rclone sync", Style::default().fg(theme::FG)),
|
||||||
|
Span::styled(" → ", Style::default().fg(theme::DIM)),
|
||||||
|
Span::styled("2 ", Style::default().fg(theme::ACCENT)),
|
||||||
|
Span::styled("NFC diff", Style::default().fg(theme::FG)),
|
||||||
|
Span::styled(" → ", Style::default().fg(theme::DIM)),
|
||||||
|
Span::styled("3 ", Style::default().fg(theme::ACCENT)),
|
||||||
|
Span::styled("rsync", Style::default().fg(theme::FG)),
|
||||||
|
Span::styled(" → ", Style::default().fg(theme::DIM)),
|
||||||
|
Span::styled("4 ", Style::default().fg(theme::ACCENT)),
|
||||||
|
Span::styled("eject / fatsort", Style::default().fg(theme::FG)),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
.style(Style::default().bg(theme::PANEL_BG)),
|
||||||
|
hint_inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_steps(f: &mut Frame, area: Rect, state: &SyncState, app: &App) {
|
||||||
|
let block = panel("STEPS");
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
for step in &state.steps {
|
||||||
|
lines.push(step_line(step, app));
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
}
|
||||||
|
f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn step_line(step: &StepState, app: &App) -> Line<'static> {
|
||||||
|
let icon = step_icon(step.status, app);
|
||||||
|
let title_style = match step.status {
|
||||||
|
StepStatus::Active => Style::default().fg(theme::FG).add_modifier(Modifier::BOLD),
|
||||||
|
StepStatus::Done => Style::default().fg(theme::GREEN),
|
||||||
|
StepStatus::Error => Style::default().fg(theme::RED),
|
||||||
|
StepStatus::Pending => Style::default().fg(theme::DIM),
|
||||||
|
};
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
icon,
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(step.title.clone(), title_style),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(step.detail.clone(), Style::default().fg(theme::DIM)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_progress(f: &mut Frame, area: Rect, state: &SyncState) {
|
||||||
|
let block = panel("PROGRESS");
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
|
||||||
|
let active = state
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.position(|s| s.status == StepStatus::Active)
|
||||||
|
.unwrap_or(usize::MAX);
|
||||||
|
|
||||||
|
let (pct, done, total, speed, eta, current, label) = if active == 0 {
|
||||||
|
(
|
||||||
|
state.mirror.pct,
|
||||||
|
state.mirror.done.clone(),
|
||||||
|
state.mirror.total.clone(),
|
||||||
|
state.mirror.speed.clone(),
|
||||||
|
state.mirror.eta.clone(),
|
||||||
|
state.mirror.current.clone(),
|
||||||
|
"Mirroring local library",
|
||||||
|
)
|
||||||
|
} else if active == 1 {
|
||||||
|
(
|
||||||
|
state.device_prog.pct,
|
||||||
|
state.device_prog.done.clone(),
|
||||||
|
state.device_prog.total.clone(),
|
||||||
|
state.device_prog.speed.clone(),
|
||||||
|
state.device_prog.eta.clone(),
|
||||||
|
state.device_prog.current.clone(),
|
||||||
|
"Syncing device",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(0, String::new(), String::new(), String::new(), String::new(), String::new(), "Finalizing")
|
||||||
|
};
|
||||||
|
|
||||||
|
let [gauge_row, stats] = Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(inner);
|
||||||
|
|
||||||
|
let g = Gauge::default()
|
||||||
|
.block(Block::default().borders(Borders::BOTTOM).border_style(Style::default().fg(theme::DIM)))
|
||||||
|
.gauge_style(Style::default().fg(theme::ACCENT).bg(theme::BG_ALT))
|
||||||
|
.ratio((pct as f64) / 100.0)
|
||||||
|
.label(Span::styled(
|
||||||
|
format!(" {label} · {pct}% "),
|
||||||
|
Style::default().fg(theme::FG).add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
f.render_widget(g, gauge_row);
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
if !done.is_empty() || !total.is_empty() {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
if done.is_empty() { "…".to_string() } else { done.clone() },
|
||||||
|
Style::default().fg(theme::CYAN),
|
||||||
|
),
|
||||||
|
Span::styled(" / ", Style::default().fg(theme::DIM)),
|
||||||
|
Span::styled(
|
||||||
|
if total.is_empty() { "…".to_string() } else { total },
|
||||||
|
Style::default().fg(theme::CYAN),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
let mut right: Vec<Span> = Vec::new();
|
||||||
|
if !speed.is_empty() {
|
||||||
|
right.push(Span::styled(speed, Style::default().fg(theme::YELLOW)));
|
||||||
|
}
|
||||||
|
if !eta.is_empty() {
|
||||||
|
right.push(Span::styled(" ETA ", Style::default().fg(theme::DIM)));
|
||||||
|
right.push(Span::styled(eta, Style::default().fg(theme::YELLOW)));
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
truncate(¤t, inner.width.saturating_sub(2) as usize),
|
||||||
|
Style::default().fg(theme::DIM),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
}
|
||||||
|
let mut stats_line = lines;
|
||||||
|
stats_line.push(Line::from(
|
||||||
|
std::iter::once(Span::raw(" "))
|
||||||
|
.chain(right.into_iter())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
));
|
||||||
|
f.render_widget(Paragraph::new(stats_line).style(Style::default().bg(theme::PANEL_BG)), stats);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
use ratatui::style::Color;
|
||||||
|
|
||||||
|
// Tokyo Night palette
|
||||||
|
pub const BG: Color = Color::Rgb(0x1a, 0x1b, 0x26);
|
||||||
|
pub const BG_ALT: Color = Color::Rgb(0x16, 0x16, 0x1e);
|
||||||
|
pub const PANEL_BG: Color = Color::Rgb(0x1f, 0x23, 0x33);
|
||||||
|
pub const FG: Color = Color::Rgb(0xc0, 0xca, 0xf5);
|
||||||
|
pub const DIM: Color = Color::Rgb(0x56, 0x5f, 0x89);
|
||||||
|
pub const ACCENT: Color = Color::Rgb(0x7a, 0xa2, 0xf7);
|
||||||
|
pub const CYAN: Color = Color::Rgb(0x7d, 0xcf, 0xff);
|
||||||
|
pub const GREEN: Color = Color::Rgb(0x9e, 0xce, 0x6a);
|
||||||
|
pub const YELLOW: Color = Color::Rgb(0xe0, 0xaf, 0x68);
|
||||||
|
pub const RED: Color = Color::Rgb(0xf7, 0x76, 0x8e);
|
||||||
|
pub const MAGENTA: Color = Color::Rgb(0xbb, 0x9a, 0xf7);
|
||||||
|
pub const ORANGE: Color = Color::Rgb(0xff, 0x9e, 0x64);
|
||||||
Reference in New Issue
Block a user