lesser 0.1.0

A lesser pager (even less than less), for everyday use
use std::io::{self, Write};

use crossterm::{
    event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
    execute, terminal,
};
use regex::Regex;

use crate::buffer::LineBuffer;
use crate::flags::Flags;
use crate::layout::Layout;
use crate::render::{self, Viewport};
use crate::search::Search;

pub fn dump(buf: &LineBuffer) -> anyhow::Result<()> {
    let stdout = io::stdout();
    let mut out = stdout.lock();
    let n = buf.lines().len();
    for (i, line) in buf.lines().iter().enumerate() {
        out.write_all(line.as_bytes())?;
        if i + 1 < n || buf.final_newline() {
            out.write_all(b"\n")?;
        }
    }
    Ok(())
}

pub fn run(flags: Flags, buf: LineBuffer) -> anyhow::Result<()> {
    let _guard = TerminalGuard::enter(!flags.no_init)?;

    let (cols, rows) = terminal::size()?;
    let mut vp = Viewport { top: 0, rows, cols };

    let mut chop = flags.chop_long_lines;
    let mut layout = Layout::build(&buf, cols, chop);

    let mut search: Option<Search> = None;
    let mut search_input: Option<String> = None;
    let mut preview: Option<Regex> = None;
    let mut info: Option<String> = None;

    loop {
        preview = match search_input.as_deref() {
            None | Some("") => None,
            Some(input) => Regex::new(input).ok().or(preview),
        };

        let status = compute_status(&vp, &layout, search_input.as_deref(), info.as_deref());
        let highlight: Option<&Regex> = match preview.as_ref() {
            Some(re) => Some(re),
            None => search.as_ref().map(|s| &s.pattern),
        };
        render::draw(&buf, &layout, &vp, &flags, &status, highlight)?;

        let event = event::read()?;
        info = None;

        match event {
            Event::Key(key) => {
                let in_search = search_input.is_some();
                if in_search {
                    let action = handle_search_key(key, search_input.as_mut().unwrap());
                    match action {
                        SearchAction::Continue => {}
                        SearchAction::Exit => search_input = None,
                        SearchAction::Commit(pattern) => {
                            search_input = None;
                            commit_search(&pattern, &mut search, &mut vp, &buf, &layout, &mut info);
                        }
                    }
                } else {
                    match handle_normal_key(key, &mut vp, &buf, &layout, &mut search, &mut info) {
                        NormalAction::Quit => break,
                        NormalAction::EnterSearch => search_input = Some(String::new()),
                        NormalAction::ToggleChop => {
                            chop = !chop;
                            rebuild_layout(&mut layout, &buf, &mut vp, chop);
                        }
                        NormalAction::Continue => {}
                    }
                }
            }
            Event::Resize(w, h) => {
                vp.cols = w;
                vp.rows = h;
                rebuild_layout(&mut layout, &buf, &mut vp, chop);
            }
            _ => {}
        }
    }

    Ok(())
}

#[derive(PartialEq, Eq)]
enum NormalAction {
    Continue,
    EnterSearch,
    ToggleChop,
    Quit,
}

enum SearchAction {
    Continue,
    Exit,
    Commit(String),
}

fn compute_status(
    vp: &Viewport,
    layout: &Layout,
    search_input: Option<&str>,
    info: Option<&str>,
) -> String {
    if let Some(msg) = info {
        return msg.to_string();
    }
    if let Some(input) = search_input {
        return format!("/{}", input);
    }
    let at_end = vp.top + vp.content_rows() >= layout.len();
    if at_end { "(END)".into() } else { ":".into() }
}

