use crossterm::terminal as crossterm_terminal;
use log::debug;
use std::path::{Path, PathBuf};
use crate::config::{self, CliOverrides, Config};
use crate::input::InputSource;
use crate::tile::VisualLine;
use crate::watch::FileWatcher;
use super::mode_command::CommandState;
use super::mode_search::{self, LastSearch, SearchState};
use super::mode_toc::{self, TocState};
use super::mode_url::{self, UrlPickerState};
use super::state::{self, ExitReason, Layout, LoadedTiles, ViewState};
use super::terminal;
pub(super) enum ViewerMode {
Normal,
Search(SearchState),
Command(CommandState),
UrlPicker(UrlPickerState),
Toc(TocState),
}
pub(super) enum Effect {
ScrollTo(u32),
MarkDirty,
Flash(String),
RedrawStatusBar,
RedrawSearch,
RedrawCommandBar,
RedrawUrlPicker,
RedrawToc,
Yank(String),
OpenUrl(String),
SetMode(ViewerMode),
SetLastSearch(LastSearch),
DeletePlacements,
EnterUrlPickerAll,
GoBack,
Exit(ExitReason),
}
pub(super) struct JumpEntry {
pub path: PathBuf,
pub y_offset: u32,
}
pub(super) struct Viewport {
pub mode: ViewerMode,
pub view: ViewState,
pub tiles: LoadedTiles,
pub flash: Option<String>,
pub dirty: bool,
pub last_search: Option<LastSearch>,
}
pub(super) struct ViewContext<'a> {
pub layout: &'a Layout,
pub acc_value: Option<u32>,
pub input: &'a InputSource,
pub jump_stack: &'a [JumpEntry],
pub markdown: &'a str,
pub visual_lines: &'a [VisualLine],
}
impl Viewport {
pub(super) fn apply(
&mut self,
effect: Effect,
ctx: &ViewContext,
) -> anyhow::Result<Option<ExitReason>> {
match effect {
Effect::ScrollTo(y) => {
let cell_h = ctx.layout.cell_h as u32;
let snapped = (y / cell_h) * cell_h;
if snapped != y {
debug!("scroll snap: {y} -> {snapped} (cell_h={cell_h})");
}
self.view.y_offset = snapped;
self.dirty = true;
}
Effect::MarkDirty => {
self.dirty = true;
}
Effect::Flash(msg) => {
self.flash = Some(msg);
}
Effect::RedrawStatusBar => {
terminal::draw_status_bar(
ctx.layout,
&self.view,
ctx.acc_value,
self.flash.as_deref(),
)?;
}
Effect::RedrawSearch => {
if let ViewerMode::Search(ss) = &self.mode {
mode_search::draw_search_screen(
ctx.layout,
&ss.query,
&ss.matches,
ss.selected,
ss.scroll_offset,
ss.pattern_valid,
)?;
}
}
Effect::RedrawCommandBar => {
if let ViewerMode::Command(cs) = &self.mode {
terminal::draw_command_bar(ctx.layout, &cs.input)?;
}
}
Effect::RedrawUrlPicker => {
if let ViewerMode::UrlPicker(up) = &self.mode {
mode_url::draw_url_screen(ctx.layout, up)?;
}
}
Effect::RedrawToc => {
if let ViewerMode::Toc(ts) = &self.mode {
mode_toc::draw_toc_screen(ctx.layout, ts)?;
}
}
Effect::Yank(text) => {
let _ = terminal::send_osc52(&text);
}
Effect::OpenUrl(url) => {
if let InputSource::File(cur) = ctx.input
&& is_local_markdown_link(&url)
&& let Some(path) = resolve_link_path(&url, cur)
{
return Ok(Some(ExitReason::Navigate { path }));
}
let _ = open::that_in_background(&url);
}
Effect::SetMode(m) => {
match &m {
ViewerMode::Search(ss) => {
mode_search::draw_search_screen(
ctx.layout,
&ss.query,
&ss.matches,
ss.selected,
ss.scroll_offset,
ss.pattern_valid,
)?;
}
ViewerMode::Command(cs) => {
terminal::draw_command_bar(ctx.layout, &cs.input)?;
}
ViewerMode::UrlPicker(up) => {
mode_url::draw_url_screen(ctx.layout, up)?;
}
ViewerMode::Toc(ts) => {
mode_toc::draw_toc_screen(ctx.layout, ts)?;
}
ViewerMode::Normal => {
terminal::clear_screen()?;
terminal::delete_all_images()?;
self.tiles.map.clear();
self.dirty = true;
}
}
self.mode = m;
}
Effect::SetLastSearch(ls) => {
self.last_search = Some(ls);
}
Effect::DeletePlacements => {
self.tiles.delete_placements()?;
}
Effect::EnterUrlPickerAll => {
let entries = mode_url::collect_all_url_entries(ctx.markdown, ctx.visual_lines);
if entries.is_empty() {
self.flash = Some("No URLs in document".into());
if !matches!(self.mode, ViewerMode::Normal) {
terminal::clear_screen()?;
terminal::delete_all_images()?;
self.tiles.map.clear();
self.mode = ViewerMode::Normal;
self.dirty = true;
} else {
terminal::draw_status_bar(
ctx.layout,
&self.view,
ctx.acc_value,
self.flash.as_deref(),
)?;
}
} else {
self.tiles.delete_placements()?;
terminal::clear_screen()?;
let up = UrlPickerState::new(entries);
mode_url::draw_url_screen(ctx.layout, &up)?;
self.mode = ViewerMode::UrlPicker(up);
}
}
Effect::GoBack => {
if ctx.jump_stack.is_empty() {
self.flash = Some("No previous file".into());
terminal::draw_status_bar(
ctx.layout,
&self.view,
ctx.acc_value,
self.flash.as_deref(),
)?;
} else {
return Ok(Some(ExitReason::GoBack));
}
}
Effect::Exit(reason) => {
return Ok(Some(reason));
}
}
Ok(None)
}
}
pub(super) struct Session {
pub layout: Layout,
pub config: Config,
pub cli_overrides: CliOverrides,
pub input: InputSource,
pub filename: String,
pub watcher: Option<FileWatcher>,
pub jump_stack: Vec<JumpEntry>,
pub scroll_carry: u32,
pub pending_flash: Option<String>,
pub watch: bool,
}
impl Session {
pub(super) fn update_layout_for_resize(
&mut self,
new_cols: u16,
new_rows: u16,
) -> anyhow::Result<()> {
let new_winsize = crossterm_terminal::window_size()?;
self.layout = state::compute_layout(
new_cols,
new_rows,
new_winsize.width,
new_winsize.height,
self.config.viewer.sidebar_cols,
);
terminal::delete_all_images()?;
Ok(())
}
pub(super) fn handle_exit(
&mut self,
exit: ExitReason,
scroll_position: u32,
) -> anyhow::Result<bool> {
match exit {
ExitReason::Quit => return Ok(true),
ExitReason::Resize { new_cols, new_rows } => {
self.scroll_carry = scroll_position;
debug!("resize: rebuilding tiled document and sidebar");
self.update_layout_for_resize(new_cols, new_rows)?;
}
ExitReason::Reload => {
self.scroll_carry = scroll_position;
debug!("file changed: reloading document");
terminal::delete_all_images()?;
}
ExitReason::ConfigReload => {
self.scroll_carry = scroll_position;
debug!("config reload requested");
match config::reload_config(&self.cli_overrides) {
Ok(new_config) => {
if crate::theme::get(&new_config.theme).is_none() {
self.pending_flash = Some(format!(
"Reload failed: unknown theme '{}'",
new_config.theme
));
debug!(
"config reload: unknown theme '{}', keeping old config",
new_config.theme
);
terminal::delete_all_images()?;
return Ok(false);
}
if new_config.viewer.sidebar_cols != self.config.viewer.sidebar_cols {
let winsize = crossterm_terminal::window_size()?;
self.layout = state::compute_layout(
winsize.columns,
winsize.rows,
winsize.width,
winsize.height,
new_config.viewer.sidebar_cols,
);
}
self.config = new_config;
self.pending_flash = Some("Config reloaded".into());
}
Err(e) => {
self.pending_flash = Some(format!("Reload failed: {e}"));
debug!("config reload failed: {e}");
}
}
terminal::delete_all_images()?;
}
ExitReason::Navigate { path } => {
if !path.exists() {
self.pending_flash = Some(format!("File not found: {}", path.display()));
terminal::delete_all_images()?;
return Ok(false);
}
if let InputSource::File(cur) = &self.input {
self.jump_stack.push(JumpEntry {
path: cur.clone(),
y_offset: scroll_position,
});
}
let canonical = std::fs::canonicalize(&path).unwrap_or(path);
debug!("navigate: jumping to {}", canonical.display());
self.input = InputSource::File(canonical.clone());
self.filename = self.input.display_name().to_string();
self.scroll_carry = 0;
if self.watch {
self.watcher = Some(FileWatcher::new(&canonical)?);
}
terminal::delete_all_images()?;
}
ExitReason::GoBack => {
let entry = self.jump_stack.pop().expect("GoBack with empty stack");
debug!("go back: returning to {}", entry.path.display());
self.input = InputSource::File(entry.path.clone());
self.filename = self.input.display_name().to_string();
self.scroll_carry = entry.y_offset;
if self.watch {
self.watcher = Some(FileWatcher::new(&entry.path)?);
}
terminal::delete_all_images()?;
}
}
Ok(false)
}
}
fn is_local_markdown_link(url: &str) -> bool {
if url.contains("://") || url.starts_with("mailto:") {
return false;
}
let path_part = url.split('#').next().unwrap_or(url);
path_part.ends_with(".md") || path_part.ends_with(".markdown")
}
fn resolve_link_path(url: &str, current_file: &Path) -> Option<PathBuf> {
let path_part = url.split('#').next().unwrap_or(url);
if path_part.is_empty() {
return None;
}
let base_dir = current_file.parent()?;
Some(base_dir.join(path_part))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_local_markdown_link() {
assert!(is_local_markdown_link("./other.md"));
assert!(is_local_markdown_link("other.md"));
assert!(is_local_markdown_link("../docs/guide.md"));
assert!(is_local_markdown_link("file.md#section"));
assert!(is_local_markdown_link("notes.markdown"));
assert!(!is_local_markdown_link("https://example.com"));
assert!(!is_local_markdown_link("http://example.com/page.md"));
assert!(!is_local_markdown_link("mailto:user@example.com"));
assert!(!is_local_markdown_link("data.csv"));
assert!(!is_local_markdown_link("image.png"));
assert!(!is_local_markdown_link(""));
}
#[test]
fn test_resolve_link_path() {
let current = Path::new("/home/user/docs/readme.md");
assert_eq!(
resolve_link_path("other.md", current),
Some(PathBuf::from("/home/user/docs/other.md"))
);
assert_eq!(
resolve_link_path("../guide.md", current),
Some(PathBuf::from("/home/user/docs/../guide.md"))
);
assert_eq!(
resolve_link_path("sub/page.md#heading", current),
Some(PathBuf::from("/home/user/docs/sub/page.md"))
);
assert_eq!(resolve_link_path("#heading", current), None);
assert_eq!(resolve_link_path("", current), None);
}
}