use crossterm::{
QueueableCommand, cursor,
style::{self, Stylize},
terminal::{Clear, ClearType},
};
use std::io::{self, Write, stdout};
use crate::url::LinkTarget;
use super::keymap::UrlAction;
use super::layout::Layout;
use super::mode_normal::open_link_target;
use super::query::{DocumentQuery, UrlEntry, extract_urls_from_lines};
use super::{Effect, ViewerMode};
pub(super) struct UrlPickerEntry {
pub target: LinkTarget,
pub text: String,
pub visual_line: usize,
}
pub(super) struct UrlPickerState {
pub entries: Vec<UrlPickerEntry>,
pub selected: usize,
pub scroll_offset: usize,
}
impl UrlPickerState {
pub(super) fn new(entries: Vec<UrlPickerEntry>) -> Self {
Self {
entries,
selected: 0,
scroll_offset: 0,
}
}
}
pub(super) fn collect_all_url_entries(doc: &DocumentQuery) -> Vec<UrlPickerEntry> {
let mut entries = Vec::new();
let mut seen_ranges: Vec<(usize, usize)> = Vec::new();
for (vl_idx, vl) in doc.visual_lines.iter().enumerate() {
let Some(ref r) = vl.md_block_range else {
continue;
};
if seen_ranges.contains(&(r.start, r.end)) {
continue;
}
seen_ranges.push((r.start, r.end));
let start = doc.byte_offset_to_line(r.start);
let end = doc.byte_offset_to_line(r.end.saturating_sub(1).max(r.start));
let url_entries = extract_urls_from_lines(doc.markdown, start, end);
let line_num = vl_idx + 1; for UrlEntry { target, text } in url_entries {
entries.push(UrlPickerEntry {
target,
text,
visual_line: line_num,
});
}
}
entries
}
pub(super) fn draw_url_screen(layout: &Layout, state: &UrlPickerState) -> io::Result<()> {
let mut out = stdout();
out.queue(Clear(ClearType::All))?;
let total_cols = (layout.sidebar_cols + layout.image_cols) as usize;
out.queue(cursor::MoveTo(0, 0))?;
let header = " URLs:";
write!(out, "{}", header.white().bold())?;
let list_start_row: u16 = 1;
let list_end_row = layout.status_row; let visible_count = (list_end_row - list_start_row) as usize;
for i in 0..visible_count {
let entry_idx = state.scroll_offset + i;
let row = list_start_row + i as u16;
out.queue(cursor::MoveTo(0, row))?;
if entry_idx >= state.entries.len() {
write!(out, "{:width$}", "", width = total_cols)?;
continue;
}
let e = &state.entries[entry_idx];
let is_selected = entry_idx == state.selected;
let marker = if is_selected { " > " } else { " " };
let line_label = format!("L{:<4}", e.visual_line);
let url_display = e.target.display_url();
let content = if e.text.is_empty() {
format!("{marker}{line_label} {url_display}")
} else {
format!("{marker}{line_label} [{}] {url_display}", e.text)
};
let display: String = content.chars().take(total_cols).collect();
let pad = total_cols.saturating_sub(display.len());
if is_selected {
write!(
out,
"{}",
format!("{display}{:pad$}", "").on_dark_blue().white()
)?;
} else {
write!(out, "{display}{:pad$}", "")?;
}
}
out.queue(cursor::MoveTo(0, layout.status_row))?;
let status = format!(
" {} URL{} | Enter:open j/k:select Esc:cancel",
state.entries.len(),
if state.entries.len() == 1 { "" } else { "s" }
);
let padded = format!("{:<width$}", status, width = total_cols);
write!(out, "{}", padded.on_dark_grey().white())?;
out.queue(style::ResetColor)?;
out.flush()
}
pub(super) fn handle(
action: UrlAction,
state: &mut UrlPickerState,
visible_count: usize,
current_file: Option<&std::path::Path>,
) -> Vec<Effect> {
match action {
UrlAction::SelectNext => {
if !state.entries.is_empty() {
state.selected = (state.selected + 1).min(state.entries.len() - 1);
if state.selected >= state.scroll_offset + visible_count {
state.scroll_offset = state.selected - visible_count + 1;
}
}
vec![Effect::RedrawUrlPicker]
}
UrlAction::SelectPrev => {
if !state.entries.is_empty() {
state.selected = state.selected.saturating_sub(1);
if state.selected < state.scroll_offset {
state.scroll_offset = state.selected;
}
}
vec![Effect::RedrawUrlPicker]
}
UrlAction::Confirm => {
if state.entries.is_empty() {
return vec![Effect::SetMode(ViewerMode::Normal), Effect::MarkDirty];
}
let entry = &state.entries[state.selected];
let display = entry.target.display_url().to_string();
let mut effects = open_link_target(&entry.target, current_file);
effects.push(Effect::Flash(format!("Opening {display}")));
effects.push(Effect::SetMode(ViewerMode::Normal));
effects
}
UrlAction::Cancel => vec![Effect::SetMode(ViewerMode::Normal), Effect::MarkDirty],
}
}
#[cfg(test)]
mod tests {
use super::super::query::test_helpers::*;
use super::*;
#[test]
fn test_collect_all_url_entries_basic() {
let md =
"See [Rust](https://rust.invalid/) here.\nPlain line.\n[Docs](https://docs.invalid/)\n";
let vls = vec![
make_vl(md, Some((1, 1))),
make_vl(md, Some((2, 2))),
make_vl(md, Some((3, 3))),
];
let ci = empty_ci();
let doc = DocumentQuery::new(md, &vls, &ci, 0);
let entries = collect_all_url_entries(&doc);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].target.display_url(), "https://rust.invalid/");
assert_eq!(entries[0].text, "Rust");
assert_eq!(entries[0].visual_line, 1);
assert_eq!(entries[1].target.display_url(), "https://docs.invalid/");
assert_eq!(entries[1].visual_line, 3);
}
#[test]
fn test_collect_all_url_entries_empty() {
let md = "No links here.\n";
let vls = vec![make_vl(md, Some((1, 1)))];
let ci = empty_ci();
let doc = DocumentQuery::new(md, &vls, &ci, 0);
let entries = collect_all_url_entries(&doc);
assert!(entries.is_empty());
}
#[test]
fn test_collect_deduplicates_same_range() {
let md = "See [A](https://a.invalid/) text.\n";
let vls = vec![make_vl(md, Some((1, 1))), make_vl(md, Some((1, 1)))];
let ci = empty_ci();
let doc = DocumentQuery::new(md, &vls, &ci, 0);
let entries = collect_all_url_entries(&doc);
assert_eq!(entries.len(), 1);
}
#[test]
fn test_handle_select_next() {
let entries = vec![
UrlPickerEntry {
target: LinkTarget::ExternalUrl("https://a.invalid/".into()),
text: "A".into(),
visual_line: 1,
},
UrlPickerEntry {
target: LinkTarget::ExternalUrl("https://b.invalid/".into()),
text: "B".into(),
visual_line: 2,
},
];
let mut state = UrlPickerState::new(entries);
assert_eq!(state.selected, 0);
let _ = handle(UrlAction::SelectNext, &mut state, 20, None);
assert_eq!(state.selected, 1);
let _ = handle(UrlAction::SelectNext, &mut state, 20, None);
assert_eq!(state.selected, 1);
}
#[test]
fn test_handle_select_prev() {
let entries = vec![
UrlPickerEntry {
target: LinkTarget::ExternalUrl("https://a.invalid/".into()),
text: "A".into(),
visual_line: 1,
},
UrlPickerEntry {
target: LinkTarget::ExternalUrl("https://b.invalid/".into()),
text: "B".into(),
visual_line: 2,
},
];
let mut state = UrlPickerState::new(entries);
state.selected = 1;
let _ = handle(UrlAction::SelectPrev, &mut state, 20, None);
assert_eq!(state.selected, 0);
let _ = handle(UrlAction::SelectPrev, &mut state, 20, None);
assert_eq!(state.selected, 0);
}
#[test]
fn test_handle_confirm_opens_selected() {
let entries = vec![
UrlPickerEntry {
target: LinkTarget::ExternalUrl("https://a.invalid/".into()),
text: "A".into(),
visual_line: 1,
},
UrlPickerEntry {
target: LinkTarget::ExternalUrl("https://b.invalid/".into()),
text: "B".into(),
visual_line: 2,
},
];
let mut state = UrlPickerState::new(entries);
state.selected = 1;
let effects = handle(UrlAction::Confirm, &mut state, 20, None);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::OpenExternalUrl(u) if u == "https://b.invalid/"))
);
}
#[test]
fn test_handle_cancel_returns_normal() {
let entries = vec![UrlPickerEntry {
target: LinkTarget::ExternalUrl("https://a.invalid/".into()),
text: "A".into(),
visual_line: 1,
}];
let mut state = UrlPickerState::new(entries);
let effects = handle(UrlAction::Cancel, &mut state, 20, None);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::SetMode(ViewerMode::Normal)))
);
}
}