use ratatui::buffer::Buffer as Surface;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::Widget;
use crate::config::Config;
use crate::editor::buffer::Buffer;
use crate::editor::cursor::Position;
use crate::editor::selection::Range;
use crate::editor::window::{Area, Window};
use crate::search::{LineMatch, Search};
use crate::syntax::Highlight;
use crate::theme::Theme;
use crate::ui::text::DisplayLine;
#[must_use]
pub fn gutter_width(config: &Config, line_count: usize) -> u16 {
if !config.line_numbers {
return 0;
}
let digits = line_count.max(1).ilog10() as u16 + 1;
digits.max(3) + 2
}
pub fn scroll_into_view(buffer: &Buffer, window: &mut Window, config: &Config, area: Rect) {
let gutter = gutter_width(config, buffer.document.len_lines());
let width = usize::from(area.width.saturating_sub(gutter));
let height = usize::from(area.height);
let head = window.cursor().head;
if !config.word_wrap {
let line = DisplayLine::new(&buffer.document.line_string(head.line), config.tab_width);
window.view.scroll_to(
head.line,
line.column_of(head.col),
height,
width,
config.scrolloff,
);
return;
}
window.view.left_col = 0;
if head.line < window.view.top_line {
window.view.top_line = head.line;
}
if width == 0 || height == 0 {
return;
}
let rows_of = |line: usize| {
DisplayLine::new(&buffer.document.line_string(line), config.tab_width)
.wrap(width)
.len()
};
while window.view.top_line < head.line {
let rows: usize = (window.view.top_line..=head.line).map(rows_of).sum();
if rows <= height {
break;
}
window.view.top_line += 1;
}
}
#[must_use]
pub fn position_at(
buffer: &Buffer,
window: &Window,
config: &Config,
area: Area,
x: u16,
y: u16,
) -> Option<Position> {
if !area.contains(x, y) {
return None;
}
let gutter = gutter_width(config, buffer.document.len_lines());
let width = usize::from(area.width.saturating_sub(gutter));
let column = usize::from(x.saturating_sub(area.x + gutter));
let row = usize::from(y - area.y);
let (line, cell) = if config.word_wrap {
wrapped_cell(buffer, window, config, width, row, column)?
} else {
(window.view.top_line + row, window.view.left_col + column)
};
let line = line.min(buffer.document.last_line());
let display = DisplayLine::new(&buffer.document.line_string(line), config.tab_width);
Some(Position::new(line, display.char_at(cell)))
}
fn wrapped_cell(
buffer: &Buffer,
window: &Window,
config: &Config,
width: usize,
row: usize,
column: usize,
) -> Option<(usize, usize)> {
if width == 0 {
return None;
}
let mut remaining = row;
let mut line = window.view.top_line;
while line <= buffer.document.last_line() {
let display = DisplayLine::new(&buffer.document.line_string(line), config.tab_width);
let chunks = display.wrap(width);
if let Some(&(start, end)) = chunks.get(remaining) {
let last = if end == display.width() { end } else { end - 1 };
return Some((line, (start + column).min(last)));
}
remaining -= chunks.len();
line += 1;
}
Some((buffer.document.last_line(), usize::MAX))
}
#[derive(Debug, Clone, Copy)]
struct Row {
y: u16,
line: usize,
cells: (usize, usize),
}
pub struct EditorView<'a> {
pub buffer: &'a Buffer,
pub window: &'a Window,
pub focused: bool,
pub theme: &'a Theme,
pub config: &'a Config,
pub selections: &'a [Range],
pub search: Option<&'a Search>,
pub active_match: Option<Range>,
}
impl EditorView<'_> {
#[must_use]
pub fn caret_position(&self, area: Rect) -> Option<(u16, u16)> {
let head = self.window.cursor().head;
let view = self.window.view;
let gutter = gutter_width(self.config, self.buffer.document.len_lines());
let width = usize::from(area.width.saturating_sub(gutter));
let height = usize::from(area.height);
let display = DisplayLine::new(
&self.buffer.document.line_string(head.line),
self.config.tab_width,
);
let target = display.column_of(head.col);
let (row, column) = if self.config.word_wrap {
let mut row = 0usize;
for line in view.top_line..head.line {
row += self.rows_of(line, width).1.len();
if row >= height {
return None;
}
}
let chunks = display.wrap(width);
let index = chunks
.iter()
.position(|(start, end)| target >= *start && target < *end)
.unwrap_or(chunks.len() - 1);
(row + index, target - chunks[index].0)
} else {
(
head.line.checked_sub(view.top_line)?,
target.checked_sub(view.left_col)?,
)
};
if row >= height || column >= width {
return None;
}
Some((
area.x + gutter + u16::try_from(column).ok()?,
area.y + u16::try_from(row).ok()?,
))
}
fn render_gutter(&self, surface: &mut Surface, area: Rect, y: u16, line: usize, caret: usize) {
let width = gutter_width(self.config, self.buffer.document.len_lines());
if width == 0 {
return;
}
let is_caret_line = line == caret;
let number = if self.config.relative_line_numbers && !is_caret_line {
line.abs_diff(caret)
} else {
line + 1
};
let style = if is_caret_line {
self.theme.gutter_active
} else {
self.theme.gutter
};
let label = format!("{number:>width$} ", width = usize::from(width) - 1);
for (offset, ch) in label.chars().enumerate() {
let Ok(offset) = u16::try_from(offset) else {
break;
};
if offset >= width {
break;
}
if let Some(cell) = surface.cell_mut((area.x + offset, y)) {
cell.set_char(ch).set_style(style);
}
}
}
fn render_row(&self, surface: &mut Surface, area: Rect, row: Row, caret: usize) {
let Row {
y,
line,
cells: (first_cell, last_cell),
} = row;
let gutter = gutter_width(self.config, self.buffer.document.len_lines());
let x0 = area.x + gutter;
let width = area.width.saturating_sub(gutter);
let base = if line == caret && self.config.highlight_current_line && self.focused {
self.theme.text.patch(self.theme.cursor_line)
} else {
self.theme.text
};
let text = self.buffer.document.line_string(line);
let display = DisplayLine::new(&text, self.config.tab_width);
let line_start = self.buffer.document.line_start(line);
let left = first_cell;
let matches = self
.search
.map(|search| search.matches_in_line(&text))
.unwrap_or_default();
let syntax = if self.config.syntax_highlighting {
self.buffer.syntax.highlight(&self.buffer.document, line)
} else {
Vec::new()
};
for column in 0..width {
let Some(cell) = surface.cell_mut((x0 + column, y)) else {
continue;
};
let index = left + usize::from(column);
match display.cells.get(index).filter(|_| index < last_cell) {
Some(display_cell) => {
let style = self.style_for(
base,
line_start,
display_cell.char_index,
&matches,
&syntax,
);
match display_cell.glyph {
Some(glyph) => {
cell.set_char(glyph).set_style(style);
}
None if column == 0 => {
cell.set_char(' ').set_style(style);
}
None => {
cell.set_symbol("").set_style(style);
}
}
}
None => {
cell.set_char(' ').set_style(base);
}
}
}
}
fn style_for(
&self,
base: Style,
line_start: usize,
column: usize,
matches: &[LineMatch],
syntax: &[Highlight],
) -> Style {
let index = line_start + column;
if self.selections.iter().any(|range| range.contains(index)) {
return base.patch(self.theme.selection);
}
if self.active_match.is_some_and(|range| range.contains(index)) {
return base.patch(self.theme.search_active);
}
if matches
.iter()
.any(|found| column >= found.start && column < found.end)
{
return base.patch(self.theme.search);
}
match syntax
.iter()
.find(|span| column >= span.start && column < span.end)
{
Some(span) => base.patch(self.theme.syntax.style_for(span.kind)),
None => base,
}
}
fn rows_of(&self, line: usize, width: usize) -> (DisplayLine, Vec<(usize, usize)>) {
let display = DisplayLine::new(
&self.buffer.document.line_string(line),
self.config.tab_width,
);
let rows = display.wrap(width);
(display, rows)
}
fn render_filler(&self, surface: &mut Surface, area: Rect, from_row: u16) {
for row in from_row..area.height {
if let Some(cell) = surface.cell_mut((area.x, area.y + row)) {
cell.set_char('~').set_style(self.theme.gutter);
}
}
}
fn render_unwrapped(&self, surface: &mut Surface, area: Rect, caret: usize) {
let top = self.window.view.top_line;
let left = self.window.view.left_col;
for row in 0..area.height {
let line = top + usize::from(row);
if line >= self.buffer.document.len_lines() {
self.render_filler(surface, area, row);
return;
}
let y = area.y + row;
self.render_gutter(surface, area, y, line, caret);
self.render_row(
surface,
area,
Row {
y,
line,
cells: (left, usize::MAX),
},
caret,
);
}
}
fn render_wrapped(&self, surface: &mut Surface, area: Rect, caret: usize) {
let width = usize::from(
area.width
.saturating_sub(gutter_width(self.config, self.buffer.document.len_lines())),
);
let mut row = 0u16;
let mut line = self.window.view.top_line;
while row < area.height {
if line >= self.buffer.document.len_lines() {
self.render_filler(surface, area, row);
return;
}
let (_, chunks) = self.rows_of(line, width);
for (index, (start, end)) in chunks.into_iter().enumerate() {
if row >= area.height {
break;
}
let y = area.y + row;
if index == 0 {
self.render_gutter(surface, area, y, line, caret);
} else {
self.render_gutter_continuation(surface, area, y);
}
self.render_row(
surface,
area,
Row {
y,
line,
cells: (start, end),
},
caret,
);
row += 1;
}
line += 1;
}
}
fn render_gutter_continuation(&self, surface: &mut Surface, area: Rect, y: u16) {
let width = gutter_width(self.config, self.buffer.document.len_lines());
for offset in 0..width {
if let Some(cell) = surface.cell_mut((area.x + offset, y)) {
cell.set_char(if offset + 2 == width { '·' } else { ' ' })
.set_style(self.theme.gutter);
}
}
}
}
impl Widget for EditorView<'_> {
fn render(self, area: Rect, surface: &mut Surface) {
if area.is_empty() {
return;
}
surface.set_style(area, self.theme.text);
let caret = self.window.cursor().head.line;
if self.config.word_wrap {
self.render_wrapped(surface, area, caret);
} else {
self.render_unwrapped(surface, area, caret);
}
}
}