Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #36 from RivoLink/feat/interactive-drag-hover-scrollbar

feat: interactive drag/hover scrollbar

authored by

Rivo Link and committed by
GitHub
3a72e951 d57b9e49

+96 -11
+11 -1
src/app/mod.rs
··· 7 7 render::{build_status_bar, build_toc_line_with_index, toc_header_line}, 8 8 theme::{current_syntect_theme, current_theme_preset, theme_preset_index}, 9 9 }; 10 - use ratatui::text::Line; 10 + use ratatui::{layout::Rect, text::Line}; 11 11 use std::{ 12 12 path::PathBuf, 13 13 time::{Duration, Instant, SystemTime}, ··· 122 122 pub(super) theme_picker: ThemePickerState, 123 123 pub(super) editor_picker: EditorPickerState, 124 124 pub(super) render_width: usize, 125 + pub(crate) content_area: Rect, 126 + pub(crate) mouse_position: (u16, u16), 127 + pub(crate) scrollbar_dragging: bool, 125 128 pub(super) editor_config: Option<String>, 126 129 pub(super) editor_flash: Option<(EditorFlash, Instant)>, 127 130 watch_flash: Option<(WatchFlash, Instant)>, ··· 236 239 index: 0, 237 240 }, 238 241 render_width: 80, 242 + content_area: Rect::default(), 243 + mouse_position: (0, 0), 244 + scrollbar_dragging: false, 239 245 editor_config: None, 240 246 editor_flash: None, 241 247 watch_flash: None, ··· 622 628 623 629 pub(crate) fn scroll_bottom(&mut self) { 624 630 self.scroll = self.total().saturating_sub(1); 631 + } 632 + 633 + pub(crate) fn scroll_to(&mut self, position: usize) { 634 + self.scroll = position.min(self.total().saturating_sub(1)); 625 635 } 626 636 627 637 pub(crate) fn toggle_toc(&mut self) {
+33 -9
src/render/content.rs
··· 44 44 content_area, 45 45 ); 46 46 47 + let (mouse_col, mouse_row) = app.mouse_position; 48 + let sb_x = area.x + area.width - SCROLLBAR_WIDTH; 49 + let on_sb_column = mouse_col >= sb_x 50 + && mouse_col < sb_x + SCROLLBAR_WIDTH 51 + && mouse_row >= area.y 52 + && mouse_row < area.y + area.height; 53 + 54 + let total = app.total(); 55 + let track_len = area.height as usize; 56 + let mouse_on_thumb = on_sb_column && track_len > 0 && total > 0 && { 57 + let thumb_size = (track_len * track_len / total).max(1).min(track_len); 58 + let max_offset = track_len.saturating_sub(thumb_size); 59 + let thumb_offset = if total <= 1 { 60 + 0 61 + } else { 62 + app.scroll() * max_offset / (total - 1) 63 + }; 64 + let thumb_top = area.y as usize + thumb_offset; 65 + let thumb_bottom = thumb_top + thumb_size; 66 + let row = mouse_row as usize; 67 + row >= thumb_top && row < thumb_bottom 68 + }; 69 + 70 + let mut scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) 71 + .begin_symbol(None) 72 + .end_symbol(None) 73 + .track_symbol(Some("│")) 74 + .thumb_symbol("█"); 75 + if mouse_on_thumb || app.scrollbar_dragging { 76 + scrollbar = scrollbar.thumb_style(Style::default().fg(theme.ui.scrollbar_hover)); 77 + } 78 + 47 79 let mut scrollbar_state = ScrollbarState::new(app.total()).position(app.scroll()); 48 - f.render_stateful_widget( 49 - Scrollbar::new(ScrollbarOrientation::VerticalRight) 50 - .begin_symbol(None) 51 - .end_symbol(None) 52 - .track_symbol(Some("│")) 53 - .thumb_symbol("█"), 54 - area, 55 - &mut scrollbar_state, 56 - ); 80 + f.render_stateful_widget(scrollbar, area, &mut scrollbar_state); 57 81 } 58 82 59 83 fn inner_content_area(area: Rect) -> Rect {
+1
src/render/mod.rs
··· 36 36 toc::render_toc_panel(f, app, ta); 37 37 } 38 38 39 + app.content_area = content_area; 39 40 let viewport_height = content_area.height as usize; 40 41 content::render_content_panel(f, app, content_area, viewport_height); 41 42 content::render_status_bar(f, app, root[1], viewport_height);
+46 -1
src/runtime.rs
··· 5 5 }; 6 6 use anyhow::Result; 7 7 use crossterm::event::{self, poll, Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; 8 - use ratatui::{backend::CrosstermBackend, Terminal}; 8 + use ratatui::{backend::CrosstermBackend, layout::Rect, Terminal}; 9 9 use std::{ 10 10 fs::OpenOptions, 11 11 io, ··· 412 412 } 413 413 } 414 414 Event::Mouse(mouse) => { 415 + let prev_pos = app.mouse_position; 416 + app.mouse_position = (mouse.column, mouse.row); 415 417 let state_changed = if app.is_file_picker_open() || app.is_theme_picker_open() { 418 + if matches!(mouse.kind, MouseEventKind::Up(..)) { 419 + app.scrollbar_dragging = false; 420 + } 416 421 false 417 422 } else { 418 423 match mouse.kind { ··· 423 428 MouseEventKind::ScrollDown => { 424 429 app.scroll_down(MOUSE_SCROLL_STEP); 425 430 true 431 + } 432 + MouseEventKind::Down(..) 433 + if is_on_scrollbar(app.content_area, mouse.column, mouse.row) => 434 + { 435 + app.scrollbar_dragging = true; 436 + scrollbar_scroll_to(app, mouse.row); 437 + true 438 + } 439 + MouseEventKind::Drag(..) if app.scrollbar_dragging => { 440 + scrollbar_scroll_to(app, mouse.row); 441 + true 442 + } 443 + MouseEventKind::Up(..) => { 444 + app.scrollbar_dragging = false; 445 + false 446 + } 447 + MouseEventKind::Moved if prev_pos != app.mouse_position => { 448 + let area = app.content_area; 449 + let (prev_col, prev_row) = prev_pos; 450 + is_on_scrollbar(area, prev_col, prev_row) 451 + || is_on_scrollbar(area, mouse.column, mouse.row) 426 452 } 427 453 _ => false, 428 454 } ··· 560 586 } 561 587 } 562 588 Ok(()) 589 + } 590 + 591 + fn is_on_scrollbar(area: Rect, col: u16, row: u16) -> bool { 592 + area.width > 0 && { 593 + let sb_x = area.x + area.width - SCROLLBAR_WIDTH; 594 + col >= sb_x && col < sb_x + SCROLLBAR_WIDTH && row >= area.y && row < area.y + area.height 595 + } 596 + } 597 + 598 + fn scrollbar_scroll_to(app: &mut App, row: u16) { 599 + let content_top = app.content_area.y as usize; 600 + let content_height = app.content_area.height as usize; 601 + let row = row as usize; 602 + if row >= content_top && content_height > 1 { 603 + let offset = (row - content_top).min(content_height - 1); 604 + let max_scroll = app.total().saturating_sub(1); 605 + let scroll_pos = offset * max_scroll / (content_height - 1); 606 + app.scroll_to(scroll_pos); 607 + } 563 608 } 564 609 565 610 fn sync_render_width(
+5
src/theme.rs
··· 29 29 pub(crate) toc_bg: Color, 30 30 pub(crate) toc_border: Color, 31 31 pub(crate) content_bg: Color, 32 + pub(crate) scrollbar_hover: Color, 32 33 pub(crate) status_bg: Color, 33 34 pub(crate) status_separator: Color, 34 35 pub(crate) status_brand_fg: Color, ··· 94 95 toc_bg: Color::Rgb(232, 239, 245), 95 96 toc_border: Color::Rgb(170, 182, 194), 96 97 content_bg: Color::Rgb(242, 247, 250), 98 + scrollbar_hover: Color::Rgb(76, 122, 168), 97 99 status_bg: Color::Rgb(224, 233, 240), 98 100 status_separator: Color::Rgb(108, 126, 144), 99 101 status_brand_fg: Color::Rgb(245, 248, 250), ··· 129 131 toc_bg: Color::Rgb(18, 18, 22), 130 132 toc_border: Color::Rgb(52, 52, 58), 131 133 content_bg: Color::Rgb(18, 20, 28), 134 + scrollbar_hover: Color::Rgb(105, 178, 218), 132 135 status_bg: Color::Rgb(18, 20, 32), 133 136 status_separator: Color::Rgb(116, 126, 156), 134 137 status_brand_fg: Color::Rgb(16, 18, 26), ··· 230 233 toc_bg: Color::Rgb(16, 22, 18), 231 234 toc_border: Color::Rgb(50, 66, 54), 232 235 content_bg: Color::Rgb(19, 26, 22), 236 + scrollbar_hover: Color::Rgb(126, 198, 170), 233 237 status_bg: Color::Rgb(18, 27, 24), 234 238 status_separator: Color::Rgb(112, 141, 126), 235 239 status_brand_fg: Color::Rgb(14, 21, 18), ··· 302 306 toc_bg: Color::Rgb(7, 54, 66), 303 307 toc_border: Color::Rgb(88, 110, 117), 304 308 content_bg: Color::Rgb(0, 43, 54), 309 + scrollbar_hover: Color::Rgb(42, 161, 152), 305 310 status_bg: Color::Rgb(0, 43, 54), 306 311 status_separator: Color::Rgb(101, 123, 131), 307 312 status_brand_fg: Color::Rgb(0, 43, 54),