#![deny(clippy::arithmetic_side_effects)]
use crate::theme::{Bg, DiffBg, Fg, Syntax};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use std::collections::HashMap;
use std::ops::Range;
use crate::ui::{Ctx, cols, fill, idx, put, text};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Link {
Url(String),
More,
Star,
Comment,
Filter,
Sort,
State(String),
Branch,
FindFile,
Section(usize),
Quote {
author: String,
at: String,
body: String,
},
Form(crate::ui::form::Row),
Artifact {
id: u64,
name: String,
},
Asset(String),
Review(ReviewLink),
Setting(crate::ui::pages::SettingLink),
Queued(u64),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ReviewLink {
Fold(String),
Open {
pr: crate::api::model::PrRef,
id: u64,
},
AsReviewed {
pr: crate::api::model::PrRef,
commit: String,
},
Since {
pr: crate::api::model::PrRef,
commit: String,
},
Draft {
pr: crate::api::model::PrRef,
id: u64,
url: String,
},
}
impl Link {
pub fn url(&self) -> Option<&str> {
match self {
Link::Url(url) | Link::Review(ReviewLink::Draft { url, .. }) => Some(url),
_ => None,
}
}
}
impl From<String> for Link {
fn from(url: String) -> Self {
Link::Url(url)
}
}
impl From<&str> for Link {
fn from(url: &str) -> Self {
Link::Url(url.to_owned())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Role {
Body,
Title,
Heading,
Strong,
Emph,
Strike,
Meta,
Link,
Code,
Syntax(Syntax),
Success,
Error,
Accent,
Added,
Removed,
Chip(Bg),
Label(String),
Heat(u8),
Match,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Seg {
text: String,
pub role: Role,
pub link: Option<u32>,
}
impl Seg {
pub fn new(text: impl Into<String>, role: Role) -> Self {
Self::linked(text, role, None)
}
pub fn linked(text: impl Into<String>, role: Role, link: impl Into<Option<u32>>) -> Self {
Self {
text: text::expand_tabs(text),
role,
link: link.into(),
}
}
pub fn text(&self) -> &str {
&self.text
}
}
pub fn highlight(seg: Seg, query: &str) -> Vec<Seg> {
let q = query.to_ascii_lowercase();
if q.is_empty() {
return vec![seg];
}
let lower = seg.text().to_ascii_lowercase();
let piece = |text: &str, role: &Role| Seg::linked(text, role.clone(), seg.link);
let mut out = Vec::new();
let mut from = 0;
for found in text::hits(&lower, &q) {
let (at, end) = (found.start, found.end);
let (Some(before), Some(hit)) = (seg.text().get(from..at), seg.text().get(at..end)) else {
continue;
};
if !before.is_empty() {
out.push(piece(before, &seg.role));
}
out.push(piece(hit, &Role::Match));
from = end;
}
if out.is_empty() {
return vec![seg];
}
if let Some(rest) = seg.text().get(from..).filter(|r| !r.is_empty()) {
out.push(piece(rest, &seg.role));
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Frame {
#[default]
None,
Top,
Body,
Rule,
Bottom,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Tone {
#[default]
Plain,
Code,
Marked,
Added,
Removed,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PageLine {
pub segs: Vec<Seg>,
pub indent: u16,
pub right: Vec<Seg>,
pub frame: Frame,
pub tone: Tone,
pub fixed: u8,
}
impl PageLine {
pub fn is_blank(&self) -> bool {
self.segs.iter().all(|s| s.text.trim().is_empty()) && self.right.is_empty()
}
pub fn text(&self) -> String {
self.segs.iter().map(|s| s.text.as_str()).collect()
}
pub fn code(&self) -> Option<(String, usize)> {
if self.tone == Tone::Plain {
return None;
}
let fixed = usize::from(self.fixed);
let before = (self.segs.iter().take(fixed))
.fold(0, |w: usize, s| w.saturating_add(text::width(&s.text)));
let code = self.segs.iter().skip(fixed).map(|s| s.text.as_str());
Some((code.collect(), before))
}
pub fn first_match(&self) -> Option<usize> {
let mut col = 0usize;
for seg in self.segs.iter().skip(usize::from(self.fixed)) {
if seg.role == Role::Match {
return Some(col);
}
col = col.saturating_add(text::width(&seg.text));
}
None
}
fn shifted(&self, shift: usize) -> PageLine {
let fixed = usize::from(self.fixed);
let mut skip = shift;
let mut segs: Vec<Seg> = self.segs.iter().take(fixed).cloned().collect();
for seg in self.segs.iter().skip(fixed) {
if skip == 0 {
segs.push(seg.clone());
continue;
}
let mut rest = String::new();
for c in seg.text.chars() {
let w = text::width(c.encode_utf8(&mut [0; 4]));
if skip >= w && rest.is_empty() {
skip = skip.saturating_sub(w);
} else {
skip = 0;
rest.push(c);
}
}
if !rest.is_empty() {
segs.push(Seg::linked(rest, seg.role.clone(), seg.link));
}
}
PageLine {
segs,
..self.clone()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Item {
pub start: usize,
pub end: usize,
pub link: u32,
pub cols: Option<(u16, u16)>,
}
const BOX_PAD: u16 = 2;
pub const ASIDE_GAP: u16 = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Aside {
pub width: u16,
pub lines: Vec<PageLine>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Page {
pub lines: Vec<PageLine>,
pub links: Vec<Link>,
link_ids: HashMap<Link, u32>,
pub width: u16,
pub items: Vec<Item>,
pub aside: Option<Aside>,
pub compact: bool,
pub jump: Option<usize>,
pub selects: Option<usize>,
pub anchors: HashMap<String, usize>,
pub marks: Vec<usize>,
pub numbered: Vec<usize>,
}
impl Page {
pub fn new(width: u16) -> Self {
Self {
width: width.max(20),
..Self::default()
}
}
pub fn with_aside(total: u16) -> Self {
if total < 104 {
return Self::new(total);
}
let grown = total.saturating_sub(140).checked_div(3).unwrap_or(0);
let width = grown.saturating_add(30).min(48);
Self {
aside: Some(Aside {
width,
lines: Vec::new(),
}),
..Self::new(total.saturating_sub(width.saturating_add(ASIDE_GAP)))
}
}
pub fn link(&mut self, link: impl Into<Link>) -> u32 {
let link = link.into();
if let Some(&i) = self.link_ids.get(&link) {
return i;
}
let i = idx(self.links.len());
self.links.push(link.clone());
self.link_ids.insert(link, i);
i
}
pub fn anchor(&mut self, name: impl Into<String>) {
let line = self.lines.len();
self.anchors.entry(name.into()).or_insert(line);
}
pub fn target(&self, i: u32) -> Option<&Link> {
self.links.get(i as usize)
}
pub fn find(&self, link: &Link, near: usize) -> Option<usize> {
let same = |l: &Link| match (l, link) {
(Link::Quote { at, .. }, Link::Quote { at: was, .. }) => at == was,
_ => l == link || *link == Link::More,
};
let items = self.items.iter().enumerate();
let found = items.filter(|(_, it)| self.target(it.link).is_some_and(same));
found.map(|(i, _)| i).min_by_key(|i| i.abs_diff(near))
}
pub fn push(&mut self, line: PageLine) {
self.lines.push(line);
}
pub(crate) fn add(&mut self, frame: Frame, indent: u16, segs: Vec<Seg>, right: Vec<Seg>) {
self.lines.push(PageLine {
segs,
indent,
right,
frame,
tone: Tone::Plain,
fixed: 0,
});
}
pub fn blank(&mut self) {
if self
.lines
.last()
.is_some_and(|l| !(l.is_blank() && l.frame == Frame::None))
{
self.lines.push(PageLine::default());
}
}
pub fn line(&mut self, segs: Vec<Seg>) {
self.add(Frame::None, 0, segs, Vec::new());
}
pub fn link_line(
&mut self,
text: impl Into<String>,
url: impl Into<Link>,
role: Role,
indent: u16,
) -> u32 {
let link = self.link(url);
self.add(
Frame::None,
indent,
vec![Seg::linked(text, role, link)],
Vec::new(),
);
link
}
pub fn room(&self, frame: Frame, indent: u16) -> u16 {
let inner = match frame {
Frame::None => self.width,
_ => self.width.saturating_sub(2 * BOX_PAD),
};
inner.saturating_sub(indent).max(8)
}
pub fn wrapped(&mut self, segs: Vec<Seg>, indent: u16, frame: Frame) {
let width = usize::from(self.room(frame, indent));
for segs in wrap_segs(segs, width) {
self.add(frame, indent, segs, Vec::new());
}
}
pub fn rule(&mut self, indent: u16, frame: Frame) {
let rule = "─".repeat(usize::from(self.room(frame, indent)));
self.add(frame, indent, vec![Seg::new(rule, Role::Meta)], Vec::new());
}
pub fn box_top(&mut self, title: Vec<Seg>, right: Vec<Seg>) {
self.blank_before_box();
self.add(Frame::Top, 0, title, right);
}
pub fn box_rule(&mut self) {
self.add(Frame::Rule, 0, Vec::new(), Vec::new());
}
pub fn box_bottom(&mut self) {
while self
.lines
.last()
.is_some_and(|l| l.frame == Frame::Body && l.is_blank() && l.tone == Tone::Plain)
{
self.lines.pop();
}
self.add(Frame::Bottom, 0, Vec::new(), Vec::new());
}
pub fn box_gap(&mut self) {
if self
.lines
.last()
.is_some_and(|l| !(l.frame == Frame::Body && l.is_blank()) && l.frame != Frame::Top)
{
self.add(Frame::Body, 0, Vec::new(), Vec::new());
}
}
pub fn box_line(&mut self, segs: Vec<Seg>, right: Vec<Seg>, indent: u16) {
self.add(Frame::Body, indent, segs, right);
}
fn blank_before_box(&mut self) {
if self.lines.last().is_some_and(|l| l.frame == Frame::Bottom) {
self.lines.push(PageLine::default());
return;
}
self.blank();
}
pub fn item(&mut self, start: usize, link: u32) {
let end = self.lines.len();
let gaps = self.lines.iter().skip(start);
let start = start.saturating_add(
gaps.take_while(|l| l.frame == Frame::None && l.is_blank())
.count(),
);
if end > start {
self.items.push(Item {
start,
end,
link,
cols: None,
});
}
}
pub fn side_by_side(&mut self, widths: &[u16], gap: u16, mut f: impl FnMut(usize, &mut Page)) {
let mut columns = Vec::new();
for (i, width) in widths.iter().enumerate() {
let mut column = Page::new(*width);
column.compact = self.compact;
column.links = std::mem::take(&mut self.links);
column.link_ids = std::mem::take(&mut self.link_ids);
f(i, &mut column);
self.links = std::mem::take(&mut column.links);
self.link_ids = std::mem::take(&mut column.link_ids);
columns.push(column);
}
let base = self.lines.len();
let rows = columns.iter().map(|c| c.lines.len()).max().unwrap_or(0);
for row in 0..rows {
let mut segs = Vec::new();
for (i, column) in columns.iter().enumerate() {
if i > 0 {
segs.push(Seg::new(" ".repeat(usize::from(gap)), Role::Body));
}
match column.lines.get(row) {
Some(line) => segs.extend(flatten(line, column.width)),
None => segs.push(Seg::new(" ".repeat(usize::from(column.width)), Role::Body)),
}
}
self.add(Frame::None, 0, segs, Vec::new());
}
let mut x = 0u16;
for column in &columns {
for it in &column.items {
self.items.push(Item {
start: base.saturating_add(it.start),
end: base.saturating_add(it.end),
link: it.link,
cols: Some((x, column.width)),
});
}
x = x.saturating_add(column.width).saturating_add(gap);
}
}
pub fn build_aside(&mut self, f: impl FnOnce(&mut Page)) -> bool {
let Some(Aside { width, .. }) = self.aside else {
return false;
};
let mut aside = Page::new(width);
aside.links = std::mem::take(&mut self.links);
aside.link_ids = std::mem::take(&mut self.link_ids);
f(&mut aside);
self.links = std::mem::take(&mut aside.links);
self.link_ids = std::mem::take(&mut aside.link_ids);
let lines = aside.lines;
self.aside = Some(Aside { width, lines });
true
}
pub fn sections(&self) -> Vec<usize> {
let starts = self.lines.iter().enumerate().filter(|(_, l)| {
l.frame == Frame::Top
|| l.segs
.first()
.is_some_and(|s| matches!(s.role, Role::Heading | Role::Title))
});
let mut out = Vec::new();
let mut previous = None;
for (i, _) in starts {
if previous.is_none_or(|p: usize| p.saturating_add(1) != i) {
out.push(i);
}
previous = Some(i);
}
out
}
pub fn mark(&mut self, query: &str) {
let q = query.to_ascii_lowercase();
for (i, line) in self.lines.iter_mut().enumerate() {
if text::has(&line.text().to_ascii_lowercase(), &q) {
let segs = std::mem::take(&mut line.segs);
line.segs = segs.into_iter().flat_map(|s| highlight(s, &q)).collect();
self.marks.push(i);
}
}
}
pub fn wide(&self) -> usize {
let code = self.lines.iter().filter(|l| l.tone != Tone::Plain);
let width = |l: &PageLine| l.segs.iter().map(|s| text::width(&s.text)).sum::<usize>();
let longest = code.map(width).max().unwrap_or(0);
longest.saturating_sub(usize::from(self.width) / 2)
}
pub fn height(&self) -> usize {
let aside = self.aside.as_ref().map_or(0, |a| a.lines.len());
self.lines.len().max(aside)
}
pub fn line_of(&self, link: &Link) -> Option<usize> {
let id = *self.link_ids.get(link)?;
(self.lines.iter()).position(|l| l.segs.iter().any(|s| s.link == Some(id)))
}
pub fn item_at(&self, line: usize) -> Option<usize> {
self.items
.iter()
.position(|i| (i.start..i.end).contains(&line))
}
}
pub fn wrap(text: &str, max: usize) -> Vec<String> {
wrap_segs(vec![Seg::new(text.trim_end(), Role::Body)], max.max(1))
.into_iter()
.map(|line| line.into_iter().map(|s| s.text).collect())
.collect()
}
pub(crate) fn wrap_segs(segs: Vec<Seg>, width: usize) -> Vec<Vec<Seg>> {
let mut lines: Vec<Vec<Seg>> = vec![Vec::new()];
let mut used = 0usize;
let push = |lines: &mut Vec<Vec<Seg>>, seg: &Seg, text: &str| {
let Some(line) = lines.last_mut() else {
return;
};
match line.last_mut() {
Some(last) if last.role == seg.role && last.link == seg.link => {
last.text.push_str(text);
}
_ => line.push(Seg {
text: text.to_owned(),
role: seg.role.clone(),
link: seg.link,
}),
}
};
for seg in segs {
for (n, part) in seg.text.split('\n').enumerate() {
if n > 0 {
lines.push(Vec::new());
used = 0;
}
for word in part.split_inclusive(' ') {
let w = text::width(word.trim_end());
if used > 0 && used.saturating_add(w) > width {
lines.push(Vec::new());
used = 0;
if word.trim().is_empty() {
continue;
}
}
if w > width {
let mut chunk = String::new();
for (g, gw) in text::graphemes(word) {
if used.saturating_add(gw) > width {
push(&mut lines, &seg, &chunk);
chunk.clear();
lines.push(Vec::new());
used = 0;
}
chunk.push_str(g);
used = used.saturating_add(gw);
}
push(&mut lines, &seg, &chunk);
continue;
}
push(&mut lines, &seg, word);
used = used.saturating_add(text::width(word));
}
}
}
for last in lines.iter_mut().filter_map(|l| l.last_mut()) {
last.text.truncate(last.text.trim_end().len());
}
lines
}
const PAGE_BG: Bg = Bg::ContainerLow;
const CODE_BG: Bg = Bg::Diff(DiffBg::Context);
const MARKED_BG: Bg = Bg::DiffSelected(DiffBg::Context);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Columns {
pub main_x: u16,
pub aside_x: Option<u16>,
}
pub fn columns(page: &Page, area: Rect) -> Columns {
let aside = (page.aside.as_ref()).map(|a| ASIDE_GAP.saturating_add(a.width));
let total = page.width.saturating_add(aside.unwrap_or(0));
let main_x = area.x.saturating_add(area.width.saturating_sub(total) / 2);
Columns {
main_x,
aside_x: aside.map(|_| main_x.saturating_add(page.width).saturating_add(ASIDE_GAP)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Spot {
pub x: u16,
pub y: u16,
pub link: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PageHit {
pub line: Option<usize>,
pub link: Option<u32>,
}
fn inside(line: &PageLine, x: u16, width: u16) -> Option<(u16, u16)> {
match line.frame {
Frame::None => Some((
x.saturating_add(line.indent),
width.saturating_sub(line.indent),
)),
Frame::Body => Some((
x.saturating_add(BOX_PAD).saturating_add(line.indent),
width.saturating_sub((2 * BOX_PAD).saturating_add(line.indent)),
)),
Frame::Top => Some((x.saturating_add(3), width.saturating_sub(6))),
Frame::Rule | Frame::Bottom => None,
}
}
fn place(line: &PageLine, x: u16, width: u16) -> Vec<(u16, String, &Seg)> {
let Some((tx, tw)) = inside(line, x, width) else {
return Vec::new();
};
let gap = if line.frame == Frame::Top { 4 } else { 2 };
let right_cap = if line.segs.is_empty() { tw } else { tw / 2 };
let right_w = line
.right
.iter()
.map(|s| cols(text::width(&s.text)))
.fold(0, u16::saturating_add)
.min(right_cap);
let left_w = if right_w > 0 {
tw.saturating_sub(right_w.saturating_add(gap))
} else {
tw
};
let mut out = Vec::new();
lay_out(&mut out, &line.segs, tx, left_w);
let right_x = tx.saturating_add(tw.saturating_sub(right_w));
lay_out(&mut out, &line.right, right_x, right_w);
out
}
fn lay_out<'a>(out: &mut Vec<(u16, String, &'a Seg)>, segs: &'a [Seg], mut x: u16, room: u16) {
let mut room = usize::from(room);
for seg in segs {
if room == 0 {
break;
}
let shown = text::truncate(&seg.text, room);
let w = text::width(&shown);
room = room.saturating_sub(w);
out.push((x, shown, seg));
x = x.saturating_add(cols(w));
}
}
fn flatten(line: &PageLine, width: u16) -> Vec<Seg> {
let w = usize::from(width);
let blank = Some((" ".to_owned(), Role::Body, None));
let mut cells: Vec<Option<(String, Role, Option<u32>)>> = vec![blank; w];
let put = |cells: &mut Vec<_>, x: usize, text: &str, role: Role, link: Option<u32>| {
if let Some(cell) = cells.get_mut(x) {
*cell = Some((text.to_owned(), role, link));
}
};
let edges = match line.frame {
Frame::Top => Some(("╭", "─", "╮")),
Frame::Rule => Some(("├", "─", "┤")),
Frame::Bottom => Some(("╰", "─", "╯")),
Frame::Body => Some(("│", " ", "│")),
Frame::None => None,
};
if let Some((left, middle, right)) = edges {
for x in 0..w {
let edge = match x {
0 => left,
x if x.saturating_add(1) == w => right,
_ => middle,
};
put(&mut cells, x, edge, Role::Meta, None);
}
}
for (sx, shown, seg) in place(line, 0, width) {
let mut x = usize::from(sx);
if line.frame == Frame::Top {
let after = x.saturating_add(text::width(&shown));
for gap in [x.saturating_sub(1), after] {
put(&mut cells, gap, " ", Role::Body, None);
}
}
for c in shown.chars() {
let mut buf = [0; 4];
let c = c.encode_utf8(&mut buf);
let cw = text::width(c);
if x.saturating_add(cw) > w {
break;
}
put(&mut cells, x, c, seg.role.clone(), seg.link);
for k in 1..cw {
if let Some(cell) = cells.get_mut(x.saturating_add(k)) {
*cell = None;
}
}
x = x.saturating_add(cw);
}
}
let mut out: Vec<Seg> = Vec::new();
for (text, role, link) in cells.into_iter().flatten() {
match out.last_mut() {
Some(last) if last.role == role && last.link == link => last.text.push_str(&text),
_ => out.push(Seg::linked(text, role, link)),
}
}
out
}
pub fn spots(page: &Page, area: Rect, scroll: usize) -> Vec<Spot> {
let layout = columns(page, area);
let mut out = Vec::new();
let mut add = |lines: &[PageLine], x: u16, width: u16| {
for (line, y) in lines.iter().skip(scroll).zip(area.top()..area.bottom()) {
let mut seen = Vec::new();
for (sx, _, seg) in place(line, x, width) {
if let Some(link) = seg.link
&& !seen.contains(&link)
{
seen.push(link);
out.push(Spot { x: sx, y, link });
}
}
}
};
add(&page.lines, layout.main_x, page.width);
if let (Some(ax), Some(a)) = (layout.aside_x, &page.aside) {
add(&a.lines, ax, a.width);
}
out.sort_by_key(|s| (s.y, s.x));
out
}
pub fn hit(page: &Page, area: Rect, scroll: usize, x: u16, y: u16) -> PageHit {
if !(area.y..area.bottom()).contains(&y) {
return PageHit::default();
}
let row = scroll.saturating_add(usize::from(y.saturating_sub(area.y)));
let layout = columns(page, area);
let link_at = |line: &PageLine, lx: u16, width: u16| {
place(line, lx, width)
.into_iter()
.find(|(sx, shown, _)| (*sx..sx.saturating_add(cols(text::width(shown)))).contains(&x))
.and_then(|(_, _, seg)| seg.link)
};
if let (Some(ax), Some(a)) = (layout.aside_x, &page.aside)
&& x >= ax
{
let link = a.lines.get(row).and_then(|l| link_at(l, ax, a.width));
return PageHit { line: None, link };
}
let Some(line) = page.lines.get(row) else {
return PageHit::default();
};
PageHit {
line: Some(row),
link: link_at(line, layout.main_x, page.width),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HintLabel {
pub x: u16,
pub y: u16,
pub label: String,
}
pub fn draw_hints(theme: &crate::theme::Theme, hints: &[HintLabel], area: Rect, buf: &mut Buffer) {
for hint in hints {
let style = theme
.fill(Bg::TertiaryContainer)
.add_modifier(Modifier::BOLD);
let width = cols(text::width(&hint.label));
let x = hint
.x
.checked_sub(width)
.filter(|x| *x >= area.x)
.unwrap_or(hint.x);
Span::styled(hint.label.clone(), style).render(
Rect {
x,
y: hint.y,
width,
height: 1,
},
buf,
);
}
}
pub struct PageView<'a> {
pub ctx: Ctx<'a>,
pub page: &'a Page,
pub scroll: usize,
pub selected: Option<usize>,
pub from: Option<usize>,
pub current: Option<usize>,
pub hints: &'a [HintLabel],
pub shift: usize,
pub word: Option<(usize, Range<usize>)>,
}
impl Widget for PageView<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let theme = self.ctx.theme;
fill(buf, area, theme, PAGE_BG);
let layout = columns(self.page, area);
let items = |i: Option<usize>| i.and_then(|i| self.page.items.get(i)).copied();
let beside = items(self.selected).filter(|s| s.cols.is_some() && self.from.is_none());
let selected =
items(self.selected)
.filter(|_| beside.is_none())
.map(|s| match items(self.from) {
Some(f) => s.start.min(f.start)..s.end.max(f.end),
None => s.start..s.end,
});
for (row, y) in (area.top()..area.bottom()).enumerate() {
let i = self.scroll.saturating_add(row);
if let Some(line) = self.page.lines.get(i) {
let sel = selected.as_ref().is_some_and(|s| s.contains(&i));
let here = self.current == Some(i);
self.line(
buf,
line,
layout.main_x,
y,
self.page.width,
sel || here,
here,
);
if let Some((_, cols)) = self.word.as_ref().filter(|(at, _)| *at == i) {
self.word(buf, line, (layout.main_x, y), cols);
}
if let Some(Item {
cols: Some((from, width)),
..
}) = beside.filter(|s| (s.start..s.end).contains(&i))
{
let x = layout.main_x.saturating_add(from).saturating_add(1);
let band = Rect {
x,
y,
width: width.saturating_sub(2),
height: 1,
}
.intersection(area);
if let Some(bg) = theme.fill(Bg::Selected).bg {
buf.set_style(band, Style::new().bg(bg));
}
put(buf, x, y, "▌", theme.accent(Bg::Selected));
}
}
if let (Some(ax), Some(a)) = (layout.aside_x, &self.page.aside)
&& let Some(line) = a.lines.get(i)
{
self.line(buf, line, ax, y, a.width, false, false);
}
}
self.scrollbar(area, buf);
draw_hints(theme, self.hints, area, buf);
}
}
impl PageView<'_> {
#[expect(
clippy::too_many_arguments,
reason = "a line, where, and how it's marked"
)]
fn line(
&self,
buf: &mut Buffer,
line: &PageLine,
x: u16,
y: u16,
width: u16,
sel: bool,
current: bool,
) {
let theme = self.ctx.theme;
let row = |x: u16, w: u16| Rect {
x,
y,
width: w,
height: 1,
};
let inner_bg = match (line.tone, sel) {
_ if current => Bg::Selected,
(Tone::Code, _) => CODE_BG,
(Tone::Marked, _) => MARKED_BG,
(Tone::Added, _) => Bg::Diff(DiffBg::Added),
(Tone::Removed, _) => Bg::Diff(DiffBg::Removed),
(Tone::Plain, true) => Bg::Selected,
(Tone::Plain, false) => PAGE_BG,
};
let framed = line.frame != Frame::None;
if sel {
let band = if framed {
row(x.saturating_add(1), width.saturating_sub(2))
} else {
row(x.saturating_sub(2), width.saturating_add(2))
};
fill(buf, band, theme, Bg::Selected);
put(buf, band.x, y, "▌", theme.accent(Bg::Selected));
} else if line.tone != Tone::Plain {
let band = if framed {
row(x.saturating_add(1), width.saturating_sub(2))
} else {
row(x, width)
};
fill(buf, band, theme, inner_bg);
}
let border = theme.separator(PAGE_BG);
let rule = |buf: &mut Buffer, left: &str, right: &str| {
let middle = "─".repeat(usize::from(width.saturating_sub(2)));
put(buf, x, y, &format!("{left}{middle}{right}"), border);
};
match line.frame {
Frame::None => {}
Frame::Top => rule(buf, "╭", "╮"),
Frame::Rule => rule(buf, "├", "┤"),
Frame::Bottom => rule(buf, "╰", "╯"),
Frame::Body => {
put(buf, x, y, "│", border);
put(
buf,
x.saturating_add(width.saturating_sub(1)),
y,
"│",
border,
);
}
}
let shifted;
let line = if self.shift > 0 && line.tone != Tone::Plain {
shifted = line.shifted(self.shift);
&shifted
} else {
line
};
let placed = place(line, x, width);
if line.frame == Frame::Top {
for (sx, shown, _) in &placed {
let w = cols(text::width(shown));
fill(
buf,
row(sx.saturating_sub(1), w.saturating_add(2)),
theme,
PAGE_BG,
);
}
}
for (sx, shown, seg) in placed {
let w = cols(text::width(&shown));
let style = match seg.role {
Role::Match if current => theme.fill(Bg::Primary),
_ => self.style(&seg.role, inner_bg),
};
Line::from(Span::styled(shown, style)).render(row(sx, w), buf);
}
}
fn word(&self, buf: &mut Buffer, line: &PageLine, (x, y): (u16, u16), cols: &Range<usize>) {
let (Some((_, before)), Some((tx, tw))) = (line.code(), inside(line, x, self.page.width))
else {
return;
};
let code_x = usize::from(tx).saturating_add(before);
let end = usize::from(tx.saturating_add(tw));
let at = |c: usize| {
let c = code_x.saturating_add(c.saturating_sub(self.shift));
u16::try_from(c.min(end)).unwrap_or(u16::MAX)
};
let from = at(cols.start);
let area = Rect {
x: from,
y,
width: at(cols.end).saturating_sub(from),
height: 1,
};
buf.set_style(area, Style::new().add_modifier(Modifier::REVERSED));
}
fn scrollbar(&self, area: Rect, buf: &mut Buffer) {
let total = self.page.height();
let h = usize::from(area.height);
if total <= h || h == 0 || area.width < 2 {
return;
}
let thumb = h
.saturating_mul(h)
.checked_div(total)
.unwrap_or(h)
.clamp(1, h);
let top = self
.scroll
.saturating_mul(h.saturating_sub(thumb))
.checked_div(total.saturating_sub(h))
.unwrap_or(0);
let x = area.right().saturating_sub(1);
for r in top..top.saturating_add(thumb).min(h) {
let y = area.y.saturating_add(cols(r));
put(buf, x, y, "▐", self.ctx.theme.separator(PAGE_BG));
}
}
fn style(&self, role: &Role, bg: Bg) -> Style {
role_style(self.ctx.theme, role, bg)
}
}
pub(crate) fn role_style(theme: &crate::theme::Theme, role: &Role, bg: Bg) -> Style {
match role {
Role::Body => theme.body(bg),
Role::Title => theme.title(bg),
Role::Heading => theme.accent(bg).add_modifier(Modifier::BOLD),
Role::Strong => theme.body(bg).add_modifier(Modifier::BOLD),
Role::Emph => theme.body(bg).add_modifier(Modifier::ITALIC),
Role::Strike => theme.meta(bg).add_modifier(Modifier::CROSSED_OUT),
Role::Meta => theme.meta(bg),
Role::Link | Role::Accent => theme.accent(bg),
Role::Code => match bg {
Bg::Diff(_) | Bg::DiffSelected(_) => theme.style(Fg::Syntax(Syntax::String), bg),
_ => theme.fill(Bg::ContainerHigh),
},
Role::Syntax(s) => match bg {
Bg::Diff(_) | Bg::DiffSelected(_) => theme.style(Fg::Syntax(*s), bg),
_ => theme.body(bg),
},
Role::Success => theme.style(Fg::Success, bg),
Role::Error => theme.error(bg),
Role::Added => theme.style(Fg::DiffAddedSign, bg),
Role::Removed => theme.style(Fg::DiffRemovedSign, bg),
Role::Chip(chip) => theme.fill(*chip),
Role::Label(color) => theme.label_chip(color),
Role::Heat(level) => theme.style(Fg::Heat(*level), bg),
Role::Match => theme.fill(Bg::TertiaryContainer),
}
}
pub fn reveal(
start: usize,
end: usize,
scroll: usize,
height: usize,
total: usize,
margin: usize,
) -> usize {
let height = height.max(1);
let max = total.saturating_sub(height);
let margin = margin.min(height.saturating_sub(end.saturating_sub(start)) / 2);
let mut scroll = scroll.min(max);
if end.saturating_add(margin) > scroll.saturating_add(height) {
scroll = end.saturating_add(margin).saturating_sub(height);
}
if start < scroll.saturating_add(margin) {
scroll = start.saturating_sub(margin);
}
scroll.min(max)
}
#[cfg(test)]
mod tests {
use super::*;
fn texts(lines: &[Vec<Seg>]) -> Vec<String> {
lines
.iter()
.map(|l| l.iter().map(|s| s.text.as_str()).collect())
.collect()
}
#[test]
fn wraps_across_segments() {
let segs = vec![
Seg::new("hello ", Role::Body),
Seg::new("bold words ", Role::Strong),
Seg::new("and more text", Role::Body),
];
let lines = wrap_segs(segs, 12);
assert_eq!(texts(&lines), ["hello bold", "words and", "more text"]);
assert_eq!(lines[0][1].role, Role::Strong);
}
#[test]
fn wraps_words() {
assert_eq!(wrap("one two three", 7), ["one two", "three"]);
assert_eq!(wrap("a\n\nabcdefghij", 4), ["a", "", "abcd", "efgh", "ij"]);
assert_eq!(wrap("", 4), [""]);
assert_eq!(wrap("a\r\n\tb", 10), ["a", " b"]);
}
#[test]
fn links_are_shared_and_items_mark_rows() {
let mut page = Page::new(40);
page.line(vec![Seg::new("title", Role::Title)]);
page.blank();
let start = page.lines.len();
let a = page.link_line("one", "https://github.com/a", Role::Link, 0);
page.line(vec![Seg::new("its second line", Role::Meta)]);
page.item(start, a);
let b = page.link_line("again", "https://github.com/a", Role::Link, 0);
assert_eq!(a, b, "same URL, same link");
assert_eq!(
page.items,
[Item {
start: 2,
end: 4,
link: a,
cols: None,
}]
);
assert_eq!(page.item_at(3), Some(0));
assert_eq!(page.item_at(4), None);
}
#[test]
fn boxes_wrap_inside_their_borders_and_place_titles_on_the_edge() {
let mut page = Page::new(24);
page.box_top(
vec![Seg::new("Title", Role::Strong)],
vec![Seg::new("3", Role::Meta)],
);
page.wrapped(
vec![Seg::new("one two three four five", Role::Body)],
0,
Frame::Body,
);
page.box_gap();
page.box_bottom();
let texts: Vec<String> = page.lines.iter().map(PageLine::text).collect();
assert_eq!(texts, ["Title", "one two three four", "five", ""]);
assert_eq!(
page.lines.last().unwrap().frame,
Frame::Bottom,
"no gap above the edge"
);
let area = Rect::new(0, 0, 24, 10);
let spots = spots(&page, area, 0);
assert!(spots.is_empty());
let placed = place(&page.lines[0], 0, 24);
assert_eq!(placed[0].0, 3, "after `╭─ `");
assert_eq!(placed[1].0, 24 - 3 - 1, "right title ends before ` ─╮`");
}
#[test]
fn hits_and_spots_find_links_in_both_columns() {
let mut page = Page::with_aside(110);
let link = page.link("https://github.com/a");
page.line(vec![
Seg::new("see ", Role::Body),
Seg::linked("here", Role::Link, link),
]);
assert!(page.build_aside(|aside| {
aside.link_line("b", "https://github.com/b", Role::Link, 0);
}));
let area = Rect::new(0, 0, 120, 5);
let cols = columns(&page, area);
assert_eq!(cols.main_x, (120 - 110) / 2);
let spots = spots(&page, area, 0);
assert_eq!(spots.len(), 2);
assert_eq!(spots[0].x, cols.main_x + 4);
assert_eq!(
page.links[spots[1].link as usize],
Link::from("https://github.com/b")
);
let h = hit(&page, area, 0, cols.main_x + 5, 0);
assert_eq!(
h,
PageHit {
line: Some(0),
link: Some(link)
}
);
let h = hit(&page, area, 0, cols.main_x + 1, 0);
assert_eq!(
h,
PageHit {
line: Some(0),
link: None
}
);
let h = hit(&page, area, 0, cols.aside_x.unwrap(), 0);
assert_eq!(h.link, Some(spots[1].link));
}
#[test]
fn reveal_scrolls_as_little_as_possible() {
assert_eq!(reveal(0, 1, 0, 10, 100, 2), 0);
assert_eq!(reveal(20, 22, 0, 10, 100, 2), 14);
assert_eq!(reveal(5, 6, 12, 10, 100, 2), 3);
assert_eq!(reveal(99, 100, 0, 10, 100, 2), 90);
assert_eq!(reveal(10, 40, 0, 10, 100, 2), 10);
}
}