fn handle_normal_key(
    key: KeyEvent,
    vp: &mut Viewport,
    buf: &LineBuffer,
    layout: &Layout,
    search: &mut Option<Search>,
    info: &mut Option<String>,
) -> NormalAction {
    let content_rows = vp.content_rows();
    let max_top = layout.len().saturating_sub(content_rows);

    match key.code {
        KeyCode::Char('q') => return NormalAction::Quit,
        KeyCode::Char('/') => return NormalAction::EnterSearch,
        KeyCode::Char('S') => return NormalAction::ToggleChop,
        KeyCode::Char('n') => match search.as_mut() {
            Some(s) => match s.find_next(buf.lines()) {
                Some(line) => set_top_to_line(vp, layout, line, max_top),
                None => *info = Some("Pattern not found".into()),
            },
            None => *info = Some("No previous search".into()),
        },
        KeyCode::Char('j') | KeyCode::Down | KeyCode::Enter => {
            vp.top = (vp.top + 1).min(max_top);
        }
        KeyCode::Char('k') | KeyCode::Up => {
            vp.top = vp.top.saturating_sub(1);
        }
        KeyCode::Char(' ') | KeyCode::PageDown => {
            vp.top = (vp.top + content_rows).min(max_top);
        }
        KeyCode::Char('b') | KeyCode::PageUp => {
            vp.top = vp.top.saturating_sub(content_rows);
        }
        KeyCode::Char('g') | KeyCode::Home => vp.top = 0,
        KeyCode::Char('G') | KeyCode::End => vp.top = max_top,
        _ => {}
    }
    NormalAction::Continue
}

fn handle_search_key(key: KeyEvent, input: &mut String) -> SearchAction {
    let is_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
    match key.code {
        KeyCode::Esc => SearchAction::Exit,
        KeyCode::Char('c') if is_ctrl => SearchAction::Exit,
        KeyCode::Enter => SearchAction::Commit(std::mem::take(input)),
        KeyCode::Backspace => {
            if input.pop().is_none() {
                SearchAction::Exit
            } else {
                SearchAction::Continue
            }
        }
        KeyCode::Char(c) if !is_ctrl => {
            input.push(c);
            SearchAction::Continue
        }
        _ => SearchAction::Continue,
    }
}

fn commit_search(
    pattern: &str,
    search: &mut Option<Search>,
    vp: &mut Viewport,
    buf: &LineBuffer,
    layout: &Layout,
    info: &mut Option<String>,
) {
    if pattern.is_empty() {
        return;
    }
    match Search::new(pattern) {
        Ok(mut s) => {
            let max_top = layout.len().saturating_sub(vp.content_rows());
            // Map current visual top → logical line, then start search from there.
            let from_line = layout.segment(vp.top).map(|s| s.line_idx).unwrap_or(0);
            match s.find_forward(buf.lines(), from_line) {
                Some(line) => set_top_to_line(vp, layout, line, max_top),
                None => *info = Some("Pattern not found".into()),
            }
            *search = Some(s);
        }
        Err(e) => *info = Some(format!("Invalid regex: {}", e)),
    }
}

fn set_top_to_line(vp: &mut Viewport, layout: &Layout, line: usize, max_top: usize) {
    if let Some(seg) = layout.first_segment_of(line) {
        vp.top = seg.min(max_top);
    }
}

fn rebuild_layout(layout: &mut Layout, buf: &LineBuffer, vp: &mut Viewport, chop: bool) {
    let anchor_line = layout.segment(vp.top).map(|s| s.line_idx);
    layout.rebuild(buf, vp.cols, chop);
    let max_top = layout.len().saturating_sub(vp.content_rows());
    if let Some(line) = anchor_line {
        if let Some(seg_idx) = layout.first_segment_of(line) {
            vp.top = seg_idx.min(max_top);
            return;
        }
    }
    if vp.top > max_top {
        vp.top = max_top;
    }
}

struct TerminalGuard {
    alt_screen: bool,
}

impl TerminalGuard {
    fn enter(alt_screen: bool) -> io::Result<Self> {
        terminal::enable_raw_mode()?;
        if alt_screen {
            execute!(io::stdout(), terminal::EnterAlternateScreen)?;
        }
        Ok(Self { alt_screen })
    }
}

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        if self.alt_screen {
            let _ = execute!(io::stdout(), terminal::LeaveAlternateScreen);
        }
        let _ = terminal::disable_raw_mode();
    }
}