ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git
commit bb40b5bd6c6dbda79cc1a3e82422c74e6ea1571d
parent 3e8f73864576e9125d2be9f35286fbf7086d9ddd
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 16:41:15 +0900

Replace the Ctrl+E chord prefix with direct Ctrl shortcuts

Search, help, about and library sync are now Ctrl+S, Ctrl+H, Ctrl+A and
Ctrl+L instead of a two-keystroke chord. About moved off i because terminals
send Ctrl+I as the same byte as Tab (0x09) and Ctrl+M as Enter (0x0D), so
neither can be bound; Ctrl+H is fine, since Backspace is 0x7F rather than
0x08, and Ctrl+S is safe because raw mode clears IXON.

The Ctrl arms have to be matched before the unmodified ones: the existing
global bindings match on any modifier, so Ctrl+S would have toggled shuffle.

Keyboard resize mode is gone — dragging a panel border already did the same
job. The drag now requires Ctrl, so an ordinary click one column from a
border selects a list row instead of silently starting a resize.

Ctrl+C now quits from anywhere including an open modal, and Ctrl-modified
keys no longer type a literal character into the search and playlist name
fields.

Diffstat:
MREADME.md | 17++++++++---------
Msrc/app/handler.rs | 202++++++++++++++++++++++++++++++++++++++-----------------------------------------
Msrc/main.rs | 2--
Msrc/ui/mod.rs | 22+---------------------
Msrc/ui/widgets/help_modal.rs | 15++++++++-------
Msrc/ui/widgets/status_bar.rs | 8+-------
6 files changed, 116 insertion(+), 150 deletion(-)

diff --git a/README.md b/README.md
@@ -106,7 +106,7 @@ Switch browsing modes using the tabs at the top: Queue, Directories, Artists, Al
 
 ### Search
 
-Press `Ctrl+E, s` to open the search modal. Results filter as you type.
+Press `Ctrl+S` to open the search modal. Results filter as you type.
 
 You can also search by specific fields:
 
@@ -117,7 +117,7 @@ You can also search by specific fields:
 
 ## Keybindings
 
-Press `Ctrl+E, h` inside the app to view the full keybinding list at any time.
+Press `Ctrl+H` inside the app to view the full keybinding list at any time.
 
 ### Playback
 
@@ -147,15 +147,14 @@ Press `Ctrl+E, h` inside the app to view the full keybinding list at any time.
 | `q` | Quit |
 | `Ctrl+C` | Quit |
 
-### Chord commands (press `Ctrl+E`, then a key)
+### Commands
 
 | Key | Action |
 |-----|--------|
-| `Ctrl+E, s` | Search |
-| `Ctrl+E, h` | Help |
-| `Ctrl+E, r` | Panel resize mode |
-| `Ctrl+E, i` | About OMMP |
-| `Ctrl+E, l` | Rescan library |
+| `Ctrl+S` | Search |
+| `Ctrl+H` | Help |
+| `Ctrl+A` | About OMMP |
+| `Ctrl+L` | Rescan library |
 
 ### Mouse
 
@@ -164,7 +163,7 @@ Press `Ctrl+E, h` inside the app to view the full keybinding list at any time.
 | Click tab | Switch tab |
 | Click panel | Focus panel + select item |
 | Double-click track | Play immediately |
-| Drag panel border | Resize panels |
+| `Ctrl` + drag panel border | Resize panels |
 | Click progress bar | Seek to position |
 | Scroll wheel | Scroll lists |
 
diff --git a/src/app/handler.rs b/src/app/handler.rs
@@ -29,9 +29,22 @@ fn split_widths(boundary_pct: u16, fixed: u16, min_w: u16) -> Option<(u16, u16)>
     Some((first, budget - first))
 }
 
