use log::debug;
use std::path::Path;
use crate::url::LinkTarget;
use super::effect::ExitReason;
use super::input_history::ScrollDirection;
use super::keymap::Action;
use super::layout::{ScrollState, visual_line_offset};
use super::mode_command::CommandState;
use super::mode_grep::{LastSearch, SearchDirection};
use super::mode_inline_search::InlineSearchState;
use super::mode_toc::{TocState, collect_headings};
use super::mode_url::{UrlPickerEntry, UrlPickerState, collect_all_url_entries};
use super::query::DocumentQuery;
use super::{Effect, ViewerMode};
pub(super) fn open_link_target(target: &LinkTarget, current_file: Option<&Path>) -> Vec<Effect> {
match target {
LinkTarget::LocalMarkdown(rel) => {
match current_file.and_then(|f| crate::url::resolve_link_path(rel, f)) {
Some(path) => vec![Effect::Exit(ExitReason::Navigate { path })],
None => vec![Effect::OpenExternalUrl(rel.clone())],
}
}
LinkTarget::ExternalUrl(url) => vec![Effect::OpenExternalUrl(url.clone())],
}
}
pub(super) struct NormalCtx<'a> {
pub scroll: &'a ScrollState,
pub doc: &'a DocumentQuery<'a>,
pub max_scroll: u32,
pub scroll_step: u32,
pub half_page: u32,
pub last_search: &'a mut Option<LastSearch>,
pub current_file: Option<&'a Path>,
pub current_scale: f64,
}
const ZOOM_PRESETS: &[f64] = &[0.50, 0.67, 0.85, 1.00, 1.10, 1.25, 1.50, 2.00];
fn next_zoom_preset(current: f64, dir: i32) -> f64 {
debug_assert!(dir == 1 || dir == -1);
if dir > 0 {
ZOOM_PRESETS
.iter()
.copied()
.find(|&p| p > current + 1e-9)
.unwrap_or(current)
} else {
ZOOM_PRESETS
.iter()
.rev()
.copied()
.find(|&p| p < current - 1e-9)
.unwrap_or(current)
}
}
fn format_zoom(scale: f64) -> String {
format!("zoom: {}%", (scale * 100.0).round() as i32)
}
fn zoom_effects(current: f64, target: f64) -> Vec<Effect> {
if (target - current).abs() < 1e-9 {
let label = if ZOOM_PRESETS
.last()
.is_some_and(|&m| (target - m).abs() < 1e-9)
{
"max"
} else if ZOOM_PRESETS
.first()
.is_some_and(|&m| (target - m).abs() < 1e-9)
{
"min"
} else {
"no change"
};
vec![
Effect::Flash(format!("{} ({label})", format_zoom(target))),
Effect::RedrawStatusBar,
]
} else {
vec![Effect::Exit(ExitReason::SetScale {
old: current,
new: target,
flash: Some(format_zoom(target)),
})]
}
}
pub(super) fn handle(action: Action, ctx: &mut NormalCtx) -> Vec<Effect> {
match action {
Action::Quit => vec![Effect::Exit(ExitReason::Quit)],
Action::CancelInput => vec![Effect::RedrawStatusBar],
Action::Digit => vec![Effect::RedrawStatusBar],
Action::ZoomIn => zoom_effects(ctx.current_scale, next_zoom_preset(ctx.current_scale, 1)),
Action::ZoomOut => zoom_effects(ctx.current_scale, next_zoom_preset(ctx.current_scale, -1)),
Action::ZoomReset => zoom_effects(ctx.current_scale, 1.0),
Action::ScrollDown(count) => {
let cur = ctx.scroll.derived_target(ctx.max_scroll);
let y = (cur + count * ctx.scroll_step).min(ctx.max_scroll);
let delta = y as i32 - cur as i32;
debug!(
"scroll down: target {cur} → {y} (count={count}, step={}, max={}, delta={delta})",
ctx.scroll_step, ctx.max_scroll
);
vec![Effect::ScrollImpulse {
delta_px: delta,
direction: ScrollDirection::Down,
}]
}
Action::ScrollUp(count) => {
let cur = ctx.scroll.derived_target(ctx.max_scroll);
let y = cur.saturating_sub(count * ctx.scroll_step);
let delta = y as i32 - cur as i32;
debug!(
"scroll up: target {cur} → {y} (count={count}, step={}, max={}, delta={delta})",
ctx.scroll_step, ctx.max_scroll
);
vec![Effect::ScrollImpulse {
delta_px: delta,
direction: ScrollDirection::Up,
}]
}
Action::HalfPageDown(count) => {
let cur = ctx.scroll.derived_target(ctx.max_scroll);
let y = (cur + count * ctx.half_page).min(ctx.max_scroll);
let delta = y as i32 - cur as i32;
debug!(
"scroll half-down: target {cur} → {y} (count={count}, half={}, max={}, delta={delta})",
ctx.half_page, ctx.max_scroll
);
vec![Effect::ScrollImpulse {
delta_px: delta,
direction: ScrollDirection::Down,
}]
}
Action::HalfPageUp(count) => {
let cur = ctx.scroll.derived_target(ctx.max_scroll);
let y = cur.saturating_sub(count * ctx.half_page);
let delta = y as i32 - cur as i32;
debug!(
"scroll half-up: target {cur} → {y} (count={count}, half={}, max={}, delta={delta})",
ctx.half_page, ctx.max_scroll
);
vec![Effect::ScrollImpulse {
delta_px: delta,
direction: ScrollDirection::Up,
}]
}
Action::JumpToTop => {
debug!("scroll top: y_offset {} → 0", ctx.scroll.y_offset);
vec![Effect::ScrollAnchor(0)]
}
Action::JumpToBottom => {
debug!(
"scroll bottom: y_offset {} → {} (max={})",
ctx.scroll.y_offset, ctx.max_scroll, ctx.max_scroll
);
vec![Effect::ScrollAnchor(ctx.max_scroll)]
}
Action::JumpToLine(n) => {
let y = visual_line_offset(ctx.doc.visual_lines, ctx.max_scroll, n);
debug!("jump to line {n}: y_offset {} → {}", ctx.scroll.y_offset, y);
vec![Effect::ScrollAnchor(y)]
}
Action::EnterInlineSearch => {
let is = InlineSearchState::new(ctx.scroll.y_offset, SearchDirection::Forward);
vec![Effect::SetMode(ViewerMode::InlineSearch(is))]
}
Action::EnterBackwardSearch => {
let is = InlineSearchState::new(ctx.scroll.y_offset, SearchDirection::Backward);
vec![Effect::SetMode(ViewerMode::InlineSearch(is))]
}
Action::EnterCommand => {
let cs = CommandState {
input: String::new(),
};
vec![Effect::SetMode(ViewerMode::Command(cs))]
}
Action::SearchNextMatch => navigate_search(ctx, KeyDirection::Next),
Action::SearchPrevMatch => navigate_search(ctx, KeyDirection::Prev),
Action::YankExactPrompt => {
vec![
Effect::Flash("Type Ny to yank line N".into()),
Effect::RedrawStatusBar,
]
}
Action::YankExact(n) => yank_and_flash(
ctx,
n,
|doc, idx| doc.yank_exact(idx),
|n, lc| format!("Yanked L{n} ({lc} line{})", if lc > 1 { "s" } else { "" }),
),
Action::YankBlockPrompt => {
vec![
Effect::Flash("Type NY to yank block N".into()),
Effect::RedrawStatusBar,
]
}
Action::YankBlock(n) => yank_and_flash(
ctx,
n,
|doc, idx| doc.yank_lines(idx, idx),
|n, lc| format!("Yanked L{n} block ({lc} lines)"),
),
Action::OpenUrlPrompt => {
vec![
Effect::Flash("Type No to open URL on line N".into()),
Effect::RedrawStatusBar,
]
}
Action::OpenUrl(n) => open_url(ctx, n),
Action::GoBack => vec![Effect::GoBack],
Action::EnterToc => {
let entries = collect_headings(ctx.doc);
if entries.is_empty() {
vec![
Effect::Flash("No headings in document".into()),
Effect::RedrawStatusBar,
]
} else {
vec![
Effect::DeletePlacements,
Effect::SetMode(ViewerMode::Toc(TocState::new(entries))),
]
}
}
Action::EnterUrlPicker => {
let entries = collect_all_url_entries(ctx.doc);
if entries.is_empty() {
vec![
Effect::Flash("No URLs in document".into()),
Effect::RedrawStatusBar,
]
} else {
vec![
Effect::DeletePlacements,
Effect::SetMode(ViewerMode::UrlPicker(UrlPickerState::new(entries))),
]
}
}
}
}
enum KeyDirection {
Next,
Prev,
}
fn navigate_search(ctx: &mut NormalCtx, key_dir: KeyDirection) -> Vec<Effect> {
let Some(ls) = ctx.last_search.as_mut() else {
return vec![
Effect::Flash("No search results".into()),
Effect::RedrawStatusBar,
];
};
let forward = matches!(
(&key_dir, ls.direction),
(KeyDirection::Next, SearchDirection::Forward)
| (KeyDirection::Prev, SearchDirection::Backward)
);
if forward {
ls.advance_next();
} else {
ls.advance_prev();
}
let Some(vl_idx) = ls.current_visual_line_idx() else {
return vec![];
};
let line_num = (vl_idx + 1) as u32;
let y = visual_line_offset(ctx.doc.visual_lines, ctx.max_scroll, line_num);
let flash = format!("match {}/{}", ls.current_idx + 1, ls.matches.len());
vec![
Effect::ShowHighlights,
Effect::InvalidateOverlays,
Effect::ScrollAnchor(y),
Effect::Flash(flash),
]
}
fn yank_and_flash(
ctx: &NormalCtx,
line_num: u32,
extract: impl FnOnce(&DocumentQuery, usize) -> String,
format_msg: impl FnOnce(u32, usize) -> String,
) -> Vec<Effect> {
let vl_idx = (line_num as usize).saturating_sub(1);
if vl_idx >= ctx.doc.visual_lines.len() {
return vec![
Effect::Flash(format!(
"Line {line_num} out of range (max {})",
ctx.doc.visual_lines.len()
)),
Effect::RedrawStatusBar,
];
}
let text = extract(ctx.doc, vl_idx);
if text.is_empty() {
return vec![
Effect::Flash(format!("L{line_num}: no source mapping")),
Effect::RedrawStatusBar,
];
}
let line_count = text.lines().count();
debug!("yank L{line_num}: {} bytes, {line_count} lines", text.len());
vec![
Effect::Yank(text),
Effect::Flash(format_msg(line_num, line_count)),
Effect::RedrawStatusBar,
]
}
fn open_url(ctx: &NormalCtx, line_num: u32) -> Vec<Effect> {
let vl_idx = (line_num as usize).saturating_sub(1);
if vl_idx >= ctx.doc.visual_lines.len() {
return vec![
Effect::Flash(format!(
"Line {line_num} out of range (max {})",
ctx.doc.visual_lines.len()
)),
Effect::RedrawStatusBar,
];
}
if ctx.doc.visual_lines[vl_idx].md_block_range.is_none() {
return vec![
Effect::Flash(format!("L{line_num}: no source mapping")),
Effect::RedrawStatusBar,
];
}
let urls = ctx.doc.extract_urls(vl_idx);
if urls.is_empty() {
return vec![
Effect::Flash(format!("L{line_num}: no URL found")),
Effect::RedrawStatusBar,
];
}
if urls.len() == 1 {
let display = urls[0].target.display_url().to_string();
debug!("open_url L{line_num}: {display}");
let mut effects = open_link_target(&urls[0].target, ctx.current_file);
effects.push(Effect::Flash(format!("Opening {display}")));
effects.push(Effect::RedrawStatusBar);
effects
} else {
debug!("open_url L{line_num}: {} URLs, entering picker", urls.len());
let entries: Vec<UrlPickerEntry> = urls
.into_iter()
.map(|u| UrlPickerEntry {
target: u.target,
text: u.text,
visual_line: line_num as usize,
})
.collect();
vec![
Effect::DeletePlacements,
Effect::SetMode(ViewerMode::UrlPicker(UrlPickerState::new(entries))),
]
}
}
#[cfg(test)]
mod tests {
use super::super::query::test_helpers::empty_ci;
use super::super::scroll_animator::ScrollAnimator;
use super::*;
use crate::frame::VisualLine;
fn make_vl(y_px: u32) -> VisualLine {
VisualLine {
y_pt: 0.0,
y_px,
md_block_range: None,
md_offset: None,
diff_status: None,
}
}
fn make_ctx<'a>(
scroll: &'a ScrollState,
doc: &'a DocumentQuery<'a>,
last_search: &'a mut Option<LastSearch>,
) -> NormalCtx<'a> {
NormalCtx {
scroll,
doc,
max_scroll: 1000,
scroll_step: 30,
half_page: 200,
last_search,
current_file: None,
current_scale: 1.0,
}
}
fn make_state(y_offset: u32) -> ScrollState {
ScrollState::new(
y_offset,
2000,
800,
600,
crate::config::ScrollAnimation::ExpDecay,
)
}
#[test]
fn scroll_accumulates_onto_target_not_render_position() {
let mut state = make_state(0);
state.anchor = 0.0;
let _ = state.input_history.record(ScrollDirection::Down, 100);
state.animator = ScrollAnimator::new_exp_decay(40.0);
state.y_offset = 40;
let vls = vec![make_vl(0)];
let ci = empty_ci();
let doc = DocumentQuery::new("", &vls, &ci, 0);
let mut ls = None;
let mut ctx = make_ctx(&state, &doc, &mut ls);
ctx.scroll_step = 50;
let effects = handle(Action::ScrollDown(1), &mut ctx);
assert!(matches!(
effects[0],
Effect::ScrollImpulse {
delta_px: 50,
direction: ScrollDirection::Down,
}
));
}
#[test]
fn scroll_down_clamps_to_max() {
let state = make_state(990);
let vls = vec![make_vl(0)];
let ci = empty_ci();
let doc = DocumentQuery::new("", &vls, &ci, 0);
let mut ls = None;
let mut ctx = make_ctx(&state, &doc, &mut ls);
ctx.max_scroll = 1000;
let effects = handle(Action::ScrollDown(1), &mut ctx);
assert!(matches!(
effects[0],
Effect::ScrollImpulse {
delta_px: 10,
direction: ScrollDirection::Down,
}
));
}
#[test]
fn scroll_up_clamps_to_zero() {
let state = make_state(10);
let vls = vec![make_vl(0)];
let ci = empty_ci();
let doc = DocumentQuery::new("", &vls, &ci, 0);
let mut ls = None;
let mut ctx = make_ctx(&state, &doc, &mut ls);
let effects = handle(Action::ScrollUp(1), &mut ctx);
assert!(matches!(
effects[0],
Effect::ScrollImpulse {
delta_px: -10,
direction: ScrollDirection::Up,
}
));
}
#[test]
fn half_page_down() {
let state = make_state(0);
let vls = vec![make_vl(0)];
let ci = empty_ci();
let doc = DocumentQuery::new("", &vls, &ci, 0);
let mut ls = None;
let mut ctx = make_ctx(&state, &doc, &mut ls);
let effects = handle(Action::HalfPageDown(1), &mut ctx);
assert!(matches!(
effects[0],
Effect::ScrollImpulse {
delta_px: 200,
direction: ScrollDirection::Down,
}
));
}
#[test]
fn half_page_up() {
let state = make_state(500);
let vls = vec![make_vl(0)];
let ci = empty_ci();
let doc = DocumentQuery::new("", &vls, &ci, 0);
let mut ls = None;
let mut ctx = make_ctx(&state, &doc, &mut ls);
let effects = handle(Action::HalfPageUp(2), &mut ctx);
assert!(matches!(
effects[0],
Effect::ScrollImpulse {
delta_px: -400,
direction: ScrollDirection::Up,
}
));
}
#[test]
fn jump_to_top() {
let state = make_state(500);
let vls = vec![make_vl(0)];
let ci = empty_ci();
let doc = DocumentQuery::new("", &vls, &ci, 0);
let mut ls = None;
let mut ctx = make_ctx(&state, &doc, &mut ls);
let effects = handle(Action::JumpToTop, &mut ctx);
assert!(matches!(effects[0], Effect::ScrollAnchor(0)));
}
#[test]
fn jump_to_bottom() {
let state = make_state(0);
let vls = vec![make_vl(0)];
let ci = empty_ci();
let doc = DocumentQuery::new("", &vls, &ci, 0);
let mut ls = None;
let mut ctx = make_ctx(&state, &doc, &mut ls);
let effects = handle(Action::JumpToBottom, &mut ctx);
assert!(matches!(effects[0], Effect::ScrollAnchor(1000)));
}
#[test]
fn quit_returns_exit() {
let state = make_state(0);
let vls = vec![make_vl(0)];
let ci = empty_ci();
let doc = DocumentQuery::new("", &vls, &ci, 0);
let mut ls = None;
let mut ctx = make_ctx(&state, &doc, &mut ls);
let effects = handle(Action::Quit, &mut ctx);
assert!(matches!(effects[0], Effect::Exit(ExitReason::Quit)));
}
#[test]
fn enter_inline_search_sets_mode_and_saves_scroll() {
let state = make_state(42);
let vls = vec![make_vl(0)];
let ci = empty_ci();
let doc = DocumentQuery::new("", &vls, &ci, 0);
let mut ls = None;
let mut ctx = make_ctx(&state, &doc, &mut ls);
let effects = handle(Action::EnterInlineSearch, &mut ctx);
assert_eq!(effects.len(), 1);
assert!(matches!(
effects[0],
Effect::SetMode(ViewerMode::InlineSearch(_))
));
}
#[test]
fn yank_out_of_range_flashes_error() {
let state = make_state(0);
let vls = vec![make_vl(0)];
let ci = empty_ci();
let doc = DocumentQuery::new("hello", &vls, &ci, 0);
let mut ls = None;
let mut ctx = make_ctx(&state, &doc, &mut ls);
let effects = handle(Action::YankExact(99), &mut ctx);
assert!(matches!(&effects[0], Effect::Flash(msg) if msg.contains("out of range")));
}
#[test]
fn open_url_no_source_mapping_flashes_error() {
let state = make_state(0);
let vls = vec![make_vl(0)]; let ci = empty_ci();
let doc = DocumentQuery::new("hello", &vls, &ci, 0);
let mut ls = None;
let mut ctx = make_ctx(&state, &doc, &mut ls);
let effects = handle(Action::OpenUrl(1), &mut ctx);
assert!(matches!(&effects[0], Effect::Flash(msg) if msg.contains("no source mapping")));
}
#[test]
fn search_next_without_results_flashes() {
let state = make_state(0);
let vls = vec![make_vl(0)];
let ci = empty_ci();
let doc = DocumentQuery::new("", &vls, &ci, 0);
let mut ls = None;
let mut ctx = make_ctx(&state, &doc, &mut ls);
let effects = handle(Action::SearchNextMatch, &mut ctx);
assert!(matches!(&effects[0], Effect::Flash(msg) if msg.contains("No search results")));
}
#[test]
fn go_back_returns_effect() {
let state = make_state(0);
let vls = vec![make_vl(0)];
let ci = empty_ci();
let doc = DocumentQuery::new("", &vls, &ci, 0);
let mut ls = None;
let mut ctx = make_ctx(&state, &doc, &mut ls);
let effects = handle(Action::GoBack, &mut ctx);
assert!(matches!(effects[0], Effect::GoBack));
}
#[test]
fn next_zoom_preset_exact_up() {
assert_eq!(next_zoom_preset(1.0, 1), 1.10);
}
#[test]
fn next_zoom_preset_exact_down() {
assert_eq!(next_zoom_preset(1.0, -1), 0.85);
}
#[test]
fn next_zoom_preset_top_edge_clamp() {
assert_eq!(next_zoom_preset(2.0, 1), 2.0);
}
#[test]
fn next_zoom_preset_bottom_edge_clamp() {
assert_eq!(next_zoom_preset(0.5, -1), 0.5);
}
#[test]
fn next_zoom_preset_off_preset_snap_up() {
assert_eq!(next_zoom_preset(0.7, 1), 0.85);
}
#[test]
fn next_zoom_preset_off_preset_snap_down() {
assert_eq!(next_zoom_preset(0.7, -1), 0.67);
}
#[test]
fn zoom_effects_no_change_flashes() {
let effects = zoom_effects(1.0, 1.0);
assert_eq!(effects.len(), 2);
assert!(matches!(
&effects[0],
Effect::Flash(msg) if msg.contains("no change") && msg.contains("100%")
));
assert!(matches!(&effects[1], Effect::RedrawStatusBar));
}
#[test]
fn zoom_effects_at_max_flashes_max() {
let effects = zoom_effects(2.0, 2.0);
assert_eq!(effects.len(), 2);
assert!(matches!(
&effects[0],
Effect::Flash(msg) if msg.contains("max") && msg.contains("200%")
));
assert!(matches!(&effects[1], Effect::RedrawStatusBar));
}
#[test]
fn zoom_effects_at_min_flashes_min() {
let effects = zoom_effects(0.5, 0.5);
assert_eq!(effects.len(), 2);
assert!(matches!(
&effects[0],
Effect::Flash(msg) if msg.contains("min") && msg.contains("50%")
));
assert!(matches!(&effects[1], Effect::RedrawStatusBar));
}
#[test]
fn zoom_effects_change_emits_exit_with_flash() {
let effects = zoom_effects(1.0, 1.25);
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::Exit(ExitReason::SetScale { old, new, flash })
if (*old - 1.0).abs() < 1e-9
&& (*new - 1.25).abs() < 1e-9
&& flash.as_deref() == Some("zoom: 125%")
));
}
}