From acfb3be1e71887861b0c32c961a4ac9c44645d08 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Thu, 22 Oct 2026 13:25:54 +0200 Subject: [PATCH] feat: switch between provider sessions without restarting --- src/keys.rs | 5 +++ src/main.rs | 90 ++++++++++++++++++++++++++++++++++++++-------- src/miruro_ui.rs | 84 +++++++++++++++++++++++-------------------- tests/unit/keys.rs | 23 ++++++++++++ 4 files changed, 150 insertions(+), 52 deletions(-) diff --git a/src/keys.rs b/src/keys.rs index 2689a61..c0ba91f 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -104,6 +104,7 @@ macro_rules! actions { actions! { Interrupt, "app.interrupt", "Exit immediately", ALL, ["ctrl+c"]; Quit, "app.quit", "Quit the application", &[Context::Normal, Context::Info, Context::Description, Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, Context::Help], ["q"]; + ProviderChoose, "app.provider", "Choose the browsing provider", &[Context::Normal, Context::CollectionNormal], ["p"]; Help, "help.toggle", "Open or close keyboard help", NON_TEXT, ["?"]; InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter, stage new tag or save category name", TEXT, ["enter"]; InputCancel, "input.cancel", "Leave text input without submitting", TEXT, ["escape"]; @@ -518,6 +519,10 @@ impl Bindings { (&[VideoFirst], "First loaded video"), (&[VideoLast], "Last loaded video"), (&[ViewRefresh], "Reload config and refresh"), + ( + &[ProviderChoose], + "Choose the browsing provider (NORMAL only)", + ), (&[Help], "Open / close this reference"), (&[Quit, Interrupt], "Quit"), ], diff --git a/src/main.rs b/src/main.rs index 7ab8209..8e40b67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -157,7 +157,7 @@ use ratatui::{ use video_state::{CollectionKind, WatchStatus}; use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; -#[derive(Parser)] +#[derive(Clone, Parser)] #[command( version, about = "Browse YouTube or Miruro and open selected videos in Terebi", @@ -611,7 +611,7 @@ impl KeyHints { ( Context::Normal, format!( - "NORMAL · {}/{} select · {} play · {status} · {}/{} later · {}/{} saved · {} search · {} help · {} quit", + "NORMAL · {}/{} select · {} play · {status} · {}/{} later · {}/{} saved · {} search · {} provider · {} help · {} quit", key(Action::VideoNext), key(Action::VideoPrevious), key(Action::VideoPlay), @@ -620,6 +620,7 @@ impl KeyHints { key(Action::SavedVideoSave), key(Action::SavedVideoBrowse), key(Action::SearchEdit), + key(Action::ProviderChoose), key(Action::Help), key(Action::Quit) ), @@ -651,7 +652,7 @@ impl KeyHints { ( Context::CollectionNormal, format!( - "NORMAL · {}/{} select · {} play · {status} · {}/{} later · {}/{} saved · {} remove selected · {} remove list · {} search · {} help · {} quit", + "NORMAL · {}/{} select · {} play · {status} · {}/{} later · {}/{} saved · {} remove selected · {} remove list · {} search · {} provider · {} help · {} quit", key(Action::VideoNext), key(Action::VideoPrevious), key(Action::VideoPlay), @@ -662,6 +663,7 @@ impl KeyHints { key(Action::CollectionRemoveSelected), key(Action::CollectionRemoveBulk), key(Action::SearchEdit), + key(Action::ProviderChoose), key(Action::Help), key(Action::Quit) ), @@ -3558,7 +3560,7 @@ impl App { ); } - fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { + fn run(&mut self, terminal: &mut DefaultTerminal, initial: bool) -> io::Result> { struct RestoreTerminalInput; impl Drop for RestoreTerminalInput { fn drop(&mut self) { @@ -3576,7 +3578,7 @@ impl App { SetCursorStyle::SteadyBlock )?; self.thumbnail.query_terminal(); - if !self.views[self.view_index].query.trim().is_empty() { + if initial && !self.views[self.view_index].query.trim().is_empty() { self.search(); } let started = Instant::now(); @@ -3587,8 +3589,13 @@ impl App { if event::poll(refresh)? { match event::read()? { Event::Key(key) if key.kind != KeyEventKind::Release => { + if self.config.keys.resolve(self.key_context(), key) + == Some(Action::ProviderChoose) + { + return choose_provider(terminal, &self.config.keys, Some(false)); + } if self.key(key) { - return Ok(()); + return Ok(None); } } Event::Paste(text) => self.paste(&text), @@ -3694,15 +3701,17 @@ fn provider_selection( fn choose_provider( terminal: &mut DefaultTerminal, bindings: &keys::Bindings, + current: Option, ) -> io::Result> { - let mut selected = 0; + let mut selected = usize::from(current.unwrap_or(false)); let mut selection = ListState::default(); let hint = format!( - "{}/{} select · {} open · {} quit", + "{}/{} select · {} open · {} {}", bindings.label(Action::SelectorNext), bindings.label(Action::SelectorPrevious), bindings.label(Action::SelectorOpen), bindings.label(Action::SelectorClose), + if current.is_some() { "cancel" } else { "quit" }, ); let providers = List::new(PROVIDERS.map(|(name, _)| name)) .highlight_style(ui::SELECTED) @@ -3740,6 +3749,9 @@ fn choose_provider( .resolve(Context::Normal, key) .filter(|action| *action == Action::Quit) }); + if current.is_some() && action == Some(Action::SelectorClose) { + return Ok(current); + } if let std::ops::ControlFlow::Break(provider) = provider_selection(&mut selected, action) { @@ -3761,16 +3773,66 @@ fn main() -> io::Result<()> { let config = config::load(&config_path)?; ratatui::run(|terminal| { if !options.miruro { - let Some(miruro) = choose_provider(terminal, &config.keys)? else { + let Some(miruro) = choose_provider(terminal, &config.keys, None)? else { return Ok(()); }; options.miruro = miruro; } - let state = video_state::State::open(&config_path)?; - if options.miruro { - miruro_ui::run(terminal, options, config_path, config, state) - } else { - App::with_state(options, config_path, config, state).run(terminal) + let mut youtube: Option = None; + let mut miruro: Option = None; + let mut initial = true; + loop { + let next = if options.miruro { + if miruro.is_none() { + miruro = Some(miruro_ui::Browser::new( + options.clone(), + config_path.clone(), + config::load(&config_path)?, + video_state::State::open(&config_path)?, + )); + } + miruro.as_mut().unwrap().run(terminal)? + } else { + let first = youtube.is_none(); + if first { + youtube = Some(App::with_state( + options.clone(), + config_path.clone(), + config::load(&config_path)?, + video_state::State::open(&config_path)?, + )); + } + youtube.as_mut().unwrap().run(terminal, first)? + }; + let Some(next) = next else { + return Ok(()); + }; + if initial { + options.query = None; + initial = false; + } + if next != options.miruro { + if next && miruro.is_none() { + miruro = Some(miruro_ui::Browser::new( + options.clone(), + config_path.clone(), + config::load(&config_path)?, + video_state::State::open(&config_path)?, + )); + } else if !next && youtube.is_none() { + youtube = Some(App::with_state( + options.clone(), + config_path.clone(), + config::load(&config_path)?, + video_state::State::open(&config_path)?, + )); + } + std::mem::swap( + &mut youtube.as_mut().unwrap().video_state, + &mut miruro.as_mut().unwrap().state, + ); + } + options.miruro = next; } }) } diff --git a/src/miruro_ui.rs b/src/miruro_ui.rs index 7cb32ac..99242be 100644 --- a/src/miruro_ui.rs +++ b/src/miruro_ui.rs @@ -294,11 +294,11 @@ impl Screen { } // History owns the actual screens, so back/forward never has to refetch a catalog. -struct Browser { +pub(crate) struct Browser { options: Options, config_path: PathBuf, config: config::Config, - state: State, + pub(crate) state: State, screens: Vec, index: usize, input: Option, @@ -380,7 +380,12 @@ impl Picker { } impl Browser { - fn new(options: Options, config_path: PathBuf, config: config::Config, state: State) -> Self { + pub(crate) fn new( + options: Options, + config_path: PathBuf, + config: config::Config, + state: State, + ) -> Self { let mut query = String::new(); crate::paste_input(&mut query, options.query.as_deref().unwrap_or("")); let mut browser = Self { @@ -1279,6 +1284,7 @@ impl Browser { "Show series/episode info with cover art", ), (Action::Help, "Keyboard help"), + (Action::ProviderChoose, "Choose provider"), (Action::Quit, "Exit"), (Action::Interrupt, "Exit immediately"), ]; @@ -1437,6 +1443,7 @@ impl Browser { "NORMAL", &[ (Action::Help, "help"), + (Action::ProviderChoose, "provider"), (Action::VideoPlay, "play"), (Action::InfoOverview, "info"), (Action::WatchStatusWatching, "mark watching"), @@ -1453,6 +1460,7 @@ impl Browser { "NORMAL", &[ (Action::Help, "help"), + (Action::ProviderChoose, "provider"), (Action::SearchEdit, "search"), ( Action::VideoPlay, @@ -2040,44 +2048,44 @@ fn media_details(media: &Media) -> String { text } -pub fn run( - terminal: &mut DefaultTerminal, - options: Options, - config_path: PathBuf, - config: config::Config, - state: State, -) -> io::Result<()> { - struct RestoreTerminalInput; - impl Drop for RestoreTerminalInput { - fn drop(&mut self) { - let _ = execute!( - io::stdout(), - DisableBracketedPaste, - SetCursorStyle::DefaultUserShape - ); +impl Browser { + pub(crate) fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result> { + struct RestoreTerminalInput; + impl Drop for RestoreTerminalInput { + fn drop(&mut self) { + let _ = execute!( + io::stdout(), + DisableBracketedPaste, + SetCursorStyle::DefaultUserShape + ); + } } - } - let _restore = RestoreTerminalInput; - execute!( - io::stdout(), - EnableBracketedPaste, - SetCursorStyle::SteadyBlock - )?; - let mut browser = Browser::new(options, config_path, config, state); - browser.thumbnail.query_terminal(); - let started = Instant::now(); - loop { - browser.update(); - terminal.draw(|frame| browser.draw(frame, started.elapsed()))?; - if event::poll(Duration::from_millis(100))? { - match event::read()? { - Event::Key(key) if key.kind != KeyEventKind::Release => { - if browser.key(key) { - return Ok(()); + let _restore = RestoreTerminalInput; + execute!( + io::stdout(), + EnableBracketedPaste, + SetCursorStyle::SteadyBlock + )?; + self.thumbnail.query_terminal(); + let started = Instant::now(); + loop { + self.update(); + terminal.draw(|frame| self.draw(frame, started.elapsed()))?; + if event::poll(Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) if key.kind != KeyEventKind::Release => { + if self.config.keys.resolve(self.context(), key) + == Some(Action::ProviderChoose) + { + return crate::choose_provider(terminal, &self.config.keys, Some(true)); + } + if self.key(key) { + return Ok(None); + } } + Event::Paste(text) => self.paste(&text), + _ => {} } - Event::Paste(text) => browser.paste(&text), - _ => {} } } } diff --git a/tests/unit/keys.rs b/tests/unit/keys.rs index 914aa6f..598453f 100644 --- a/tests/unit/keys.rs +++ b/tests/unit/keys.rs @@ -15,6 +15,29 @@ fn press( bindings.resolve(context, KeyEvent::new(code, modifiers)) } +#[test] +fn provider_choice_is_configurable_and_normal_only() { + let defaults = Bindings::default(); + let overrides = configured("[keys.app]\nprovider = 'alt+p'\n").unwrap(); + for &context in ALL { + let expected = matches!(context, Context::Normal | Context::CollectionNormal) + .then_some(Action::ProviderChoose); + assert_eq!( + press(&defaults, context, KeyCode::Char('p'), KeyModifiers::NONE) + .filter(|action| *action == Action::ProviderChoose), + expected, + ); + assert_eq!( + press(&overrides, context, KeyCode::Char('p'), KeyModifiers::ALT), + expected, + ); + assert_ne!( + press(&overrides, context, KeyCode::Char('p'), KeyModifiers::NONE), + Some(Action::ProviderChoose), + ); + } +} + #[test] fn only_section_headers_are_bold_in_the_grouped_overview() { use ratatui::{