Skip to content

Commit 82194d5

Browse files
committed
feat: add mouse drag scroll
1 parent b12a470 commit 82194d5

9 files changed

Lines changed: 125 additions & 26 deletions

File tree

.github/workflows/release.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,4 +297,9 @@ jobs:
297297
TARGET_REPO="${CLOUDSMITH_REPO}/${TARGET_DISTRIBUTION}/${TARGET_RELEASE}"
298298
fi
299299
echo "Publishing to Cloudsmith repository: ${TARGET_REPO}"
300-
cloudsmith push deb "${TARGET_REPO}" dist/debian-*/*.deb --republish
300+
shopt -s nullglob
301+
for pkg in dist/debian-*/*.deb; do
302+
if [[ -f "${pkg}" ]]; then
303+
cloudsmith push deb --republish "${TARGET_REPO}" "${pkg}"
304+
fi
305+
done

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## [0.1.9] - 2026-03-10
4+
5+
### Changed
6+
7+
- Improved preview UX for scrollbar interactions by adding click-and-drag scrolling and visible scroll-position metadata in the preview border.
8+
- Removed plain-text fallback notice text from rendered previews to keep content display clean.
9+
- Fixed Cloudsmith apt publish flow to avoid malformed CLI argument ordering and support iterating multiple `.deb` artifacts.
10+
311
## [0.1.8] - 2026-03-09
412

513
### Changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "fpv"
3-
version = "0.1.8"
3+
version = "0.1.9"
44
edition = "2021"
55
description = "A minimal, keyboard-first TUI file previewer with syntax highlighting"
66
authors = ["Digicrafts"]

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
A minimal, keyboard-first TUI file previewer for browsing directories and viewing code with syntax highlighting in the terminal.
88

9-
Current release: `v0.1.8`
9+
Current release: `v0.1.9`
1010

1111
---
1212

