use crate::core::buffer::{Buffer, Cell};
use crate::core::color::Color;
use crate::core::rect::Rect;
use crate::interaction::{
CopyTransform, HitRegion, InteractionLayer, ScrollRowHit, SelectableSpan, SelectionGroup,
TextRange, WidgetAction, WidgetId, WidgetRole, WidgetValue,
};
use crate::sanitize;
use crate::scroll_state::ScrollState;
use crate::theme::ThemeTokens;
use crate::widgets::Widget;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CodeTheme {
pub bg: Color,
pub gutter_bg: Color,
pub gutter_fg: Color,
pub text_fg: Color,
pub keyword_fg: Color,
pub string_fg: Color,
pub number_fg: Color,
pub comment_fg: Color,
pub punctuation_fg: Color,
pub current_line_bg: Color,
}
impl CodeTheme {
pub const SCRIN: Self = Self {
bg: Color::rgb(12, 17, 27),
gutter_bg: Color::rgb(9, 13, 20),
gutter_fg: Color::rgb(92, 99, 112),
text_fg: Color::rgb(201, 209, 217),
keyword_fg: Color::rgb(255, 123, 114),
string_fg: Color::rgb(165, 214, 255),
number_fg: Color::rgb(121, 192, 255),
comment_fg: Color::rgb(110, 118, 129),
punctuation_fg: Color::rgb(139, 148, 158),
current_line_bg: Color::rgb(20, 28, 43),
};
pub fn from_tokens(tokens: ThemeTokens) -> Self {
Self {
bg: tokens.panel,
gutter_bg: tokens.panel,
gutter_fg: tokens.dim,
text_fg: tokens.text,
keyword_fg: tokens.error,
string_fg: tokens.accent,
number_fg: tokens.warning,
comment_fg: tokens.dim,
punctuation_fg: tokens.dim,
current_line_bg: tokens.panel.brighten(0.08),
}
}
}
impl Default for CodeTheme {
fn default() -> Self {
Self::SCRIN
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CodeCopyMode {
CodeOnly,
FullBlock,
}
#[derive(Debug, Clone)]
pub struct CodeBlock {
pub code: String,
pub language: Option<String>,
pub theme: CodeTheme,
pub show_line_numbers: bool,
pub start_line: usize,
pub scroll: ScrollState,
pub current_line: Option<usize>,
pub pad_left: u16,
pub selectable: bool,
pub copy_mode: CodeCopyMode,
pub region_id: Option<WidgetId>,
pub show_language_header: bool,
}
impl CodeBlock {
pub fn new(code: &str) -> Self {
Self {
code: code.to_string(),
language: None,
theme: CodeTheme::default(),
show_line_numbers: true,
start_line: 1,
scroll: ScrollState::new(),
current_line: None,
pad_left: 1,
selectable: false,
copy_mode: CodeCopyMode::CodeOnly,
region_id: None,
show_language_header: false,
}
}
pub fn with_language(mut self, language: &str) -> Self {
self.language = Some(language.to_string());
self
}
pub fn with_theme(mut self, theme: CodeTheme) -> Self {
self.theme = theme;
self
}
pub fn with_line_numbers(mut self, show: bool) -> Self {
self.show_line_numbers = show;
self
}
pub fn with_start_line(mut self, start: usize) -> Self {
self.start_line = start.max(1);
self
}
pub fn with_scroll(mut self, scroll: ScrollState) -> Self {
self.scroll = scroll;
self
}
pub fn with_current_line(mut self, line: usize) -> Self {
self.current_line = Some(line);
self
}
pub fn with_selectable(mut self, selectable: bool) -> Self {
self.selectable = selectable;
self
}
pub fn with_copy_mode(mut self, mode: CodeCopyMode) -> Self {
self.copy_mode = mode;
self
}
pub fn with_region_id(mut self, id: impl Into<WidgetId>) -> Self {
self.region_id = Some(id.into());
self
}
pub fn with_language_header(mut self, show: bool) -> Self {
self.show_language_header = show;
self
}
pub fn line_count(&self) -> usize {
self.code.lines().count().max(1)
}
pub fn gutter_width(&self) -> usize {
if self.show_line_numbers {
(self.start_line + self.line_count()).to_string().len() + 2
} else {
0
}
}
pub fn code_area(&self, area: Rect) -> Rect {
if self.show_language_header && area.height > 1 {
Rect::new(
area.x,
area.y.saturating_add(1),
area.width,
area.height.saturating_sub(1),
)
} else {
area
}
}
pub fn render_with_interaction(
&self,
buffer: &mut Buffer,
area: Rect,
layer: &mut InteractionLayer,
) {
self.render(buffer, area);
if area.is_empty() {
return;
}
let region_id = self
.region_id
.clone()
.unwrap_or_else(|| WidgetId::new("code"));
let label = self
.language
.as_ref()
.map(|lang| format!("code: {}", lang))
.unwrap_or_else(|| "code".to_string());
let value = self
.language
.as_ref()
.map(|lang| WidgetValue::Language(lang.clone()))
.unwrap_or_else(|| WidgetValue::Count(self.line_count()));
layer.push_region(
HitRegion::new(region_id.clone(), area)
.with_role(WidgetRole::CodeBlock)
.with_label(label.clone())
.with_action(WidgetAction::CopyCode)
.with_value(value),
);
if self.show_language_header && area.height > 0 {
layer.push_region(
HitRegion::new(
format!("{}:header", region_id.as_ref()),
Rect::new(area.x, area.y, area.width, 1),
)
.with_role(WidgetRole::CodeBlock)
.with_label(label)
.with_action(WidgetAction::CopyCode)
.with_z_index(1),
);
}
let code_area = self.code_area(area);
if code_area.is_empty() {
return;
}
let lines: Vec<&str> = if self.code.is_empty() {
vec![""]
} else {
self.code.lines().collect()
};
let source_ranges = line_source_ranges(&self.code);
let mut scroll = self.scroll;
scroll.set_bounds(lines.len(), code_area.height as usize);
let gutter_width = self.gutter_width().min(code_area.width as usize);
let code_x = code_area.x as usize + gutter_width + self.pad_left as usize;
let code_width = (code_area.right() as usize).saturating_sub(code_x);
let selection_group = SelectionGroup::new(format!("{}:code", region_id.as_ref()));
let mut row_hits = Vec::new();
for (screen_row, line_idx) in scroll.visible_range().enumerate() {
let y = code_area.y as usize + screen_row;
if y >= code_area.bottom() as usize {
break;
}
let absolute_line = self.start_line + line_idx;
let row_id = WidgetId::new(format!("{}:line:{}", region_id.as_ref(), absolute_line));
let line_area = Rect::new(code_area.x, y as u16, code_area.width, 1);
layer.push_region(
HitRegion::new(row_id.clone(), line_area)
.with_role(WidgetRole::CodeLine)
.with_label(format!("line {}", absolute_line))
.with_action(WidgetAction::Select)
.with_row(line_idx)
.with_selection_group(selection_group.clone())
.with_value(WidgetValue::LineNumber(absolute_line))
.with_z_index(2),
);
let span_id = format!("{}:span:{}", region_id.as_ref(), absolute_line);
row_hits.push(
ScrollRowHit::new(row_id.clone(), line_idx)
.with_source_line(absolute_line)
.with_span_id(span_id.clone())
.with_item_id(row_id),
);
if !self.selectable || code_width == 0 || code_x >= code_area.right() as usize {
continue;
}
let display = sanitize::sanitize_str(lines[line_idx], code_width);
let width = sanitize::str_display_width(&display).min(code_width);
let screen_area = Rect::new(code_x as u16, y as u16, width as u16, 1);
let source_range = source_ranges.get(line_idx).cloned().unwrap_or(0..0);
let transform = match self.copy_mode {
CodeCopyMode::CodeOnly => CopyTransform::CodeOnly,
CodeCopyMode::FullBlock => CopyTransform::PlainText,
};
layer.push_selectable_span(
SelectableSpan::new(span_id, display.clone(), source_range, screen_area)
.with_source_id(region_id.clone())
.with_group(selection_group.clone())
.with_logical_range(TextRange::new(
line_idx,
0,
sanitize::str_display_width(&display),
))
.with_copy_transform(transform),
);
}
layer.push_scroll_region(region_id, code_area, scroll.offset, row_hits);
}
}
impl Widget for CodeBlock {
fn render(&self, buffer: &mut Buffer, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
buffer.fill(area, ' ', self.theme.text_fg, Some(self.theme.bg));
if self.show_language_header && area.height > 0 {
let label = self
.language
.as_ref()
.map(|lang| format!(" code: {} ", lang))
.unwrap_or_else(|| " code ".to_string());
buffer.set_str(
area.x as usize,
area.y as usize,
&sanitize::truncate_str(&label, area.width as usize),
self.theme.gutter_fg,
Some(self.theme.gutter_bg),
);
}
let area = self.code_area(area);
if area.is_empty() {
return;
}
let lines: Vec<&str> = if self.code.is_empty() {
vec![""]
} else {
self.code.lines().collect()
};
let mut scroll = self.scroll;
scroll.set_bounds(lines.len(), area.height as usize);
let gutter_width = self.gutter_width().min(area.width as usize);
for (screen_row, line_idx) in scroll.visible_range().enumerate() {
let y = area.y as usize + screen_row;
if y >= area.bottom() as usize {
break;
}
let absolute_line = self.start_line + line_idx;
let line_bg = if self.current_line == Some(absolute_line) {
self.theme.current_line_bg
} else {
self.theme.bg
};
for x in area.x as usize..area.right() as usize {
buffer.set(x, y, Cell::new(' ', self.theme.text_fg, Some(line_bg)));
}
if self.show_line_numbers && gutter_width > 0 {
let gutter = format!(
"{:>width$} │",
absolute_line,
width = gutter_width.saturating_sub(2)
);
let gutter = sanitize::truncate_str(&gutter, gutter_width);
for x in
area.x as usize..(area.x as usize + gutter_width).min(area.right() as usize)
{
buffer.set(
x,
y,
Cell::new(' ', self.theme.gutter_fg, Some(self.theme.gutter_bg)),
);
}
buffer.set_str(
area.x as usize,
y,
&gutter,
self.theme.gutter_fg,
Some(self.theme.gutter_bg),
);
}
let code_x = area.x as usize + gutter_width + self.pad_left as usize;
if code_x < area.right() as usize {
let width = area.right() as usize - code_x;
render_syntax_line(
buffer,
code_x,
y,
lines[line_idx],
width,
self.theme,
Some(line_bg),
);
}
}
}
}
fn line_source_ranges(source: &str) -> Vec<std::ops::Range<usize>> {
if source.is_empty() {
return vec![0..0];
}
let mut ranges = Vec::new();
let mut start = 0usize;
for raw in source.split_inclusive('\n') {
let line_len = raw.trim_end_matches(['\r', '\n']).len();
ranges.push(start..start + line_len);
start += raw.len();
}
if ranges.is_empty() {
ranges.push(0..source.len());
}
ranges
}
pub(crate) fn render_syntax_line(
buffer: &mut Buffer,
x: usize,
y: usize,
line: &str,
max_width: usize,
theme: CodeTheme,
bg: Option<Color>,
) {
if max_width == 0 {
return;
}
let line = sanitize::sanitize_str(line, max_width);
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with('#') || trimmed.starts_with("--") {
buffer.set_str(x, y, &line, theme.comment_fg, bg);
return;
}
let mut col = 0usize;
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if col >= max_width {
break;
}
if ch == '"' || ch == '\'' || ch == '`' {
let quote = ch;
let mut token = String::new();
token.push(ch);
for next in chars.by_ref() {
token.push(next);
if next == quote {
break;
}
}
write_colored(
buffer,
x,
y,
&token,
&mut col,
max_width,
theme.string_fg,
bg,
);
} else if ch.is_ascii_digit() {
let mut token = String::new();
token.push(ch);
while let Some(next) = chars.peek().copied() {
if next.is_ascii_digit() || next == '_' || next == '.' {
token.push(next);
chars.next();
} else {
break;
}
}
write_colored(
buffer,
x,
y,
&token,
&mut col,
max_width,
theme.number_fg,
bg,
);
} else if is_ident_start(ch) {
let mut token = String::new();
token.push(ch);
while let Some(next) = chars.peek().copied() {
if is_ident_continue(next) {
token.push(next);
chars.next();
} else {
break;
}
}
let fg = if is_keyword(&token) {
theme.keyword_fg
} else {
theme.text_fg
};
write_colored(buffer, x, y, &token, &mut col, max_width, fg, bg);
} else {
let fg = if ch.is_ascii_punctuation() {
theme.punctuation_fg
} else {
theme.text_fg
};
write_colored(buffer, x, y, &ch.to_string(), &mut col, max_width, fg, bg);
}
}
}
fn write_colored(
buffer: &mut Buffer,
x: usize,
y: usize,
text: &str,
col: &mut usize,
max_width: usize,
fg: Color,
bg: Option<Color>,
) {
for ch in text.chars() {
if *col >= max_width {
break;
}
buffer.set(x + *col, y, Cell::new(ch, fg, bg));
*col += 1;
}
}
fn is_ident_start(ch: char) -> bool {
ch == '_' || ch.is_ascii_alphabetic()
}
fn is_ident_continue(ch: char) -> bool {
ch == '_' || ch.is_ascii_alphanumeric()
}
fn is_keyword(token: &str) -> bool {
matches!(
token,
"as" | "async"
| "await"
| "break"
| "case"
| "class"
| "const"
| "continue"
| "crate"
| "default"
| "else"
| "enum"
| "export"
| "false"
| "fn"
| "for"
| "from"
| "function"
| "if"
| "impl"
| "import"
| "in"
| "let"
| "loop"
| "match"
| "mod"
| "mut"
| "pub"
| "return"
| "self"
| "static"
| "struct"
| "super"
| "trait"
| "true"
| "type"
| "use"
| "where"
| "while"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn code_block_counts_lines() {
let block = CodeBlock::new("fn main() {}\nlet x = 1;");
assert_eq!(block.line_count(), 2);
assert!(block.gutter_width() >= 3);
}
#[test]
fn code_block_renders_backdrop() {
let block = CodeBlock::new("fn main() {\n let x = 1;\n}");
let mut buffer = Buffer::new(40, 5);
block.render(&mut buffer, Rect::new(0, 0, 40, 5));
assert_eq!(
buffer.get(0, 0).unwrap().bg,
Some(CodeTheme::SCRIN.gutter_bg)
);
assert_eq!(buffer.get(8, 0).unwrap().bg, Some(CodeTheme::SCRIN.bg));
}
#[test]
fn code_block_tiny_area_no_panic() {
let block = CodeBlock::new("fn main() {}");
let mut buffer = Buffer::new(2, 1);
block.render(&mut buffer, Rect::new(0, 0, 2, 1));
}
#[test]
fn code_block_registers_code_only_selection_spans() {
let block = CodeBlock::new("fn main() {}\nprintln!(\"hi\");")
.with_language("rust")
.with_selectable(true)
.with_region_id("msg:code:0")
.with_language_header(true);
let mut buffer = Buffer::new(32, 4);
let mut layer = InteractionLayer::new();
block.render_with_interaction(&mut buffer, Rect::new(0, 0, 32, 4), &mut layer);
let group = SelectionGroup::new("msg:code:0:code");
assert_eq!(
layer.plain_text_for_group(&group),
"fn main() {}\nprintln!(\"hi\");"
);
assert!(layer
.hit_test(0, 1)
.is_some_and(|region| region.role == WidgetRole::CodeLine));
assert!(layer
.hit_test(0, 0)
.is_some_and(|region| region.role == WidgetRole::CodeBlock));
}
}