UI Overhaul

- Windows support: drift now runs natively on Windows, using named pipes instead of Unix sockets.
- Song context menu: press G on any song for quick actions like play, queue, add to playlist, and song info
- Redesined command menu: the : prompt has a new, cleaner design
- Visualizer: a live audio visualizer now sits in the bottomleft corner
- Keybind hints: the most useful shortcuts are showen next to the volume control
This commit is contained in:
socatlolmeow 2026-10-11 17:49:53 +02:00
commit 1d579f5311
12 changed files with 1088 additions and 160 deletions

View file

@ -21,17 +21,17 @@ Stream your favourite music from YouTube Music — all in one client, in your te
## Features
- **YouTube Music streaming** — search and stream tracks directly from YouTube Music via `yt-dlp` and `mpv`
- **Vim-style keybinds** — navigate with `j`/`k`, enter commands with `:`, quit with `q`
- **Queue management** — add, remove, reorder, and clear tracks in your queue
- **Playlists** — save, load, append, and delete named playlists; import from YouTube/YouTube Music URLs
- **Shuffle & repeat** — toggle shuffle and cycle through off / repeat-all / repeat-one modes
- **Volume & seek control** — adjust volume and seek with configurable step sizes
- **Session restore** — automatically restores your queue and playback position on next launch
- **Artist search** — drill into an artist's discography from the results view
- **Command palette** — full `:command` interface (`:search`, `:play`, `:volume`, `:seek`, `:save`, `:load`, `:import`, and more)
- **Autoplay** — optionally keep playing through results when the queue ends
- **TOML config** — persistent config at `~/.config/drift/config.toml` (your platform's config directory); drift never rewrites it, so comments are preserved
- **YouTube Music streaming** - search and stream tracks directly from YouTube Music via `yt-dlp` and `mpv`
- **Vim-style keybinds** - navigate with `j`/`k`, enter commands with `:`, quit with `q`
- **Queue management** - add, remove, reorder, and clear tracks in your queue
- **Playlists** - save, load, append, and delete named playlists; import from YouTube/YouTube Music URLs
- **Shuffle & repeat** - toggle shuffle and cycle through off / repeat-all / repeat-one modes
- **Volume & seek control** - adjust volume and seek with configurable step sizes
- **Session restore** - automatically restores your queue and playback position on next launch
- **Artist search** - drill into an artist's discography from the results view
- **Command palette** - full `:command` interface (`:search`, `:play`, `:volume`, `:seek`, `:save`, `:load`, `:import`, and more)
- **Autoplay** - optionally keep playing through results when the queue ends
- **TOML config** - persistent config at `~/.config/drift/config.toml` (your platform's config directory); drift never rewrites it, so comments are preserved
---

BIN
archive.zip Normal file

Binary file not shown.

View file

@ -8,7 +8,7 @@ use ratatui::widgets::TableState;
use crate::command::{self, Amount, Command, COMMAND_NAMES};
use crate::config::{self, Config};
use crate::model::{split_artists, RepeatMode, Track};
use crate::model::{fmt_time, split_artists, RepeatMode, Track};
use crate::player::{Player, PlayerEvent};
use crate::session::Session;
use crate::store::Store;
@ -58,6 +58,8 @@ pub enum Mode {
ArtistPick,
ConfirmDelete,
Help,
Menu,
Info,
}
#[derive(Clone, Copy, PartialEq, Eq)]
@ -74,6 +76,52 @@ pub enum Level {
Error,
}
/// Hotkey value meaning "this menu entry has no hotkey".
pub const NO_KEY: char = '\0';
#[derive(Clone, PartialEq, Eq)]
pub enum MenuAction {
Play,
PlayNext,
Queue,
Artist,
PlaylistMenu,
PickPlaylist(String),
NewPlaylist,
Remove,
Info,
QueueAll,
PlayAll,
ClearQueue,
SaveQueue,
OpenPlaylist,
AppendPlaylist,
DeletePlaylist,
}
#[derive(Clone)]
pub struct MenuItem {
pub key: char,
pub label: String,
pub detail: String,
pub action: MenuAction,
}
/// Ctrl held without Alt. Windows reports AltGr (needed for `@ \\ { [ ~` and
/// friends on many layouts) as Ctrl+Alt, which must still count as typing.
fn is_ctrl(key: &KeyEvent) -> bool {
key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT)
}
fn menu_item(key: char, label: &str, action: MenuAction) -> MenuItem {
MenuItem {
key,
label: label.to_string(),
detail: String::new(),
action,
}
}
pub struct Status {
pub text: String,
pub level: Level,
@ -93,7 +141,16 @@ pub struct App {
pub pl_state: TableState,
pub cmd_buf: String,
pub cmd_search: bool,
pub menu_items: Vec<MenuItem>,
pub menu_sel: usize,
pub menu_title: String,
pub menu_sub: bool,
pub menu_track: Option<Track>,
pub cmd_sel: usize,
cmd_cycle: Option<String>,
pub artist_opts: Vec<String>,
pub info_track: Option<Track>,
pub artist_sel: usize,
pub pending_delete: Option<String>,
pub help_scroll: usize,
@ -161,7 +218,16 @@ impl App {
open: None,
pl_state: TableState::default(),
cmd_buf: String::new(),
cmd_search: false,
menu_items: Vec::new(),
menu_sel: 0,
menu_title: String::new(),
menu_sub: false,
menu_track: None,
cmd_sel: 0,
cmd_cycle: None,
artist_opts: Vec::new(),
info_track: None,
artist_sel: 0,
pending_delete: None,
help_scroll: 0,
@ -370,18 +436,23 @@ impl App {
Mode::Command => self.key_command(key),
Mode::ArtistPick => self.key_artist_pick(key),
Mode::ConfirmDelete => self.key_confirm_delete(key),
Mode::Menu => self.key_menu(key),
Mode::Info => self.key_info(key),
Mode::Normal => self.key_normal(key),
}
}
fn key_normal(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let ctrl = is_ctrl(&key);
if let Some(p) = self.pending.take() {
let n = self.count.take();
if let KeyCode::Char(c) = key.code {
match (p, c) {
('g', 'g') => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))),
('g', 'e') => {
self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1)))
}
('g', 'r') => self.set_view(View::Results),
('g', 'q') => self.set_view(View::Queue),
('g', 'p') => self.set_view(View::Playlists),
@ -433,7 +504,7 @@ impl App {
self.pending = Some('g');
self.count = n;
}
KeyCode::Char('G') => self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1))),
KeyCode::Char('G') | KeyCode::Menu => self.open_menu(),
KeyCode::Char('d') => {
if matches!(self.view, View::Queue | View::Playlists) {
self.pending = Some('d');
@ -470,11 +541,13 @@ impl App {
KeyCode::Char('r') => self.cycle_repeat(),
KeyCode::Char('/') => {
self.cmd_buf = "search ".into();
self.cmd_buf.clear();
self.cmd_search = true;
self.enter_command_mode();
}
KeyCode::Char(':') => {
self.cmd_buf.clear();
self.cmd_search = false;
self.enter_command_mode();
}
KeyCode::Char('?') => self.open_help(),
@ -484,6 +557,195 @@ impl App {
}
}
/// The song under the cursor (no fallback to the playing song).
fn selected_track(&self) -> Option<Track> {
let sel = self.selected();
match self.view {
View::Results => self.results.get(sel).cloned(),
View::Queue => self.queue.get(sel).cloned(),
View::Playlists if self.in_playlist() => self
.open
.as_ref()
.and_then(|n| self.store.playlists.get(n))
.and_then(|t| t.get(sel))
.cloned(),
View::Playlists => None,
}
}
/// `G`: open the context menu for whatever is highlighted.
fn open_menu(&mut self) {
let mut items: Vec<MenuItem> = Vec::new();
if self.view == View::Playlists && !self.in_playlist() {
let Some(name) = self.playlist_name(self.selected()) else {
return self.error("No playlist selected");
};
let n = self.store.playlists.get(&name).map_or(0, Vec::len);
items.push(menu_item('o', "Open", MenuAction::OpenPlaylist));
items.push(menu_item('p', "Play", MenuAction::Play));
items.push(menu_item('a', "Append to queue", MenuAction::AppendPlaylist));
items.push(menu_item('x', "Delete playlist…", MenuAction::DeletePlaylist));
self.menu_title = format!("{name} ({n})");
self.menu_track = None;
} else {
let Some(track) = self.selected_track() else {
return self.error("No song selected");
};
let in_queue = self.view == View::Queue;
items.push(menu_item('p', "Play now", MenuAction::Play));
if !in_queue {
items.push(menu_item('n', "Play next", MenuAction::PlayNext));
items.push(menu_item('a', "Add to queue", MenuAction::Queue));
}
items.push(menu_item('g', "Show artist", MenuAction::Artist));
items.push(menu_item('l', "Add to playlist…", MenuAction::PlaylistMenu));
if in_queue {
items.push(menu_item('x', "Remove from queue", MenuAction::Remove));
} else if self.in_playlist() {
items.push(menu_item('x', "Remove from playlist", MenuAction::Remove));
}
items.push(menu_item('i', "Song info", MenuAction::Info));
if self.view == View::Results {
items.push(menu_item('Q', "Queue all results", MenuAction::QueueAll));
items.push(menu_item('P', "Play all results", MenuAction::PlayAll));
}
if in_queue {
items.push(menu_item('c', "Clear queue", MenuAction::ClearQueue));
items.push(menu_item('S', "Save queue as playlist…", MenuAction::SaveQueue));
}
self.menu_title = track.title.clone();
self.menu_track = Some(track);
}
self.menu_items = items;
self.menu_sel = 0;
self.menu_sub = false;
self.mode = Mode::Menu;
}
fn open_playlist_menu(&mut self) {
let track = self.menu_track.clone();
let mut items: Vec<MenuItem> = self
.store
.playlists
.iter()
.enumerate()
.map(|(i, (name, list))| {
let has = track
.as_ref()
.is_some_and(|t| list.iter().any(|x| x.id == t.id));
MenuItem {
key: if i < 9 {
char::from_digit(i as u32 + 1, 10).unwrap_or(NO_KEY)
} else {
NO_KEY
},
label: name.clone(),
detail: if has {
"✓ already in it".to_string()
} else {
format!("{} songs", list.len())
},
action: MenuAction::PickPlaylist(name.clone()),
}
})
.collect();
items.push(menu_item('+', "New playlist…", MenuAction::NewPlaylist));
self.menu_items = items;
self.menu_title = "Add to playlist".into();
self.menu_sel = 0;
self.menu_sub = true;
}
fn close_menu(&mut self) {
self.mode = Mode::Normal;
self.menu_items.clear();
self.menu_sel = 0;
self.menu_sub = false;
self.menu_track = None;
}
fn key_menu(&mut self, key: KeyEvent) {
let ctrl = is_ctrl(&key);
let len = self.menu_items.len();
if len == 0 {
return self.close_menu();
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('G') | KeyCode::Menu => {
if self.menu_sub {
self.open_menu();
} else {
self.close_menu();
}
}
KeyCode::Char('j') | KeyCode::Down => self.menu_sel = (self.menu_sel + 1) % len,
KeyCode::Char('k') | KeyCode::Up => self.menu_sel = (self.menu_sel + len - 1) % len,
KeyCode::Char('n') if ctrl => self.menu_sel = (self.menu_sel + 1) % len,
KeyCode::Char('p') if ctrl => self.menu_sel = (self.menu_sel + len - 1) % len,
KeyCode::Home => self.menu_sel = 0,
KeyCode::End => self.menu_sel = len - 1,
KeyCode::Enter => self.run_menu(self.menu_sel),
KeyCode::Char(c) if !ctrl && c != NO_KEY => {
if let Some(i) = self.menu_items.iter().position(|it| it.key == c) {
self.menu_sel = i;
self.run_menu(i);
}
}
_ => {}
}
}
fn run_menu(&mut self, i: usize) {
let Some(item) = self.menu_items.get(i).cloned() else {
return;
};
if item.action == MenuAction::PlaylistMenu {
return self.open_playlist_menu();
}
let track = self.menu_track.clone();
self.close_menu();
match item.action {
MenuAction::Play => self.activate(),
MenuAction::PlayNext => {
if let Some(t) = track {
self.play_next(t);
}
}
MenuAction::Queue | MenuAction::AppendPlaylist => self.add_selected(),
MenuAction::Artist => self.go_to_artist(),
MenuAction::PlaylistMenu => {}
MenuAction::PickPlaylist(name) => self.add_to_playlist(&name),
MenuAction::NewPlaylist => self.prefill_command("addto "),
MenuAction::Remove | MenuAction::DeletePlaylist => self.delete_selected(),
MenuAction::Info => {
if let Some(t) = track {
self.info_track = Some(t);
self.mode = Mode::Info;
}
}
MenuAction::QueueAll => self.add_all_results(),
MenuAction::PlayAll => self.play_all_results(),
MenuAction::ClearQueue => self.clear_queue(),
MenuAction::SaveQueue => self.prefill_command("save "),
MenuAction::OpenPlaylist => self.open_selected_playlist(),
}
}
/// Open the `:` popup with some text already typed (e.g. `save `).
fn prefill_command(&mut self, text: &str) {
self.cmd_buf = text.to_string();
self.cmd_search = false;
self.enter_command_mode();
}
fn play_next(&mut self, t: Track) {
let label = t.label();
let len = self.queue.len();
let at = self.current.map_or(0, |c| (c + 1).min(len));
self.queue.insert(at, t);
self.info(format!("Playing next: {label}"));
}
fn key_confirm_delete(&mut self, key: KeyEvent) {
self.mode = Mode::Normal;
let name = self.pending_delete.take();
@ -497,8 +759,13 @@ impl App {
self.mode = Mode::Help;
}
fn key_info(&mut self, _key: KeyEvent) {
self.mode = Mode::Normal;
self.info_track = None;
}
fn key_help(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let ctrl = is_ctrl(&key);
let s = self.help_scroll;
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') | KeyCode::Enter => {
@ -519,10 +786,45 @@ impl App {
fn enter_command_mode(&mut self) {
self.mode = Mode::Command;
self.hist_pos = None;
self.cmd_cycle = None;
self.cmd_sel = 0;
}
/// Commands matching what is typed so far (only while typing the command name).
pub fn command_matches(&self) -> Vec<&'static str> {
if self.cmd_search || self.cmd_buf.contains(' ') {
return Vec::new();
}
let prefix = self.cmd_cycle.as_deref().unwrap_or(self.cmd_buf.as_str());
COMMAND_NAMES
.iter()
.copied()
.filter(|c| c.starts_with(prefix))
.collect()
}
/// True while Tab / Shift-Tab is stepping through the suggestion list.
pub fn cycling(&self) -> bool {
self.cmd_cycle.is_some()
}
/// Most recent searches, newest first (shown in the `/` popup).
pub fn recent_searches(&self, n: usize) -> Vec<&str> {
self.history
.iter()
.rev()
.filter_map(|h| h.strip_prefix("search "))
.take(n)
.collect()
}
fn key_command(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let ctrl = is_ctrl(&key);
let cycling = matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
|| (ctrl && matches!(key.code, KeyCode::Char('n') | KeyCode::Char('p')));
if !cycling {
self.cmd_cycle = None;
}
match key.code {
KeyCode::Esc => {
self.mode = Mode::Normal;
@ -532,12 +834,18 @@ impl App {
let line = std::mem::take(&mut self.cmd_buf);
self.mode = Mode::Normal;
let trimmed = line.trim();
if !trimmed.is_empty() {
if self.history.last().map(String::as_str) != Some(trimmed) {
self.history.push(trimmed.to_string());
if trimmed.is_empty() {
return;
}
self.run_command(trimmed);
let full = if self.cmd_search {
format!("search {trimmed}")
} else {
trimmed.to_string()
};
if self.history.last() != Some(&full) {
self.history.push(full.clone());
}
self.run_command(&full);
}
KeyCode::Backspace => {
if self.cmd_buf.is_empty() {
@ -554,17 +862,30 @@ impl App {
}
KeyCode::Up => self.history_step(-1),
KeyCode::Down => self.history_step(1),
KeyCode::Tab => self.complete(),
KeyCode::Tab => self.complete(1),
KeyCode::BackTab => self.complete(-1),
KeyCode::Char('n') if ctrl => self.complete(1),
KeyCode::Char('p') if ctrl => self.complete(-1),
KeyCode::Char(c) if !ctrl => self.cmd_buf.push(c),
_ => {}
}
}
fn history_step(&mut self, dir: isize) {
if self.history.is_empty() {
let search = self.cmd_search;
let entries: Vec<String> = self
.history
.iter()
.filter_map(|h| match h.strip_prefix("search ") {
Some(q) if search => Some(q.to_string()),
_ if search => None,
_ => Some(h.clone()),
})
.collect();
if entries.is_empty() {
return;
}
let len = self.history.len() as isize;
let len = entries.len() as isize;
let pos = match (self.hist_pos, dir) {
(None, -1) => len - 1,
(None, _) => return,
@ -576,25 +897,36 @@ impl App {
} else {
let pos = pos.max(0) as usize;
self.hist_pos = Some(pos);
self.cmd_buf = self.history[pos].clone();
self.cmd_buf = entries[pos].clone();
}
}
fn complete(&mut self) {
if self.cmd_buf.contains(' ') {
/// Tab / Shift-Tab: cycle through the commands matching the typed prefix.
fn complete(&mut self, dir: isize) {
if self.cmd_search || self.cmd_buf.contains(' ') {
return;
}
let matches: Vec<&&str> = COMMAND_NAMES
.iter()
.filter(|c| c.starts_with(self.cmd_buf.as_str()))
.collect();
match matches.len() {
0 => {}
1 => self.cmd_buf = format!("{} ", matches[0]),
_ => {
let list = matches.iter().map(|s| **s).collect::<Vec<_>>().join(" ");
self.info(list);
if self.cmd_cycle.is_none() {
self.cmd_cycle = Some(self.cmd_buf.clone());
self.cmd_sel = if dir < 0 { usize::MAX } else { 0 };
} else {
let n = self.command_matches().len().max(1) as isize;
self.cmd_sel = (self.cmd_sel as isize + dir).rem_euclid(n) as usize;
}
let matches = self.command_matches();
if matches.is_empty() {
self.cmd_cycle = None;
return;
}
if self.cmd_sel >= matches.len() {
self.cmd_sel = matches.len() - 1;
}
let pick = matches[self.cmd_sel];
if matches.len() == 1 {
self.cmd_buf = format!("{pick} ");
self.cmd_cycle = None;
} else {
self.cmd_buf = pick.to_string();
}
}
@ -1661,8 +1993,8 @@ mod tests {
fn playlist_drill_in() {
let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("XDG_CONFIG_HOME", &dir);
std::env::set_var("XDG_STATE_HOME", &dir);
std::env::set_var("DRIFT_CONFIG_DIR", &dir);
std::env::set_var("DRIFT_STATE_DIR", &dir);
let (tx, _rx) = mpsc::channel();
let mut app = App::new(tx);

View file

@ -5,6 +5,7 @@ mod model;
mod player;
mod session;
mod store;
mod sys;
mod ui;
mod ytmusic;
@ -30,12 +31,9 @@ USAGE:
Inside the player press ? for key bindings and :help for commands.";
fn tool_version(name: &str) -> Option<String> {
let out = Command::new(name)
.arg("--version")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
let mut cmd = Command::new(name);
cmd.arg("--version").stdin(Stdio::null()).stderr(Stdio::null());
let out = sys::no_window(&mut cmd).output().ok()?;
out.status.success().then(|| {
String::from_utf8_lossy(&out.stdout)
.lines()
@ -51,11 +49,11 @@ fn missing_dependencies(verbose: bool) -> Vec<String> {
for (tool, hint) in [
(
"mpv",
"sudo apt install mpv | brew install mpv | sudo pacman -S mpv | winget install mpv",
"sudo apt install mpv | brew install mpv | sudo pacman -S mpv | winget install mpv.net / scoop install mpv",
),
(
"yt-dlp",
"pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp | winget install yt-dlp",
"pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp | winget install yt-dlp.yt-dlp / scoop install yt-dlp",
),
] {
match tool_version(tool) {

View file

@ -156,6 +156,7 @@ impl Player {
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
crate::sys::no_window(&mut cmd);
// Linux only: ask the kernel to SIGTERM mpv if drift dies unexpectedly.
#[cfg(target_os = "linux")]
@ -175,6 +176,9 @@ impl Player {
}
})?;
// Windows: tie mpv's lifetime to ours.
crate::sys::kill_on_exit(&child);
let start = Instant::now();
let (reader, writer) = loop {
match ipc::connect(&sock_path) {

View file

@ -17,8 +17,7 @@ pub struct Session {
impl Session {
fn path() -> Result<PathBuf> {
let dir = dirs::state_dir()
.or_else(dirs::data_local_dir)
let dir = crate::store::state_base()
.ok_or_else(|| anyhow!("cannot determine state directory"))?;
Ok(dir.join("drift").join("session.json"))
}

View file

@ -18,13 +18,44 @@ pub fn write_atomic(path: &Path, text: &str) -> Result<()> {
}
let tmp = path.with_extension("tmp");
fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?;
Ok(())
// On Windows the rename can fail briefly while an antivirus scanner or
// indexer holds the destination open, so retry a few times.
let mut last = None;
for attempt in 0..6 {
match fs::rename(&tmp, path) {
Ok(()) => return Ok(()),
Err(e) => {
last = Some(e);
if attempt < 5 {
std::thread::sleep(std::time::Duration::from_millis(40));
}
}
}
}
let _ = fs::remove_file(&tmp);
Err(last.expect("at least one attempt"))
.with_context(|| format!("replacing {}", path.display()))
}
/// Base directory for settings and playlists (`DRIFT_CONFIG_DIR` overrides it).
pub fn config_base() -> Option<PathBuf> {
std::env::var_os("DRIFT_CONFIG_DIR")
.map(PathBuf::from)
.or_else(dirs::config_dir)
}
/// Base directory for the saved session (`DRIFT_STATE_DIR` overrides it).
/// `dirs::state_dir()` is `None` on Windows and macOS, so fall back to local data.
pub fn state_base() -> Option<PathBuf> {
std::env::var_os("DRIFT_STATE_DIR")
.map(PathBuf::from)
.or_else(dirs::state_dir)
.or_else(dirs::data_local_dir)
}
impl Store {
pub fn dir() -> Result<PathBuf> {
let dir = dirs::config_dir().ok_or_else(|| anyhow!("cannot determine config directory"))?;
let dir = config_base().ok_or_else(|| anyhow!("cannot determine config directory"))?;
let new_dir = dir.join("drift");
let old_dir = dir.join("ytm-tui");
if !new_dir.exists() && old_dir.exists() {

91
src/sys.rs Normal file
View file

@ -0,0 +1,91 @@
//! Small platform shims so the rest of the app stays free of `cfg` noise.
use std::process::Command;
/// Keep child processes from flashing a console window on Windows.
pub fn no_window(cmd: &mut Command) -> &mut Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
}
/// Windows: make sure `child` is killed when drift exits, even if drift
/// crashes (the equivalent of Linux's PR_SET_PDEATHSIG).
#[cfg(windows)]
pub fn kill_on_exit(child: &std::process::Child) {
use std::ffi::c_void;
use std::os::windows::io::AsRawHandle;
use std::sync::OnceLock;
#[repr(C)]
struct BasicLimits {
per_process_user_time_limit: i64,
per_job_user_time_limit: i64,
limit_flags: u32,
minimum_working_set_size: usize,
maximum_working_set_size: usize,
active_process_limit: u32,
affinity: usize,
priority_class: u32,
scheduling_class: u32,
}
#[repr(C)]
struct IoCounters([u64; 6]);
#[repr(C)]
struct ExtendedLimits {
basic: BasicLimits,
io: IoCounters,
process_memory_limit: usize,
job_memory_limit: usize,
peak_process_memory_used: usize,
peak_job_memory_used: usize,
}
extern "system" {
fn CreateJobObjectW(attrs: *mut c_void, name: *const u16) -> *mut c_void;
fn SetInformationJobObject(
job: *mut c_void,
class: i32,
info: *mut c_void,
len: u32,
) -> i32;
fn AssignProcessToJobObject(job: *mut c_void, process: *mut c_void) -> i32;
}
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION: i32 = 9;
const KILL_ON_JOB_CLOSE: u32 = 0x2000;
// The job handle is intentionally kept open for the life of the process;
// when drift exits (or dies) Windows closes it and kills everything in it.
static JOB: OnceLock<usize> = OnceLock::new();
let job = *JOB.get_or_init(|| unsafe {
let job = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null());
if job.is_null() {
return 0;
}
let mut info: ExtendedLimits = std::mem::zeroed();
info.basic.limit_flags = KILL_ON_JOB_CLOSE;
let ok = SetInformationJobObject(
job,
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
&mut info as *mut _ as *mut c_void,
std::mem::size_of::<ExtendedLimits>() as u32,
);
if ok == 0 {
return 0;
}
job as usize
});
if job != 0 {
unsafe {
AssignProcessToJobObject(job as *mut c_void, child.as_raw_handle() as *mut c_void);
}
}
}
#[cfg(not(windows))]
pub fn kill_on_exit(_child: &std::process::Child) {}

BIN
src/test

Binary file not shown.

View file

@ -1,6 +0,0 @@
#include <stdio.h>
int main(int argc, char **argv)
{
printf("%d", argc);
}

682
src/ui.rs
View file

@ -1,10 +1,10 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Cell, Clear, Paragraph, Row, Table, Tabs};
use ratatui::widgets::{Block, BorderType, Cell, Clear, Paragraph, Row, Table, Tabs};
use ratatui::Frame;
use crate::app::{App, Level, Mode, PlayState, View};
use crate::app::{App, Level, MenuAction, Mode, PlayState, View, NO_KEY};
use crate::model::{fmt_time, Track};
const ACCENT: Color = Color::Cyan;
@ -27,11 +27,10 @@ fn dim() -> Style {
pub fn draw(f: &mut Frame, app: &mut App) {
let area = f.area();
let [top, body, playing, bottom] = Layout::vertical([
let [top, body, playing] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(3),
Constraint::Length(4),
Constraint::Length(1),
])
.areas(area);
@ -42,13 +41,41 @@ pub fn draw(f: &mut Frame, app: &mut App) {
View::Playlists => draw_playlists(f, app, body),
}
draw_now_playing(f, app, playing);
draw_cmdline(f, app, bottom);
if app.mode == Mode::Command {
draw_palette(f, app, area);
}
if app.mode == Mode::ConfirmDelete {
draw_confirm(f, app, area);
}
if app.mode == Mode::Menu {
draw_menu(f, app, area);
}
if app.mode == Mode::Help {
draw_help(f, app, area);
}
if app.mode == Mode::ArtistPick {
draw_artist_picker(f, app, area);
}
if app.mode == Mode::Info {
draw_info(f, app, area);
}
draw_toast(f, app, body);
}
fn hints(items: &[(&str, &str)], width: u16) -> Option<Line<'static>> {
let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
let mut len = 2usize;
for (i, (key, label)) in items.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" ", dim()));
len += 2;
}
spans.push(Span::styled(key.to_string(), Style::new().fg(Color::Yellow)));
spans.push(Span::styled(format!(" {label}"), dim()));
len += key.chars().count() + 1 + label.chars().count();
}
spans.push(Span::raw(" "));
(len + 4 <= width as usize).then(|| Line::from(spans).right_aligned())
}
fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
@ -181,9 +208,19 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) {
View::Queue => (&app.queue, " Queue ".to_string()),
_ => (&app.results, format!(" {} ", app.results_title)),
};
let block = Block::bordered()
let mut block = Block::bordered()
.title(format!("{title}({}) ", tracks.len()))
.border_style(Style::new().fg(ACCENT));
if !tracks.is_empty() {
let keys: &[(&str, &str)] = if view == View::Queue {
&[("⏎", "play"), ("x", "remove"), ("J/K", "move"), ("G", "menu"), ("?", "help")]
} else {
&[("⏎", "play"), ("a", "queue"), ("G", "menu"), ("/", "search"), ("?", "help")]
};
if let Some(h) = hints(keys, area.width) {
block = block.title_bottom(h);
}
}
if tracks.is_empty() {
let hint: &[&str] = if view == View::Queue {
@ -214,9 +251,15 @@ fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) {
let Some(tracks) = app.store.playlists.get(name) else {
return;
};
let block = Block::bordered()
let mut block = Block::bordered()
.title(format!(" {name} ({}) ", tracks.len()))
.border_style(Style::new().fg(ACCENT));
if !tracks.is_empty() {
let keys = [("⏎", "play"), ("a", "queue"), ("x", "remove"), ("G", "menu"), ("←", "back")];
if let Some(h) = hints(&keys, area.width) {
block = block.title_bottom(h);
}
}
if tracks.is_empty() {
return empty_hint(
f,
@ -244,9 +287,14 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) {
let [left, right] =
Layout::horizontal([Constraint::Percentage(35), Constraint::Percentage(65)]).areas(area);
let block = Block::bordered()
let mut block = Block::bordered()
.title(format!(" Playlists ({}) ", app.store.playlists.len()))
.border_style(Style::new().fg(ACCENT));
if !app.store.playlists.is_empty() {
if let Some(h) = hints(&[("⏎", "load"), ("→", "open"), ("G", "menu")], left.width) {
block = block.title_bottom(h);
}
}
if app.store.playlists.is_empty() {
empty_hint(
@ -306,54 +354,127 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) {
}
}
const BARS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
fn gradient(_t: f64) -> Color {
ACCENT
}
fn equalizer(app: &App, n: usize) -> Vec<Span<'static>> {
let t = app.tick as f64;
(0..n)
.map(|i| {
let x = i as f64;
let level = match app.play_state {
PlayState::Playing => {
let v = ((t * 0.45 + x * 1.3).sin() + (t * 0.23 + x * 2.1).sin()) / 4.0 + 0.5;
(v.clamp(0.0, 1.0) * 7.0).round() as usize
}
_ => 0,
};
let color = if app.play_state == PlayState::Playing {
gradient(x / n as f64)
} else {
Color::DarkGray
};
Span::styled(BARS[level], Style::new().fg(color))
})
.collect()
}
fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
let block = Block::bordered()
.title(" Now Playing ")
.border_style(Style::new().fg(ACCENT));
let active = app.play_state != PlayState::Stopped;
let mut block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(if active { ACCENT } else { Color::DarkGray }))
.title(Line::from(vec![
Span::styled(" ♪ ", Style::new().fg(ACCENT).bold()),
Span::styled("Now Playing ", Style::new().fg(ACCENT).bold()),
]));
let mut pending = String::new();
if let Some(c) = app.count {
pending.push_str(&c.to_string());
}
if let Some(p) = app.pending {
pending.push(p);
}
if !pending.is_empty() {
block = block.title_bottom(
Line::styled(format!(" {pending} "), Style::new().fg(Color::Yellow).bold())
.right_aligned(),
);
}
let inner = block.inner(area);
f.render_widget(block, area);
let [l1, l2] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
let mut flags = vec![format!("vol {}%", app.volume)];
if app.shuffle {
flags.push("shuffle".into());
}
let on = Style::new().fg(ACCENT).bold();
let vol_cells = 5usize;
let vol_fill = ((app.volume as usize * vol_cells) + 50) / 100;
let mut flags = vec![
Span::styled("⇄", if app.shuffle { on } else { dim() }),
Span::raw(" "),
Span::styled(
match app.repeat {
crate::model::RepeatMode::Off => "↻".to_string(),
m => format!("↻ {}", m.label()),
},
if app.repeat != crate::model::RepeatMode::Off {
flags.push(format!("repeat:{}", app.repeat.label()));
on
} else {
dim()
},
),
Span::raw(" "),
Span::styled("vol ", dim()),
];
for i in 0..vol_cells {
flags.push(if i < vol_fill.min(vol_cells) {
Span::styled("━", Style::new().fg(gradient(i as f64 / vol_cells as f64)))
} else {
Span::styled("─", dim())
});
}
let flags = flags.join(" · ");
let flags_w = flags.chars().count() as u16 + 1;
flags.push(Span::styled(format!(" {:>3}%", app.volume), Style::new().fg(Color::Gray)));
flags.push(Span::raw(" "));
let flags_w: usize = flags.iter().map(|s| s.content.chars().count()).sum();
let [title_area, flags_area] =
Layout::horizontal([Constraint::Min(1), Constraint::Length(flags_w)]).areas(l1);
f.render_widget(
Paragraph::new(flags)
.style(dim())
.alignment(Alignment::Right),
flags_area,
);
Layout::horizontal([Constraint::Min(1), Constraint::Length(flags_w as u16)]).areas(l1);
f.render_widget(Paragraph::new(Line::from(flags)), flags_area);
let icon = match app.play_state {
PlayState::Playing => "▶".to_string(),
PlayState::Paused => "⏸".to_string(),
PlayState::Loading => SPINNER[app.tick % SPINNER.len()].to_string(),
PlayState::Stopped => "■".to_string(),
};
let line = match (app.now_track(), app.play_state) {
(Some(t), s) if s != PlayState::Stopped => Line::from(vec![
Span::styled(format!("{icon} "), Style::new().fg(Color::Green)),
Span::styled(t.title.clone(), Style::new().bold()),
Span::styled(format!(" {}", t.artist), Style::new().fg(Color::Gray)),
]),
(Some(t), _) => Line::from(vec![
Span::styled(format!("{icon} "), dim()),
Span::styled(format!("Last played: {} — {}", t.title, t.artist), dim()),
]),
_ => Line::from(vec![
Span::styled(format!("{icon} "), dim()),
Span::styled("Nothing playing", dim()),
]),
};
f.render_widget(Paragraph::new(line), title_area);
let mut spans: Vec<Span> = vec![Span::raw(" ")];
match app.play_state {
PlayState::Loading => spans.push(Span::styled(
format!("{} ", SPINNER[app.tick % SPINNER.len()]),
Style::new().fg(Color::Yellow),
)),
PlayState::Playing => {
spans.extend(equalizer(app, 6));
spans.push(Span::raw(" "));
}
PlayState::Paused => {
spans.push(Span::styled("⏸ ", Style::new().fg(Color::Yellow)));
spans.push(Span::raw(" "));
}
_ => {
}
}
spans.push(Span::raw(" "));
match (app.now_track(), active) {
(Some(t), true) => {
spans.push(Span::styled(t.title.clone(), Style::new().fg(Color::White).bold()));
spans.push(Span::styled(" · ", dim()));
spans.push(Span::styled(t.artist.clone(), Style::new().fg(ACCENT)));
}
(Some(t), false) => spans.push(Span::styled(
format!("Last played: {} — {}", t.title, t.artist),
dim(),
)),
_ => spans.push(Span::styled("Nothing playing · press / to search", dim())),
}
f.render_widget(Paragraph::new(Line::from(spans)), title_area);
let left = format!(" {} ", fmt_time(app.position));
let right = format!(
@ -370,65 +491,389 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
} else {
0.0
};
let filled = (ratio * bar_w as f64).round() as usize;
let line = Line::from(vec![
Span::raw(left),
Span::styled("━".repeat(filled), Style::new().fg(ACCENT)),
Span::styled("─".repeat(bar_w.saturating_sub(filled)), dim()),
Span::raw(right),
]);
f.render_widget(Paragraph::new(line), l2);
let head = ((ratio * bar_w as f64) as usize).min(bar_w.saturating_sub(1));
let mut line = vec![Span::styled(left, Style::new().fg(Color::Gray))];
for i in 0..bar_w {
line.push(if !active || app.duration <= 0.0 {
Span::styled("─", dim())
} else if i < head {
Span::styled("━", Style::new().fg(gradient(i as f64 / bar_w.max(1) as f64)))
} else if i == head {
Span::styled("●", Style::new().fg(Color::White).bold())
} else {
Span::styled("─", dim())
});
}
line.push(Span::styled(right, Style::new().fg(Color::Gray)));
f.render_widget(Paragraph::new(Line::from(line)), l2);
}
fn draw_cmdline(f: &mut Frame, app: &App, area: Rect) {
if app.mode == Mode::ConfirmDelete {
fn draw_toast(f: &mut Frame, app: &App, body: Rect) {
let Some(s) = app.visible_status() else {
return;
};
if body.width < 16 || body.height < 5 {
return;
}
let (icon, color) = match s.level {
Level::Error => ("✖", Color::Red),
Level::Info => ("●", Color::Green),
};
let max_text = (body.width as usize).saturating_sub(8).min(70);
let mut text: String = s.text.lines().next().unwrap_or("").to_string();
if text.chars().count() > max_text {
text = text.chars().take(max_text.saturating_sub(1)).collect::<String>() + "…";
}
let w = (text.chars().count() as u16 + 6).min(body.width);
let rect = Rect::new(body.right().saturating_sub(w + 1).max(body.x), body.bottom().saturating_sub(4), w, 3);
f.render_widget(Clear, rect);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!(" {icon} "), Style::new().fg(color).bold()),
Span::styled(text, Style::new().fg(Color::White)),
]))
.block(
Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(color)),
),
rect,
);
}
fn draw_info(f: &mut Frame, app: &App, area: Rect) {
let Some(t) = &app.info_track else {
return;
};
let rows: Vec<(&str, String)> = vec![
("Title", t.title.clone()),
("Artist", t.artist.clone()),
("Album", t.album.clone().unwrap_or_else(|| "—".into())),
("Length", t.duration.map_or("—".into(), |d| fmt_time(d as f64))),
("ID", t.id.clone()),
];
let label_w = 8usize;
let widest = rows.iter().map(|(_, v)| v.chars().count()).max().unwrap_or(0);
let w = ((label_w + widest + 6).max(40) as u16).min(area.width.min(80));
let value_w = (w as usize).saturating_sub(label_w + 4).max(1);
let mut lines: Vec<Line> = Vec::new();
for (k, v) in &rows {
let mut parts = wrap_words(v, value_w).into_iter();
let first = parts.next().unwrap_or_default();
lines.push(Line::from(vec![
Span::styled(format!(" {k:<label_w$}"), header_style()),
Span::styled(first, Style::new().fg(Color::White)),
]));
for rest in parts {
lines.push(Line::from(vec![
Span::raw(" ".repeat(label_w + 1)),
Span::styled(rest, Style::new().fg(Color::White)),
]));
}
}
let popup = popup_rect(area, w, lines.len() as u16 + 2, false);
if popup.width < 12 || popup.height < 3 {
return;
}
f.render_widget(Clear, popup);
f.render_widget(
Paragraph::new(lines).block(
Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(ACCENT))
.title(Span::styled(" ♪ Song info ", Style::new().fg(ACCENT).bold()))
.title_bottom(Line::styled(" any key to close ", dim()).right_aligned()),
),
popup,
);
}
fn popup_rect(area: Rect, want_w: u16, want_h: u16, top_third: bool) -> Rect {
let w = want_w.min(area.width);
let x = area.x + (area.width - w) / 2;
let y = if top_third {
area.y + area.height / 5
} else {
area.y + area.height.saturating_sub(want_h) / 2
};
let h = want_h.min(area.bottom().saturating_sub(y));
Rect::new(x, y, w, h)
}
const CMD_INFO: &[(&str, &str)] = &[
("search", "search YouTube Music"),
("play", "play / resume · play N plays row N"),
("pause", "pause playback"),
("resume", "resume playback"),
("toggle", "toggle pause"),
("stop", "stop playback"),
("next", "skip to the next track"),
("prev", "go back to the previous track"),
("quit", "exit drift"),
("add", "queue the selected result · add N"),
("addall", "queue all results"),
("playall", "play all results"),
("remove", "remove from the queue · remove N"),
("clear", "empty the queue"),
("results", "show the Results tab"),
("queue", "show the Queue tab"),
("playlists", "show the Playlists tab"),
("volume", "set volume · 50 | +5 | -5"),
("seek", "seek · 90 | 1:30 | +10 | -10"),
("shuffle", "toggle shuffle"),
("repeat", "repeat · off | all | one"),
("save", "save the queue as a playlist"),
("load", "load a playlist into the queue"),
("append", "append a playlist to the queue"),
("delpl", "delete a playlist"),
("addto", "add the selected track to a playlist"),
("import", "import a YouTube / YT Music playlist URL"),
("artist", "songs by the selected artist"),
("help", "show key bindings"),
];
fn cmd_info(name: &str) -> &'static str {
CMD_INFO
.iter()
.find(|(n, _)| *n == name)
.map_or("", |(_, d)| *d)
}
fn draw_palette(f: &mut Frame, app: &App, area: Rect) {
const MAX_ROWS: usize = 8;
let search = app.cmd_search;
let matches = app.command_matches();
let recent = if search && app.cmd_buf.is_empty() {
app.recent_searches(5)
} else {
Vec::new()
};
let first_word = app.cmd_buf.split_whitespace().next().unwrap_or("");
let typing_args = !search && app.cmd_buf.contains(' ');
let list_rows = if search {
recent.len().max(1)
} else if typing_args {
1
} else {
matches.len().clamp(1, MAX_ROWS)
};
let want_h = 2 + 1 + 1 + list_rows as u16;
let popup = popup_rect(area, 64, want_h, true);
if popup.width < 12 || popup.height < 3 {
return;
}
let (title, prompt) = if search {
(" Search YouTube Music ", " / ")
} else {
(" Command ", " : ")
};
let footer = if search {
" ⏎ search · ↑↓ history · Esc close "
} else {
" ⏎ run · Tab complete · Esc close "
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(if search { ACCENT } else { ACCENT }))
.title(Span::styled(
title,
Style::new()
.fg(if search { ACCENT } else { ACCENT })
.bold(),
))
.title_bottom(Line::styled(footer, dim()).right_aligned());
let inner = block.inner(popup);
f.render_widget(Clear, popup);
f.render_widget(block, popup);
let [input, sep, list] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(inner);
let prompt_w = prompt.chars().count();
let avail = (input.width as usize).saturating_sub(prompt_w + 1);
let chars: Vec<char> = app.cmd_buf.chars().collect();
let shown: String = chars[chars.len().saturating_sub(avail)..].iter().collect();
let shown_w = shown.chars().count();
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
prompt,
Style::new().fg(Color::Black).bg(if search { ACCENT } else { ACCENT }).bold(),
),
Span::raw(" "),
Span::styled(shown, Style::new().fg(Color::White)),
])),
input,
);
if input.width as usize > prompt_w + 1 {
f.set_cursor_position((input.x + (prompt_w + 1 + shown_w) as u16, input.y));
}
f.render_widget(
Paragraph::new("─".repeat(sep.width as usize)).style(dim()),
sep,
);
let mut lines: Vec<Line> = Vec::new();
if search {
if recent.is_empty() {
lines.push(Line::styled(" type what you want to hear, then press Enter", dim()));
} else {
for r in recent {
lines.push(Line::from(vec![
Span::styled(" ↺ ", dim()),
Span::styled(r.to_string(), Style::new().fg(Color::Gray)),
]));
}
}
} else if typing_args {
let d = cmd_info(first_word);
lines.push(if d.is_empty() {
Line::styled(" ⏎ run", dim())
} else {
Line::from(vec![
Span::styled(format!(" {first_word} "), Style::new().fg(ACCENT).bold()),
Span::styled(d, dim()),
])
});
} else if matches.is_empty() {
lines.push(Line::styled(" no matching command", Style::new().fg(Color::Red)));
} else {
let sel = app.cycling().then_some(app.cmd_sel);
let start = sel.map_or(0, |s| (s + 1).saturating_sub(MAX_ROWS));
let width = list.width as usize;
for (i, name) in matches.iter().enumerate().skip(start).take(MAX_ROWS) {
let text = format!(" {name:<10}{}", cmd_info(name));
let text: String = text.chars().take(width).collect();
lines.push(if Some(i) == sel {
let pad = width.saturating_sub(text.chars().count());
Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style())
} else {
Line::from(vec![
Span::styled(
text.chars().take(11).collect::<String>(),
Style::new().fg(ACCENT),
),
Span::styled(text.chars().skip(11).collect::<String>(), dim()),
])
});
}
}
f.render_widget(Paragraph::new(lines), list);
}
fn draw_menu(f: &mut Frame, app: &App, area: Rect) {
let items = &app.menu_items;
if items.is_empty() {
return;
}
let label_w = items.iter().map(|i| i.label.chars().count()).max().unwrap_or(0);
let detail_w = items.iter().map(|i| i.detail.chars().count()).max().unwrap_or(0);
let body_w = 5 + label_w + if detail_w > 0 { detail_w + 3 } else { 0 } + 2;
let title_w = app.menu_title.chars().count() + 6;
let w = body_w.max(title_w).clamp(34, 60) as u16;
let popup = popup_rect(area, w, items.len() as u16 + 2, false);
if popup.width < 12 || popup.height < 3 {
return;
}
let max_title = (popup.width as usize).saturating_sub(8);
let mut title: String = app.menu_title.chars().take(max_title).collect();
if app.menu_title.chars().count() > max_title {
title.push('…');
}
let title = if app.menu_sub {
format!(" {title} ")
} else {
format!(" ♪ {title} ")
};
let footer = if app.menu_sub {
" ⏎ add · Esc back "
} else {
" ⏎ select · Esc close "
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(ACCENT))
.title(Span::styled(title, Style::new().fg(ACCENT).bold()))
.title_bottom(Line::styled(footer, dim()).right_aligned());
let inner = block.inner(popup);
f.render_widget(Clear, popup);
f.render_widget(block, popup);
let width = inner.width as usize;
let lines: Vec<Line> = items
.iter()
.enumerate()
.map(|(i, it)| {
let key = if it.key == NO_KEY { ' ' } else { it.key };
let label = format!("{:<label_w$}", it.label);
let selected = i == app.menu_sel;
if selected {
let mut text = format!(" {key} {label}");
if !it.detail.is_empty() {
text.push_str(&format!(" {}", it.detail));
}
let text: String = text.chars().take(width).collect();
let pad = width.saturating_sub(text.chars().count());
return Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style());
}
let danger = matches!(
it.action,
MenuAction::Remove | MenuAction::DeletePlaylist | MenuAction::ClearQueue
);
let label_style = if danger {
Style::new().fg(Color::Red)
} else {
Style::new().fg(Color::White)
};
let mut spans = vec![
Span::raw(" "),
Span::styled(key.to_string(), Style::new().fg(Color::Yellow).bold()),
Span::raw(" "),
Span::styled(label, label_style),
];
if !it.detail.is_empty() {
spans.push(Span::styled(format!(" {}", it.detail), dim()));
}
Line::from(spans)
})
.collect();
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_confirm(f: &mut Frame, app: &App, area: Rect) {
let name = app.pending_delete.as_deref().unwrap_or_default();
f.render_widget(
Paragraph::new(format!(" Delete playlist \u{201c}{name}\u{201d}? (y/N)"))
.style(Style::new().fg(Color::Yellow).bold()),
area,
);
let msg = format!(" Delete playlist \u{201c}{name}\u{201d}? ");
let w = (msg.chars().count() as u16 + 4).max(34);
let popup = popup_rect(area, w, 5, false);
if popup.width < 12 || popup.height < 3 {
return;
}
if app.mode == Mode::Command {
let text = format!(":{}", app.cmd_buf);
let col = text
.chars()
.count()
.min(area.width.saturating_sub(1) as usize) as u16;
f.render_widget(Paragraph::new(text), area);
f.set_cursor_position((area.x + col, area.y));
return;
}
let line = match app.visible_status() {
Some(s) => {
let style = match s.level {
Level::Error => Style::new().fg(Color::Red).bold(),
Level::Info => Style::new().fg(Color::Green),
};
Line::styled(format!(" {}", s.text), style)
}
None => Line::default(),
};
f.render_widget(Paragraph::new(line), area);
let mut pending = String::new();
if let Some(c) = app.count {
pending.push_str(&c.to_string());
}
if let Some(p) = app.pending {
pending.push(p);
}
if !pending.is_empty() {
let lines = vec![
Line::styled(msg, Style::new().fg(Color::White).bold()),
Line::from(vec![
Span::styled(" y ", Style::new().fg(Color::Black).bg(Color::Red).bold()),
Span::styled(" delete ", dim()),
Span::styled(" any other key ", Style::new().fg(Color::Gray)),
Span::styled(" cancel", dim()),
]),
];
f.render_widget(Clear, popup);
f.render_widget(
Paragraph::new(format!("{pending} "))
.alignment(Alignment::Right)
.style(Style::new().fg(Color::Yellow)),
area,
Paragraph::new(lines).alignment(Alignment::Center).block(
Block::bordered()
.border_type(BorderType::Rounded)
.title(" Confirm ")
.border_style(Style::new().fg(Color::Yellow)),
),
popup,
);
}
}
const HELP: &[(&str, &str)] = &[
@ -437,7 +882,7 @@ const HELP: &[(&str, &str)] = &[
("q Esc ?", "close"),
("NAVIGATION", "#"),
("j k / ↓ ↑", "move (counts work: 5j)"),
("gg G", "top / bottom (5G → row 5)"),
("gg ge Home End", "top / bottom (5gg or 5ge → row 5)"),
("Ctrl-d Ctrl-u", "half page down / up"),
("Tab S-Tab", "cycle Results / Queue / Playlists"),
("gr gq gp", "jump to Results / Queue / Playlists"),
@ -455,6 +900,12 @@ const HELP: &[(&str, &str)] = &[
"ga or :artist",
"songs by the artist of the selected song; a popup asks which one when there are several",
),
("SONG MENU ( G )", "#"),
("G", "menu for the highlighted song or playlist"),
("j k ⏎", "move / choose (the yellow letter is a shortcut)"),
("p n a g l", "play · play next · add to queue · artist · playlist"),
("x i", "remove · song info"),
("Esc q G", "close (Esc goes back from a sub-menu)"),
("QUEUE", "#"),
("a A P", "add selected / add all / play all results"),
(
@ -648,8 +1099,8 @@ mod tests {
fn open_playlist_renders() {
let dir = std::env::temp_dir().join(format!("drift-ui-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("XDG_CONFIG_HOME", &dir);
std::env::set_var("XDG_STATE_HOME", &dir);
std::env::set_var("DRIFT_CONFIG_DIR", &dir);
std::env::set_var("DRIFT_STATE_DIR", &dir);
let (tx, _rx) = mpsc::channel();
let mut app = App::new(tx);
@ -691,6 +1142,33 @@ mod tests {
let mut tiny = Terminal::new(TestBackend::new(20, 6)).unwrap();
tiny.draw(|f| draw(f, &mut app)).unwrap();
key(&mut app, KeyCode::Char(':'));
term.draw(|f| draw(f, &mut app)).unwrap();
assert!(text(&term).contains("Command"));
key(&mut app, KeyCode::Tab);
term.draw(|f| draw(f, &mut app)).unwrap();
tiny.draw(|f| draw(f, &mut app)).unwrap();
key(&mut app, KeyCode::Esc);
key(&mut app, KeyCode::Char('/'));
term.draw(|f| draw(f, &mut app)).unwrap();
assert!(text(&term).contains("Search YouTube Music"));
tiny.draw(|f| draw(f, &mut app)).unwrap();
key(&mut app, KeyCode::Esc);
key(&mut app, KeyCode::Char('G'));
term.draw(|f| draw(f, &mut app)).unwrap();
assert!(text(&term).contains("Play now"));
tiny.draw(|f| draw(f, &mut app)).unwrap();
key(&mut app, KeyCode::Char('l'));
term.draw(|f| draw(f, &mut app)).unwrap();
assert!(text(&term).contains("New playlist"));
key(&mut app, KeyCode::Esc);
term.draw(|f| draw(f, &mut app)).unwrap();
assert!(text(&term).contains("Play now"));
key(&mut app, KeyCode::Esc);
term.draw(|f| draw(f, &mut app)).unwrap();
assert!(!text(&term).contains("Play now"));
key(&mut app, KeyCode::Char('?'));
term.draw(|f| draw(f, &mut app)).unwrap();
let s = text(&term);

View file

@ -305,8 +305,8 @@ fn ytdlp_search(query: &str) -> Result<Vec<Track>> {
}
fn ytdlp_entries(target: &str) -> Result<Vec<Track>> {
let out = Command::new("yt-dlp")
.args([
let mut cmd = Command::new("yt-dlp");
cmd.args([
"--flat-playlist",
"--dump-json",
"--no-warnings",
@ -315,7 +315,8 @@ fn ytdlp_entries(target: &str) -> Result<Vec<Track>> {
"15",
])
.arg(target)
.stdin(Stdio::null())
.stdin(Stdio::null());
let out = crate::sys::no_window(&mut cmd)
.output()
.map_err(|e| anyhow!("could not run yt-dlp ({e}); is it installed and on PATH?"))?;