use crate::key::{self, Binding};
use rusty_bubbletea::key::KeyPressMsg;
use rusty_bubbletea::model::{Cmd, Msg};
use rusty_bubbletea::mouse::{MouseButton, MouseWheelMsg};
use rusty_lipgloss::{self, ranges::Range, Style};
use rusty_x_ansi;
use std::collections::HashMap;
const DEFAULT_HORIZONTAL_STEP: usize = 6;
pub type Option = Box<dyn FnOnce(&mut Model)>;
pub fn with_width(w: usize) -> Option {
Box::new(move |m: &mut Model| {
m.width = w;
})
}
pub fn with_height(h: usize) -> Option {
Box::new(move |m: &mut Model| {
m.height = h;
})
}
impl Default for KeyMap {
fn default() -> Self {
default_key_map()
}
}
pub fn with_key_map(km: KeyMap) -> Option {
Box::new(move |m: &mut Model| {
m.key_map = km.clone();
})
}
pub fn new(opts: Vec<Option>) -> Model {
let mut m = Model {
width: 0,
height: 0,
key_map: default_key_map(),
soft_wrap: false,
fill_height: false,
mouse_wheel_enabled: true,
mouse_wheel_delta: 3,
y_offset: 0,
x_offset: 0,
horizontal_step: DEFAULT_HORIZONTAL_STEP,
y_position: 0,
style: Style::new(),
left_gutter_func: None,
initialized: false,
lines: vec![],
longest_line_width: 0,
highlight_style: Style::new(),
selected_highlight_style: Style::new(),
style_line_func: None,
highlights: vec![],
hi_idx: -1,
clone_hack: std::marker::PhantomData,
};
for opt in opts {
opt(&mut m);
}
m.set_initial_values();
m
}
#[derive(Debug, Clone, Copy)]
pub struct GutterContext {
pub index: usize,
pub total_lines: usize,
pub soft: bool,
}
pub type GutterFunc = Box<dyn Fn(GutterContext) -> String + Send + Sync>;
pub struct Model {
width: usize,
height: usize,
pub key_map: KeyMap,
pub soft_wrap: bool,
pub fill_height: bool,
pub mouse_wheel_enabled: bool,
pub mouse_wheel_delta: usize,
y_offset: usize,
x_offset: usize,
horizontal_step: usize,
pub y_position: usize,
pub style: Style,
pub left_gutter_func: std::option::Option<GutterFunc>,
#[doc(hidden)]
#[allow(dead_code)]
clone_hack: std::marker::PhantomData<()>,
initialized: bool,
lines: Vec<String>,
longest_line_width: usize,
pub highlight_style: Style,
pub selected_highlight_style: Style,
pub style_line_func: std::option::Option<Box<dyn Fn(usize) -> Style + Send + Sync>>,
highlights: Vec<HighlightInfo>,
hi_idx: isize,
}
impl Clone for Model {
fn clone(&self) -> Self {
Model {
width: self.width,
height: self.height,
key_map: self.key_map.clone(),
soft_wrap: self.soft_wrap,
fill_height: self.fill_height,
mouse_wheel_enabled: self.mouse_wheel_enabled,
mouse_wheel_delta: self.mouse_wheel_delta,
y_offset: self.y_offset,
x_offset: self.x_offset,
horizontal_step: self.horizontal_step,
y_position: self.y_position,
style: self.style.clone(),
left_gutter_func: None,
initialized: self.initialized,
lines: self.lines.clone(),
longest_line_width: self.longest_line_width,
highlight_style: self.highlight_style.clone(),
selected_highlight_style: self.selected_highlight_style.clone(),
style_line_func: None,
highlights: self.highlights.clone(),
hi_idx: self.hi_idx,
clone_hack: std::marker::PhantomData,
}
}
}
impl std::fmt::Debug for Model {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("viewport::Model")
.field("width", &self.width)
.field("height", &self.height)
.field("y_offset", &self.y_offset)
.field("lines", &self.lines.len())
.finish()
}
}
impl Model {
fn set_initial_values(&mut self) {
self.mouse_wheel_enabled = true;
self.mouse_wheel_delta = 3;
self.horizontal_step = DEFAULT_HORIZONTAL_STEP;
self.initialized = true;
}
pub fn height(&self) -> usize {
self.height
}
pub fn set_height(&mut self, h: usize) {
self.height = h;
}
pub fn width(&self) -> usize {
self.width
}
pub fn set_width(&mut self, w: usize) {
self.width = w;
}
pub fn at_top(&self) -> bool {
self.y_offset() == 0
}
pub fn at_bottom(&self) -> bool {
self.y_offset() >= self.max_y_offset()
}
pub fn past_bottom(&self) -> bool {
self.y_offset() > self.max_y_offset()
}
pub fn scroll_percent(&self) -> f64 {
let (total, _, _) = self.calculate_line(0);
if self.height() >= total {
return 1.0;
}
let y = self.y_offset() as f64;
let h = self.height() as f64;
let t = total as f64;
let v = y / (t - h);
clamp(v, 0.0, 1.0)
}
pub fn horizontal_scroll_percent(&self) -> f64 {
if self.x_offset >= self.longest_line_width.saturating_sub(self.width()) {
return 1.0;
}
let y = self.x_offset as f64;
let h = self.width() as f64;
let t = self.longest_line_width as f64;
let v = y / (t - h);
clamp(v, 0.0, 1.0)
}
pub fn set_content(&mut self, s: &str) {
self.set_content_lines(&s.split('\n').map(|x| x.to_string()).collect::<Vec<_>>());
}
pub fn set_content_lines(&mut self, lines: &[String]) {
self.lines = lines.to_vec();
if self.lines.len() == 1 && rusty_x_ansi::string_width(&self.lines[0]) == 0 {
self.lines.clear();
} else {
let mut sub_lines: Vec<String>;
let mut i = self.lines.len();
while i > 0 {
i -= 1;
if !self.lines[i].contains('\r') && !self.lines[i].contains('\n') {
continue;
}
self.lines[i] = self.lines[i].replace("\r\n", "\n"); sub_lines = self.lines[i].split('\n').map(|x| x.to_string()).collect();
if sub_lines.len() > 1 {
self.lines
.splice(i + 1..i + 1, sub_lines[1..].iter().cloned());
self.lines[i] = sub_lines[0].clone();
}
}
}
self.longest_line_width = max_line_width(&self.lines);
self.clear_highlights();
if self.y_offset() > self.max_y_offset() {
self.goto_bottom();
}
}
pub fn get_content(&self) -> String {
self.lines.join("\n")
}
fn calculate_line(&self, yoffset: usize) -> (usize, usize, usize) {
if !self.soft_wrap {
let total = self.lines.len();
let ridx = yoffset.min(self.lines.len());
return (total, ridx, 0);
}
let max_width = self.max_width() as f64;
let mut total = 0usize;
let mut ridx = self.lines.len();
let mut voffset = 0usize;
for (i, line) in self.lines.iter().enumerate() {
let line_height =
1usize.max((rusty_x_ansi::string_width(line) as f64 / max_width).ceil() as usize);
if yoffset >= total && yoffset < total + line_height {
ridx = i;
voffset = yoffset - total;
}
total += line_height;
}
if yoffset >= total {
ridx = self.lines.len();
voffset = 0;
}
(total, ridx, voffset)
}
fn max_y_offset(&self) -> usize {
let (total, _, _) = self.calculate_line(0);
total
.saturating_sub(self.height())
.saturating_add(self.style.get_vertical_frame_size())
}
fn max_x_offset(&self) -> usize {
self.longest_line_width.saturating_sub(self.width())
}
fn max_width(&self) -> usize {
let mut gutter_size = 0;
if let Some(g) = &self.left_gutter_func {
gutter_size = rusty_x_ansi::string_width(&g(GutterContext {
index: 0,
total_lines: 0,
soft: false,
}));
}
self.width()
.saturating_sub(self.style.get_horizontal_frame_size())
.saturating_sub(gutter_size)
}
fn max_height(&self) -> usize {
self.height()
.saturating_sub(self.style.get_vertical_frame_size())
}
fn visible_lines(&self) -> Vec<String> {
let max_height = self.max_height();
let max_width = self.max_width();
if max_height == 0 || max_width == 0 {
return vec![];
}
let (total, ridx, voffset) = self.calculate_line(self.y_offset());
let mut lines: Vec<String> = vec![];
if total > 0 {
let bottom = clamp(ridx + max_height, ridx, self.lines.len());
lines = self.style_lines(self.lines[ridx..bottom].to_vec(), ridx);
lines = self.highlight_lines(lines, ridx);
}
while self.fill_height && lines.len() < max_height {
lines.push(String::new());
}
if (self.x_offset == 0 && self.longest_line_width <= max_width) || max_width == 0 {
let out = self.setup_gutter(lines, total, ridx);
return out;
}
if self.soft_wrap {
return self.soft_wrap_lines(lines, max_width, max_height, total, ridx, voffset);
}
for line in lines.iter_mut() {
*line = rusty_x_ansi::cut(line, self.x_offset, self.x_offset + max_width);
}
self.setup_gutter(lines, total, ridx)
}
fn style_lines(&self, lines: Vec<String>, offset: usize) -> Vec<String> {
match &self.style_line_func {
Some(f) => lines
.iter()
.enumerate()
.map(|(i, l)| f(i + offset).render(l))
.collect(),
None => lines,
}
}
fn highlight_lines(&self, lines: Vec<String>, offset: usize) -> Vec<String> {
if self.highlights.is_empty() {
return lines;
}
lines
.iter()
.enumerate()
.map(|(i, line)| {
let ranges =
make_highlight_ranges(&self.highlights, i + offset, &self.highlight_style);
if self.hi_idx >= 0 {
let sel = &self.highlights[self.hi_idx as usize];
if let Some(hi) = sel.lines.get(&(i + offset)) {
return rusty_lipgloss::ranges::style_ranges(
line,
&[rusty_lipgloss::ranges::new_range(
hi.0,
hi.1,
self.selected_highlight_style.clone(),
)],
);
}
}
rusty_lipgloss::ranges::style_ranges(line, &ranges)
})
.collect()
}
fn soft_wrap_lines(
&self,
lines: Vec<String>,
max_width: usize,
max_height: usize,
total: usize,
ridx: usize,
voffset: usize,
) -> Vec<String> {
let mut wrapped_lines: Vec<String> = Vec::with_capacity(max_height);
let mut idx: usize;
let mut line_width: usize;
let mut truncated_line: String;
for (i, line) in lines.iter().enumerate() {
line_width = rusty_x_ansi::string_width(line);
if line_width <= max_width {
if let Some(g) = &self.left_gutter_func {
let gutter = g(GutterContext {
index: i + ridx,
total_lines: total,
soft: false,
});
wrapped_lines.push(gutter + line);
} else {
wrapped_lines.push(line.clone());
}
continue;
}
idx = 0;
while line_width > idx {
truncated_line = rusty_x_ansi::cut(line, idx, max_width + idx);
if let Some(g) = &self.left_gutter_func {
let gutter = g(GutterContext {
index: i + ridx,
total_lines: total,
soft: idx > 0,
});
wrapped_lines.push(gutter + &truncated_line);
} else {
wrapped_lines.push(truncated_line);
}
idx += max_width;
}
}
wrapped_lines[voffset..(voffset + max_height).min(wrapped_lines.len())].to_vec()
}
fn setup_gutter(&self, lines: Vec<String>, total: usize, ridx: usize) -> Vec<String> {
match &self.left_gutter_func {
None => lines,
Some(g) => lines
.iter()
.enumerate()
.map(|(i, l)| {
let gutter = g(GutterContext {
index: i + ridx,
total_lines: total,
soft: false,
});
gutter + l
})
.collect(),
}
}
pub fn set_y_offset(&mut self, n: usize) {
self.y_offset = clamp(n, 0, self.max_y_offset());
}
pub fn y_offset(&self) -> usize {
self.y_offset
}
pub fn ensure_visible(&mut self, line: usize, colstart: usize, colend: usize) {
let max_width = self.max_width();
if colend <= max_width {
self.set_x_offset(0);
} else {
self.set_x_offset(colstart.saturating_sub(self.horizontal_step)); }
if line < self.y_offset() || line >= self.y_offset() + self.max_height() {
self.set_y_offset(line);
}
}
pub fn page_down(&mut self) {
if self.at_bottom() {
return;
}
self.scroll_down(self.height());
}
pub fn page_up(&mut self) {
if self.at_top() {
return;
}
self.scroll_up(self.height());
}
pub fn half_page_down(&mut self) {
if self.at_bottom() {
return;
}
self.scroll_down(self.height() / 2);
}
pub fn half_page_up(&mut self) {
if self.at_top() {
return;
}
self.scroll_up(self.height() / 2);
}
pub fn scroll_down(&mut self, n: usize) {
if self.at_bottom() || n == 0 || self.lines.is_empty() {
return;
}
self.set_y_offset(self.y_offset() + n);
self.hi_idx = self.find_nearest_match();
}
pub fn scroll_up(&mut self, n: usize) {
if self.at_top() || n == 0 || self.lines.is_empty() {
return;
}
self.set_y_offset(self.y_offset() - n);
self.hi_idx = self.find_nearest_match();
}
pub fn set_horizontal_step(&mut self, n: usize) {
self.horizontal_step = n;
}
pub fn x_offset(&self) -> usize {
self.x_offset
}
pub fn set_x_offset(&mut self, n: usize) {
if self.soft_wrap {
return;
}
self.x_offset = clamp(n, 0, self.max_x_offset());
}
pub fn scroll_left(&mut self, n: usize) {
self.set_x_offset(self.x_offset.saturating_sub(n));
}
pub fn scroll_right(&mut self, n: usize) {
self.set_x_offset(self.x_offset + n);
}
pub fn total_line_count(&self) -> usize {
let (total, _, _) = self.calculate_line(0);
total
}
pub fn visible_line_count(&self) -> usize {
self.visible_lines().len()
}
pub fn goto_top(&mut self) -> Vec<String> {
if self.at_top() {
return vec![];
}
self.set_y_offset(0);
self.hi_idx = self.find_nearest_match();
self.visible_lines()
}
pub fn goto_bottom(&mut self) -> Vec<String> {
self.set_y_offset(self.max_y_offset());
self.hi_idx = self.find_nearest_match();
self.visible_lines()
}
pub fn set_highlights(&mut self, matches: &[Vec<usize>]) {
if matches.is_empty() || self.lines.is_empty() {
return;
}
self.highlights = parse_matches(&self.get_content(), matches);
self.hi_idx = self.find_nearest_match();
self.show_highlight();
}
pub fn highlights(&self) -> &[HighlightInfo] {
&self.highlights
}
pub fn clear_highlights(&mut self) {
self.highlights.clear();
self.hi_idx = -1;
}
fn show_highlight(&mut self) {
if self.hi_idx == -1 {
return;
}
let (line, colstart, colend) = self.highlights[self.hi_idx as usize].coords();
self.ensure_visible(line, colstart, colend);
}
pub fn highlight_next(&mut self) {
if self.highlights.is_empty() {
return;
}
self.hi_idx = (self.hi_idx + 1) % self.highlights.len() as isize;
self.show_highlight();
}
pub fn highlight_previous(&mut self) {
if self.highlights.is_empty() {
return;
}
self.hi_idx =
(self.hi_idx - 1 + self.highlights.len() as isize) % self.highlights.len() as isize;
self.show_highlight();
}
fn find_nearest_match(&self) -> isize {
for (i, m) in self.highlights.iter().enumerate() {
if m.line_start >= self.y_offset() {
return i as isize;
}
}
-1
}
pub fn update(&mut self, msg: &dyn Msg) -> Cmd {
self.update_as_model(msg);
None
}
fn update_as_model(&mut self, msg: &dyn Msg) {
if !self.initialized {
self.set_initial_values();
}
if let Some(m) = msg.as_any().downcast_ref::<KeyPressMsg>() {
let k = &m.0;
if key::matches(k, std::slice::from_ref(&self.key_map.page_down)) {
self.page_down();
} else if key::matches(k, std::slice::from_ref(&self.key_map.page_up)) {
self.page_up();
} else if key::matches(k, std::slice::from_ref(&self.key_map.half_page_down)) {
self.half_page_down();
} else if key::matches(k, std::slice::from_ref(&self.key_map.half_page_up)) {
self.half_page_up();
} else if key::matches(k, std::slice::from_ref(&self.key_map.down)) {
self.scroll_down(1);
} else if key::matches(k, std::slice::from_ref(&self.key_map.up)) {
self.scroll_up(1);
} else if key::matches(k, std::slice::from_ref(&self.key_map.left)) {
self.scroll_left(self.horizontal_step);
} else if key::matches(k, std::slice::from_ref(&self.key_map.right)) {
self.scroll_right(self.horizontal_step);
}
return;
}
if let Some(m) = msg.as_any().downcast_ref::<MouseWheelMsg>() {
if !self.mouse_wheel_enabled {
return;
}
let mouse = &m.0;
match mouse.button {
MouseButton::MouseWheelDown => {
if mouse.mod_keys.contains(rusty_bubbletea::key::KeyMod::SHIFT) {
self.scroll_right(self.horizontal_step);
return;
}
self.scroll_down(self.mouse_wheel_delta);
}
MouseButton::MouseWheelUp => {
if mouse.mod_keys.contains(rusty_bubbletea::key::KeyMod::SHIFT) {
self.scroll_left(self.horizontal_step);
return;
}
self.scroll_up(self.mouse_wheel_delta);
}
MouseButton::MouseWheelLeft => {
self.scroll_left(self.horizontal_step);
}
MouseButton::MouseWheelRight => {
self.scroll_right(self.horizontal_step);
}
_ => {}
}
}
}
pub fn view(&self) -> String {
let mut w = self.width();
let mut h = self.height();
let sw = self.style.get_width();
if sw != 0 {
w = w.min(sw);
}
let sh = self.style.get_height();
if sh != 0 {
h = h.min(sh);
}
if w == 0 || h == 0 {
return String::new();
}
let content_width = w - self.style.get_horizontal_frame_size();
let content_height = h - self.style.get_vertical_frame_size();
let vl = self.visible_lines();
let contents = rusty_lipgloss::new_style()
.width(content_width) .height(content_height) .render(&vl.join("\n"));
self.style
.clone()
.unset_width()
.unset_height() .render(&contents)
}
}
#[derive(Debug, Clone)]
pub struct KeyMap {
pub page_down: Binding,
pub page_up: Binding,
pub half_page_up: Binding,
pub half_page_down: Binding,
pub down: Binding,
pub up: Binding,
pub left: Binding,
pub right: Binding,
}
pub fn default_key_map() -> KeyMap {
KeyMap {
page_down: key::new_binding(vec![
key::with_keys(&["pgdown", "space", "f"]),
key::with_help("f/pgdn", "page down"),
]),
page_up: key::new_binding(vec![
key::with_keys(&["pgup", "b"]),
key::with_help("b/pgup", "page up"),
]),
half_page_up: key::new_binding(vec![
key::with_keys(&["u", "ctrl+u"]),
key::with_help("u", "½ page up"),
]),
half_page_down: key::new_binding(vec![
key::with_keys(&["d", "ctrl+d"]),
key::with_help("d", "½ page down"),
]),
up: key::new_binding(vec![
key::with_keys(&["up", "k"]),
key::with_help("↑/k", "up"),
]),
down: key::new_binding(vec![
key::with_keys(&["down", "j"]),
key::with_help("↓/j", "down"),
]),
left: key::new_binding(vec![
key::with_keys(&["left", "h"]),
key::with_help("←/h", "move left"),
]),
right: key::new_binding(vec![
key::with_keys(&["right", "l"]),
key::with_help("→/l", "move right"),
]),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct HighlightInfo {
pub line_start: usize,
pub line_end: usize,
pub lines: HashMap<usize, (usize, usize)>,
}
impl HighlightInfo {
fn coords(&self) -> (usize, usize, usize) {
for i in self.line_start..=self.line_end {
if let Some(hl) = self.lines.get(&i) {
return (i, hl.0, hl.1);
}
}
(self.line_start, 0, 0)
}
}
fn parse_matches(content: &str, matches: &[Vec<usize>]) -> Vec<HighlightInfo> {
if matches.is_empty() {
return vec![];
}
let stripped: Vec<u8> = rusty_x_ansi::strip(content).as_bytes().to_vec();
let mut highlights: Vec<HighlightInfo> = Vec::with_capacity(matches.len());
for m in matches {
let (byte_start, byte_end) = (m[0], m[1]);
let mut hi = HighlightInfo {
line_start: 0,
line_end: 0,
lines: HashMap::new(),
};
let mut line = 0usize;
let mut grapheme_pos = 0usize;
let mut previous_lines_offset = 0usize;
let mut byte_pos = 0usize;
while byte_start > byte_pos && byte_pos < stripped.len() {
let c = char_at(&stripped, byte_pos);
if c == '\n' {
previous_lines_offset = grapheme_pos + 1;
line += 1;
}
grapheme_pos += 1usize.max(char_width(c));
byte_pos += char_len(c);
}
hi.line_start = line;
hi.line_end = line;
let grapheme_start = grapheme_pos;
while byte_end > byte_pos && byte_pos < stripped.len() {
let c = char_at(&stripped, byte_pos);
if c == '\n' {
let colstart = grapheme_start.saturating_sub(previous_lines_offset);
let colend = (grapheme_pos.saturating_sub(previous_lines_offset) + 1).max(colstart);
if colend > colstart {
hi.lines.insert(line, (colstart, colend));
hi.line_end = line;
}
previous_lines_offset = grapheme_pos + 1;
line += 1;
}
grapheme_pos += 1usize.max(char_width(c));
byte_pos += char_len(c);
}
if byte_pos == byte_end {
let colstart = grapheme_start.saturating_sub(previous_lines_offset);
let colend = (grapheme_pos.saturating_sub(previous_lines_offset)).max(colstart);
if colend > colstart {
hi.lines.insert(line, (colstart, colend));
hi.line_end = line;
}
}
highlights.push(hi);
}
highlights
}
fn char_at(s: &[u8], byte_pos: usize) -> char {
std::str::from_utf8(&s[byte_pos..])
.ok()
.and_then(|r| r.chars().next())
.unwrap_or('\u{FFFD}')
}
fn make_highlight_ranges(highlights: &[HighlightInfo], line: usize, style: &Style) -> Vec<Range> {
let mut result: Vec<Range> = vec![];
for hi in highlights {
if let Some(lihi) = hi.lines.get(&line) {
if *lihi == (0, 0) {
continue;
}
result.push(rusty_lipgloss::ranges::new_range(
lihi.0,
lihi.1,
style.clone(),
));
}
}
result
}
fn char_width(c: char) -> usize {
unicode_width::UnicodeWidthChar::width(c).unwrap_or(0)
}
fn char_len(c: char) -> usize {
c.len_utf8()
}
fn clamp<T: PartialOrd + Copy>(v: T, low: T, high: T) -> T {
if high < low {
return low;
}
if v < low {
low
} else if v > high {
high
} else {
v
}
}
fn max_line_width(lines: &[String]) -> usize {
let mut result = 0;
for line in lines {
result = result.max(rusty_x_ansi::string_width(line));
}
result
}