+/// True for a plain character keystroke, i.e. one that should be typed into a
+/// text field. Without the check, Ctrl+C inside the search box inserted a
+/// literal "c" instead of quitting.
+fn is_text_input(key: KeyEvent) -> bool {
+    !key.modifiers.contains(KeyModifiers::CONTROL)
+}
+
 pub fn handle_key_event(key: KeyEvent, app: &App, ui: &mut Ui) -> Vec<AppAction> {
     let mut actions = Vec::new();
 
+    // Quit works from anywhere, including with a modal open.
+    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
+        actions.push(AppAction::Quit);
+        return actions;
+    }
+
     // About modal: Esc to close, g/s to open URLs
     if ui.show_about_modal {
         match key.code {
@@ -94,7 +107,7 @@ pub fn handle_key_event(key: KeyEvent, app: &App, ui: &mut Ui) -> Vec<AppAction>
                     KeyCode::Backspace => {
                         ui.playlist_modal_input.pop();
                     }
-                    KeyCode::Char(c) => {
+                    KeyCode::Char(c) if is_text_input(key) => {
                         ui.playlist_modal_input.push(c);
                     }
                     _ => {}
@@ -212,7 +225,7 @@ pub fn handle_key_event(key: KeyEvent, app: &App, ui: &mut Ui) -> Vec<AppAction>
                 ui.search_modal_selected = 0;
                 ui.search_modal_scroll = 0;
             }
-            KeyCode::Char(c) => {
+            KeyCode::Char(c) if is_text_input(key) => {
                 ui.search_modal_input.push(c);
                 ui.search_modal_results = app.library.search(&ui.search_modal_input);
                 ui.search_modal_selected = 0;
@@ -223,62 +236,18 @@ pub fn handle_key_event(key: KeyEvent, app: &App, ui: &mut Ui) -> Vec<AppAction>
         return actions;
     }
 
-    // Chord: Ctrl+E pressed, waiting for next key
-    if ui.chord_pending {
-        ui.chord_pending = false;
-        match key.code {
-            KeyCode::Char('s') => {
-                ui.show_search_modal = true;
-            }
-            KeyCode::Char('h') => {
-                ui.show_help_modal = true;
-            }
-            KeyCode::Char('r') => {
-                ui.resize_mode = !ui.resize_mode;
-            }
-            KeyCode::Char('i') => {
-                ui.show_about_modal = true;
-            }
-            KeyCode::Char('l') => {
-                actions.push(AppAction::LibrarySync);
-            }
-            _ => {} // unknown chord, ignore
-        }
-        return actions;
-    }
-
-    // Ctrl+E → chord pending
-    if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('e') {
-        ui.chord_pending = true;
-        return actions;
-    }
-
-    // Resize mode key handling
-    if ui.resize_mode {
+    // Ctrl-modified commands. These must come before the unmodified bindings
+    // below, which match on any modifier — Ctrl+S would otherwise be swallowed
+    // by the plain `s` shuffle arm.
+    //
+    // Ctrl+I and Ctrl+M are deliberately absent: terminals send them as the same
+    // bytes as Tab (0x09) and Enter (0x0D), so they cannot be told apart.
+    if key.modifiers.contains(KeyModifiers::CONTROL) {
         match key.code {
-            KeyCode::Char('h') | KeyCode::Left => {
-                resize_pane(ui, app.focus, -2);
-            }
-            KeyCode::Char('l') | KeyCode::Right => {
-                resize_pane(ui, app.focus, 2);
-            }
-            KeyCode::Char('k') | KeyCode::Up => {
-                // Grow info pane (shrink lyrics)
-                let new_split = (ui.right_split as i16 - 3).clamp(10, 90) as u16;
-                ui.right_split = new_split;
-            }
-            KeyCode::Char('j') | KeyCode::Down => {
-                // Grow lyrics (shrink info pane)
-                let new_split = (ui.right_split as i16 + 3).clamp(10, 90) as u16;
-                ui.right_split = new_split;
-            }
-            KeyCode::Esc | KeyCode::Enter => {
-                ui.resize_mode = false;
-            }
-            KeyCode::Char('q') => {
-                ui.resize_mode = false;
-                actions.push(AppAction::Quit);
-            }
+            KeyCode::Char('s') => ui.show_search_modal = true,
+            KeyCode::Char('h') => ui.show_help_modal = true,
+            KeyCode::Char('a') => ui.show_about_modal = true,
+            KeyCode::Char('l') => actions.push(AppAction::LibrarySync),
             _ => {}
         }
         return actions;
@@ -290,10 +259,6 @@ pub fn handle_key_event(key: KeyEvent, app: &App, ui: &mut Ui) -> Vec<AppAction>
             actions.push(AppAction::Quit);
             return actions;
         }
-        (KeyModifiers::CONTROL, KeyCode::Char('c')) => {
-            actions.push(AppAction::Quit);
-            return actions;
-        }
         (_, KeyCode::Char(' ')) => {
             actions.push(AppAction::PauseResume);
             return actions;
@@ -583,8 +548,10 @@ pub fn handle_mouse_event(
     // --- Handle specific event kinds ---
     match mouse.kind {
         MouseEventKind::Down(MouseButton::Left) => {
-            // Border drag start detection
-            if in_dashboard_y {
+            // Border drag start detection. Ctrl is required so that an ordinary
+            // click one column from a border selects a list row instead of
+            // silently starting a resize.
+            if in_dashboard_y && mouse.modifiers.contains(KeyModifiers::CONTROL) {
                 if x.abs_diff(border0_x) <= 1 {
                     ui.dragging_border = Some(0);
                     return actions;
@@ -882,47 +849,6 @@ pub fn refresh_hover(app: &App, ui: &mut Ui, terminal_area: ratatui::layout::Rec
     update_tab_hover(ui, &areas, x, y);
 }
 
-/// Resize the focused pane by delta percentage points.
-/// Positive delta = grow the focused pane rightward, negative = shrink rightward.
-fn resize_pane(ui: &mut Ui, focus: FocusedPane, delta: i16) {
-    let min_width: u16 = 10;
-    let w = &mut ui.pane_widths;
-
-    match focus {
-        FocusedPane::Library => {
-            let new_lib = (w[0] as i16 + delta).clamp(min_width as i16, 80) as u16;
-            let diff = new_lib as i16 - w[0] as i16;
-            let new_play = (w[1] as i16 - diff).max(min_width as i16) as u16;
-            let actual_diff = w[1] as i16 - new_play as i16;
-            w[0] = (w[0] as i16 + actual_diff) as u16;
-            w[1] = new_play;
-        }
-        FocusedPane::Playlist => {
-            if delta < 0 {
-                let shrink = (-delta) as u16;
-                if w[1] > min_width + shrink - 1 {
-                    w[1] -= shrink;
-                    w[0] += shrink;
-                }
-            } else {
-                let grow = delta as u16;
-                if w[2] > min_width + grow - 1 {
-                    w[2] -= grow;
-                    w[1] += grow;
-                }
-            }
-        }
-        FocusedPane::Lyrics => {
-            let new_lyr = (w[2] as i16 - delta).clamp(min_width as i16, 80) as u16;
-            let diff = w[2] as i16 - new_lyr as i16;
-            let new_play = (w[1] as i16 + diff).max(min_width as i16) as u16;
-            let actual_diff = new_play as i16 - w[1] as i16;
-            w[2] = (w[2] as i16 - actual_diff) as u16;
-            w[1] = new_play;
-        }
-    }
-}
-
 /// Update queue selection based on keyboard in playlist focus
 pub fn update_queue_selection(app: &mut App, key: KeyEvent) {
     let count = app.queue.tracks.len();
@@ -947,6 +873,74 @@ pub fn update_queue_selection(app: &mut App, key: KeyEvent) {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use std::path::PathBuf;
+
+    fn new_ui() -> Ui {
+        Ui::new(
+            PathBuf::from("."),
+            ratatui_image::picker::Picker::from_fontsize((8, 16)),
+        )
+    }
+
+    fn ctrl(c: char) -> KeyEvent {
+        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
+    }
+
+    fn plain(c: char) -> KeyEvent {
+        KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
+    }
+
+    #[test]
+    fn ctrl_commands_open_their_modals() {
+        let app = App::new(PathBuf::from("."));
+
+        let mut ui = new_ui();
+        handle_key_event(ctrl('s'), &app, &mut ui);
+        assert!(ui.show_search_modal);
+
+        let mut ui = new_ui();
+        handle_key_event(ctrl('h'), &app, &mut ui);
+        assert!(ui.show_help_modal);
+
+        let mut ui = new_ui();
+        handle_key_event(ctrl('a'), &app, &mut ui);
+        assert!(ui.show_about_modal);
+
+        let mut ui = new_ui();
+        let actions = handle_key_event(ctrl('l'), &app, &mut ui);
+        assert!(matches!(actions.as_slice(), [AppAction::LibrarySync]));
+    }
+
+    #[test]
+    fn ctrl_shortcuts_do_not_fall_through_to_the_unmodified_binding() {
+        let app = App::new(PathBuf::from("."));
+        let mut ui = new_ui();
+
+        // The plain-`s` arm matches any modifier, so without ordering the Ctrl
+        // block first, Ctrl+S would toggle shuffle instead of opening search.
+        let actions = handle_key_event(ctrl('s'), &app, &mut ui);
+        assert!(actions.is_empty(), "Ctrl+S must not emit ToggleShuffle");
+        assert!(ui.show_search_modal);
+
+        // Unmodified keys are untouched.
+        let mut ui = new_ui();
+        let actions = handle_key_event(plain('s'), &app, &mut ui);
+        assert!(matches!(actions.as_slice(), [AppAction::ToggleShuffle]));
+        assert!(!ui.show_search_modal);
+    }
+
+    #[test]
+    fn ctrl_c_quits_even_from_inside_a_text_field() {
+        let app = App::new(PathBuf::from("."));
+        let mut ui = new_ui();
+        ui.show_search_modal = true;
+
+        let actions = handle_key_event(ctrl('c'), &app, &mut ui);
+
+        assert!(matches!(actions.as_slice(), [AppAction::Quit]));
+        // It used to be typed into the query as a literal "c".
+        assert!(ui.search_modal_input.is_empty());
+    }
 
     #[test]
     fn split_widths_keeps_the_total_at_100_and_both_panes_above_the_minimum() {
diff --git a/src/main.rs b/src/main.rs
@@ -211,8 +211,6 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
                             && !ui.show_search_modal
                             && !ui.show_help_modal
                             && !ui.show_playlist_modal
-                            && !ui.resize_mode
-                            && !ui.chord_pending
                         {
                             handler::update_queue_selection(&mut app, key);
                         }
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
@@ -6,8 +6,6 @@ pub mod theme;
 pub mod widgets;
 
 use ratatui::Frame;
-use ratatui::style::{Color, Style};
-use ratatui::widgets::{Block, Borders};
 use widgets::{about_modal, help_modal, playlist_modal, search_modal};
 use widgets::playlist_modal::PlaylistModalMode;
 
@@ -53,14 +51,10 @@ pub struct Ui {
     pub hovered_tab: Option<usize>,
     /// Pane width percentages [Library, Playlist, Lyrics], sum = 100
     pub pane_widths: [u16; 3],
-    /// Resize mode active (Ctrl+E)
-    pub resize_mode: bool,
     /// Border being dragged: 0 = lib|playlist, 1 = playlist|lyrics, 2 = info|lyrics (horizontal), None = not dragging
     pub dragging_border: Option<u8>,
     /// Right column split: info pane height percentage (top), lyrics gets the rest
     pub right_split: u16,
-    /// Ctrl+E pressed, waiting for next key
-    pub chord_pending: bool,
     /// Help modal visible
     pub show_help_modal: bool,
     /// Search modal visible
@@ -112,10 +106,8 @@ impl Ui {
             mouse_pos: None,
             hovered_tab: None,
             pane_widths: [20, 60, 20],
-            resize_mode: false,
             dragging_border: None,
             right_split: 50,
-            chord_pending: false,
             show_help_modal: false,
             show_search_modal: false,
             search_modal_input: String::new(),
@@ -157,7 +149,7 @@ impl Ui {
         let areas = LayoutAreas::compute(frame.area(), self.pane_widths, self.right_split);
 
         // Status bar
-        status_bar::render_status_bar(frame, areas.status_bar, app, &self.theme, self.resize_mode);
+        status_bar::render_status_bar(frame, areas.status_bar, app, &self.theme);
 
         // Tab bar
         tab_bar::render_tab_bar(frame, areas.tab_bar, app.tab, self.hovered_tab, &self.theme);
@@ -189,18 +181,6 @@ impl Ui {
         // Progress bar
         progress_bar::render_progress_bar(frame, areas.progress_bar, app, &self.theme);
 
-        // Resize mode: overlay yellow border on focused pane
-        if self.resize_mode {
-            let focused_area = match app.focus {
-                FocusedPane::Library => areas.library,
-                FocusedPane::Playlist => areas.playlist,
-                FocusedPane::Lyrics => areas.lyrics,
-            };
-            let overlay = Block::default()
-                .borders(Borders::ALL)
-                .border_style(Style::default().fg(Color::Yellow));
-            frame.render_widget(overlay, focused_area);
-        }
 
         // Modal overlays (rendered last, on top of everything)
         if self.show_search_modal {
diff --git a/src/ui/widgets/help_modal.rs b/src/ui/widgets/help_modal.rs
@@ -18,11 +18,10 @@ const PLAYBACK: &[(&str, &str)] = &[
     ("b", "Add to playlist"),
     ("p", "Info panel: clock / art"),
     ("", ""),
-    ("Ctrl+E, s", "Search"),
-    ("Ctrl+E, h", "Help (this modal)"),
-    ("Ctrl+E, r", "Resize mode"),
-    ("Ctrl+E, i", "About OMMP"),
-    ("Ctrl+E, l", "Sync library"),
+    ("Ctrl+S", "Search"),
+    ("Ctrl+H", "Help (this modal)"),
+    ("Ctrl+A", "About OMMP"),
+    ("Ctrl+L", "Sync library"),
 ];
 
 /// Right column: navigation and the queue.
@@ -37,9 +36,11 @@ const NAVIGATION: &[(&str, &str)] = &[
     ("d", "Remove from queue"),
     ("c", "Clear queue"),
     ("", ""),
+    ("Ctrl+drag border", "Resize panels"),
+    ("Double-click", "Play queue track"),
+    ("", ""),
     ("Esc", "Close modal"),
-    ("q", "Quit"),
-    ("Ctrl+C", "Quit"),
+    ("q / Ctrl+C", "Quit"),
 ];
 
 pub fn render_help_modal(frame: &mut Frame, area: Rect, theme: &Theme) {
diff --git a/src/ui/widgets/status_bar.rs b/src/ui/widgets/status_bar.rs
@@ -9,7 +9,7 @@ use crate::app::state::{PlayState, SyncState};
 use crate::ui::theme::Theme;
 use crate::ui::text::format_time;
 
-pub fn render_status_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme, resize_mode: bool) {
+pub fn render_status_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
     let block = if let Some(err) = &app.audio_error {
         // Nothing will ever play; without this the UI shows a play state and a
         // moving progress bar over silence.
@@ -22,12 +22,6 @@ pub fn render_status_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme
                     .fg(Color::Rgb(255, 90, 90))
                     .add_modifier(Modifier::BOLD),
             )
-    } else if resize_mode {
-        Block::default()
-            .borders(Borders::ALL)
-            .border_style(Style::default().fg(Color::Yellow))
-            .title(" [RESIZE] ")
-            .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
     } else if app.sync_state == SyncState::Scanning {
         Block::default()
             .borders(Borders::ALL)