src/app/state.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ pub struct SessionState {
235235
pub preview_scroll_col: usize,
236236
pub preview_selection: Option<PreviewSelection>,
237237
pub preview_selecting: bool,
238+
pub preview_scrollbar_dragging: bool,
238239
pub preview_inner_rect: (u16, u16, u16, u16),
239240
pub preview_line_number_cols: usize,
240241
pub preview_render_epoch: u64,
@@ -282,6 +283,7 @@ impl SessionState {
282283
preview_scroll_col: 0,
283284
preview_selection: None,
284285
preview_selecting: false,
286+
preview_scrollbar_dragging: false,
285287
preview_inner_rect: (0, 0, 0, 0),
286288
preview_line_number_cols: 0,
287289
preview_render_epoch: 0,
@@ -316,6 +318,7 @@ impl SessionState {
316318
self.preview_scroll_col = 0;
317319
self.preview_selection = None;
318320
self.preview_selecting = false;
321+
self.preview_scrollbar_dragging = false;
319322
}
320323

321324
pub fn clamp_preview_scroll(&mut self, total_lines: usize, viewport_rows: usize) {

src/tui/event_loop.rs

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,21 @@ fn preview_scrollbar_target_row(
168168
Some(target_row.min(max_scroll))
169169
}
170170

171+
fn handle_preview_scrollbar_drag(
172+
state: &mut SessionState,
173+
mouse: MouseEvent,
174+
total_lines: usize,
175+
) -> bool {
176+
let Some(target_row) = preview_scrollbar_target_row(state, mouse, total_lines) else {
177+
return false;
178+
};
179+
if target_row == state.preview_scroll_row {
180+
return false;
181+
}
182+
state.preview_scroll_row = target_row;
183+
true
184+
}
185+
171186
fn preview_viewport_cols(state: &SessionState) -> usize {
172187
let (_, _, inner_w, _) = state.preview_inner_rect;
173188
(inner_w as usize).saturating_sub(state.preview_line_number_cols)
@@ -775,7 +790,7 @@ pub fn process_once(
775790
}
776791
}
777792
}
778-
Event::Mouse(mouse) => {
793+
Event::Mouse(mouse) => {
779794
if state.help_overlay_visible {
780795
return Ok((false, false, false));
781796
}
@@ -785,12 +800,14 @@ pub fn process_once(
785800

786801
if !state.divider_drag_active {
787802
state.preview_copy_indicator = false;
803+
let rendered_total_lines = rendered_preview_total_lines(state, preview_total_lines);
788804
match mouse.kind {
789805
MouseEventKind::Down(MouseButton::Left) => {
790806
// If we were selecting, finalize the previous selection
791807
if state.preview_selecting {
792808
finalize_selection(state, preview_doc);
793809
}
810+
state.preview_scrollbar_dragging = false;
794811

795812
let mut tree_clicked = false;
796813
if let Some(index) = tree_index_for_click(state, mouse, nodes.len()) {
@@ -814,8 +831,6 @@ pub fn process_once(
814831
}
815832
}
816833

817-
let rendered_total_lines =
818-
rendered_preview_total_lines(state, preview_total_lines);
819834
if !tree_clicked {
820835
if let Some(target_row) =
821836
preview_scrollbar_target_row(state, mouse, rendered_total_lines)
@@ -824,6 +839,7 @@ pub fn process_once(
824839
state.preview_selection = None;
825840
state.preview_selecting = false;
826841
state.preview_copying_indicator = false;
842+
state.preview_scrollbar_dragging = true;
827843
} else if in_preview_panel(state, mouse) {
828844
let pos = mouse_to_content_position(state, mouse);
829845
state.preview_selection = Some(PreviewSelection {
@@ -838,13 +854,23 @@ pub fn process_once(
838854
}
839855
}
840856
}
857+
MouseEventKind::Drag(MouseButton::Left) if state.preview_scrollbar_dragging => {
858+
let _ = handle_preview_scrollbar_drag(
859+
state,
860+
mouse,
861+
rendered_total_lines,
862+
);
863+
}
841864
MouseEventKind::Drag(MouseButton::Left) if state.preview_selecting => {
842865
let pos = mouse_to_content_position(state, mouse);
843866
if let Some(sel) = &mut state.preview_selection {
844867
sel.cursor = pos;
845868
}
846869
}
847870
// Some terminals send Moved instead of Drag during button hold
871+
MouseEventKind::Moved if state.preview_scrollbar_dragging => {
872+
let _ = handle_preview_scrollbar_drag(state, mouse, rendered_total_lines);
873+
}
848874
MouseEventKind::Moved if state.preview_selecting => {
849875
let pos = mouse_to_content_position(state, mouse);
850876
if let Some(sel) = &mut state.preview_selection {
@@ -854,6 +880,9 @@ pub fn process_once(
854880
MouseEventKind::Up(MouseButton::Left) if state.preview_selecting => {
855881
finalize_selection(state, preview_doc);
856882
}
883+
MouseEventKind::Up(MouseButton::Left) if state.preview_scrollbar_dragging => {
884+
state.preview_scrollbar_dragging = false;
885+
}
857886
_ => {}
858887
}
859888

@@ -930,7 +959,8 @@ pub fn process_once(
930959
#[cfg(test)]
931960
mod tests {
932961
use super::{
933-
apply_mouse_resize, preview_scrollbar_target_row, tree_index_for_click, tree_panel_area,
962+
apply_mouse_resize, handle_preview_scrollbar_drag, preview_scrollbar_target_row,
963+
tree_index_for_click, tree_panel_area,
934964
};
935965
use crate::app::state::{PreviewRenderCache, PreviewRenderCacheKey, SessionState};
936966
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
@@ -988,6 +1018,24 @@ mod tests {
9881018
assert_eq!(preview_scrollbar_target_row(&state, click, 8), None);
9891019
}
9901020

1021+
#[test]
1022+
fn preview_scrollbar_drag_moves_scroll_row() {
1023+
let mut state = SessionState::new(PathBuf::from("."));
1024+
state.preview_inner_rect = (40, 2, 30, 10);
1025+
state.preview_scroll_row = 0;
1026+
state.preview_scrollbar_dragging = true;
1027+
1028+
let drag = MouseEvent {
1029+
kind: MouseEventKind::Drag(MouseButton::Left),
1030+
column: 69,
1031+
row: 8,
1032+
modifiers: KeyModifiers::NONE,
1033+
};
1034+
1035+
assert_eq!(handle_preview_scrollbar_drag(&mut state, drag, 100), true);
1036+
assert_eq!(state.preview_scroll_row, 60);
1037+
}
1038+
9911039
#[test]
9921040
fn rendered_preview_total_lines_prefers_render_cache() {
9931041
let mut state = SessionState::new(PathBuf::from("."));

src/tui/preview_pane.rs

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::app::state::{
2-
ContentType, LoadState, PreviewDocument, PreviewFallbackReason, PreviewLineChange,
2+
ContentType, LoadState, PreviewDocument, PreviewLineChange,
33
PreviewRenderCache, PreviewRenderCacheKey, PreviewSelection, SessionState,
44
};
55
use crate::config::load::ThemeProfile;
@@ -32,26 +32,57 @@ fn line_count(text: &str) -> usize {
3232
text.split('\n').count().max(1)
3333
}
3434

35+
fn preview_scroll_position_text(
36+
state: &SessionState,
37+
total_lines: usize,
38+
) -> String {
39+
let total = total_lines.max(1);
40+
let current = state.preview_scroll_row.saturating_add(1).min(total);
41+
format!("[{current}:{total}]")
42+
}
43+
44+
fn preview_border_bottom_line(
45+
state: &SessionState,
46+
total_lines: usize,
47+
width: usize,
48+
) -> String {
49+
let left = preview_scroll_position_text(state, total_lines);
50+
if width <= left.len() {
51+
return left.chars().take(width).collect();
52+
}
53+
54+
let right = preview_border_metadata_for_state(state, width.saturating_sub(left.len() + 1));
55+
let mut line = left;
56+
line.push(' ');
57+
let right_width = width.saturating_sub(line.len());
58+
59+
if right_width == 0 {
60+
return line.chars().take(width).collect();
61+
}
62+
63+
let right_with_pad = if right.is_empty() {
64+
String::new()
65+
} else {
66+
let gap = right_width.saturating_sub(right.len());
67+
format!("{}{}", " ".repeat(gap), right)
68+
};
69+
line.push_str(&right_with_pad);
70+
71+
if line.len() > width {
72+
line.chars().take(width).collect()
73+
} else {
74+
line
75+
}
76+
}
77+
3578
fn plain_text_for_doc(doc: &PreviewDocument) -> String {
3679
match doc.load_state {
3780
LoadState::Error | LoadState::Binary => doc
3881
.error_message
3982
.clone()
4083
.unwrap_or_else(|| "Unable to render preview".to_string()),
4184
_ => {
42-
let mut content = doc.content_excerpt.clone();
43-
if matches!(doc.content_type, ContentType::PlainText) {
44-
if let Some(reason) = &doc.fallback_reason {
45-
let reason_text = match reason {
46-
PreviewFallbackReason::UnsupportedExtension => "unsupported-extension",
47-
PreviewFallbackReason::EngineFailure => "highlight-failed",
48-
PreviewFallbackReason::TooLarge => "large-file-guard",
49-
PreviewFallbackReason::DecodeUncertain => "decode-uncertain",
50-
};
51-
content = format!("[plain-text fallback: {reason_text}]\n{content}");
52-
}
53-
}
54-
content
85+
doc.content_excerpt.clone()
5586
}
5687
}
5788
}
@@ -764,14 +795,18 @@ pub fn draw_preview(
764795
) {
765796
frame.render_widget(Clear, area);
766797
let title = preview_title_for_state(state);
767-
let metadata_line =
768-
preview_border_metadata_for_state(state, area.width.saturating_sub(2) as usize);
798+
let total_lines = preview_total_lines(doc);
799+
let metadata_line = preview_border_bottom_line(
800+
state,
801+
total_lines,
802+
area.width.saturating_sub(2) as usize,
803+
);
769804
let block = Block::default()
770805
.title(
771806
Line::from(vec![Span::raw(" "), Span::raw(title), Span::raw(" ")])
772807
.alignment(Alignment::Right),
773808
)
774-
.title_bottom(Line::from(metadata_line).alignment(Alignment::Right))
809+
.title_bottom(Line::from(metadata_line).alignment(Alignment::Left))
775810
.borders(Borders::ALL);
776811
let inner = block.inner(area);
777812
frame.render_widget(block, area);

tests/unit/preview_mode_tests.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,7 @@ fn preview_total_lines_counts_fallback_header_and_content() {
557557
fallback_reason: Some(PreviewFallbackReason::UnsupportedExtension),
558558
..fpv::app::state::PreviewDocument::default()
559559
};
560-
assert_eq!(preview_total_lines(&doc), 3);
560+
assert_eq!(preview_total_lines(&doc), 2);
561561

562562
doc.fallback_reason = None;
563563
assert_eq!(preview_total_lines(&doc), 2);

0 commit comments

Comments
 (0)