use crossterm::{
QueueableCommand, cursor,
style::{self, Stylize},
terminal::{Clear, ClearType},
};
use std::io::{self, Write, stdout};
use super::input::UrlAction;
use super::state::Layout;
use super::{Effect, ViewerMode};
use crate::tile::{UrlEntry, VisualLine, extract_urls_from_lines};
pub(super) struct UrlPickerEntry {
pub url: String,
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(
md_source: &str,
visual_lines: &[VisualLine],
) -> Vec<UrlPickerEntry> {
let mut entries = Vec::new();
let mut seen_ranges: Vec<(usize, usize)> = Vec::new();
for (vl_idx, vl) in visual_lines.iter().enumerate() {
let Some((start, end)) = vl.md_line_range else {
continue;
};
if seen_ranges.contains(&(start, end)) {
continue;
}
seen_ranges.push((start, end));
let url_entries = extract_urls_from_lines(md_source, start, end);
let line_num = vl_idx + 1; for UrlEntry { url, text } in url_entries {
entries.push(UrlPickerEntry {
url,
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 content = if e.text.is_empty() {
format!("{marker}{line_label} {}", e.url)
} else {
format!("{marker}{line_label} [{}] {}", e.text, e.url)
};
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,
) -> 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 url = state.entries[state.selected].url.clone();
vec![
Effect::OpenUrl(url.clone()),
Effect::Flash(format!("Opening {url}")),
Effect::SetMode(ViewerMode::Normal),
]
}
UrlAction::Cancel => vec![Effect::SetMode(ViewerMode::Normal), Effect::MarkDirty],
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tile::VisualLine;
fn make_vl(md_line_range: Option<(usize, usize)>) -> VisualLine {
VisualLine {
y_pt: 0.0,
y_px: 0,
md_line_range,
md_line_exact: None,
}
}
#[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(Some((1, 1))),
make_vl(Some((2, 2))),
make_vl(Some((3, 3))),
];
let entries = collect_all_url_entries(md, &vls);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].url, "https://rust.invalid/");
assert_eq!(entries[0].text, "Rust");
assert_eq!(entries[0].visual_line, 1);
assert_eq!(entries[1].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(Some((1, 1)))];
let entries = collect_all_url_entries(md, &vls);
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(Some((1, 1))), make_vl(Some((1, 1)))];
let entries = collect_all_url_entries(md, &vls);
assert_eq!(entries.len(), 1);
}
#[test]
fn test_handle_select_next() {
let entries = vec![
UrlPickerEntry {
url: "https://a.invalid/".into(),
text: "A".into(),
visual_line: 1,
},
UrlPickerEntry {
url: "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);
assert_eq!(state.selected, 1);
let _ = handle(UrlAction::SelectNext, &mut state, 20);
assert_eq!(state.selected, 1);
}
#[test]
fn test_handle_select_prev() {
let entries = vec![
UrlPickerEntry {
url: "https://a.invalid/".into(),
text: "A".into(),
visual_line: 1,
},
UrlPickerEntry {
url: "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);
assert_eq!(state.selected, 0);
let _ = handle(UrlAction::SelectPrev, &mut state, 20);
assert_eq!(state.selected, 0);
}
#[test]
fn test_handle_confirm_opens_selected() {
let entries = vec![
UrlPickerEntry {
url: "https://a.invalid/".into(),
text: "A".into(),
visual_line: 1,
},
UrlPickerEntry {
url: "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);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::OpenUrl(u) if u == "https://b.invalid/"))
);
}
#[test]
fn test_handle_cancel_returns_normal() {
let entries = vec![UrlPickerEntry {
url: "https://a.invalid/".into(),
text: "A".into(),
visual_line: 1,
}];
let mut state = UrlPickerState::new(entries);
let effects = handle(UrlAction::Cancel, &mut state, 20);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::SetMode(ViewerMode::Normal)))
);
}
}