add line number gutter via canvas alongside text editor

This commit is contained in:
jess 2026-04-07 21:06:32 -07:00
parent 1d3c03e23f
commit 9b8333c53f
2 changed files with 136 additions and 21 deletions

View File

@ -11,7 +11,7 @@ acord-core = { path = "../core" }
iced_wgpu = "0.14"
iced_graphics = "0.14"
iced_runtime = "0.14"
iced_widget = { version = "0.14", features = ["wgpu", "markdown"] }
iced_widget = { version = "0.14", features = ["wgpu", "markdown", "canvas"] }
wgpu = "27"
raw-window-handle = "0.6"
pollster = "0.4"

View File

@ -2,13 +2,15 @@ use std::sync::Arc;
use iced_wgpu::core::keyboard;
use iced_wgpu::core::keyboard::key;
use iced_wgpu::core::text::{Highlight, Wrapping};
use iced_wgpu::core::text::{Highlight, LineHeight, Wrapping};
use iced_wgpu::core::{
border, padding, Background, Border, Color, Element, Font, Length, Padding, Shadow, Theme,
border, padding, alignment, Background, Border, Color, Element, Font, Length,
Padding, Pixels, Point, Rectangle, Shadow, Theme,
};
use iced_widget::canvas;
use iced_widget::container;
use iced_widget::markdown;
use iced_widget::text_editor::{self, Binding, KeyPress, Motion, Status, Style};
use iced_widget::text_editor::{self, Action, Binding, KeyPress, Motion, Status, Style};
use iced_wgpu::core::text::highlighter::Format;
use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings};
@ -36,6 +38,7 @@ pub struct EditorState {
pub eval_results: Vec<(usize, String)>,
pub eval_errors: Vec<(usize, String)>,
pub lang: Option<String>,
scroll_offset: f32,
}
fn md_style() -> markdown::Style {
@ -88,11 +91,17 @@ impl EditorState {
eval_results: Vec::new(),
eval_errors: Vec::new(),
lang: Some("rust".into()),
scroll_offset: 0.0,
}
}
fn line_height(&self) -> f32 {
self.font_size * 1.3
}
pub fn set_text(&mut self, text: &str) {
self.content = text_editor::Content::with_text(text);
self.scroll_offset = 0.0;
self.reparse();
}
@ -144,6 +153,13 @@ impl EditorState {
match message {
Message::EditorAction(action) => {
let is_edit = action.is_edit();
if let Action::Scroll { lines } = &action {
let lh = self.line_height();
self.scroll_offset += *lines as f32 * lh;
self.scroll_offset = self.scroll_offset.max(0.0);
let max = (self.content.line_count() as f32 - 1.0) * lh;
self.scroll_offset = self.scroll_offset.min(max.max(0.0));
}
self.content.perform(action);
if is_edit {
self.reparse();
@ -228,12 +244,13 @@ impl EditorState {
})
.into()
} else {
let top_pad = 38.0_f32;
let editor = iced_widget::text_editor(&self.content)
.on_action(Message::EditorAction)
.font(Font::MONOSPACE)
.size(self.font_size)
.height(Length::Fill)
.padding(Padding { top: 38.0, right: 8.0, bottom: 8.0, left: 8.0 })
.padding(Padding { top: top_pad, right: 8.0, bottom: 8.0, left: 8.0 })
.wrapping(Wrapping::Word)
.key_binding(macos_key_binding)
.style(|_theme, _status| Style {
@ -244,23 +261,43 @@ impl EditorState {
selection: Color::from_rgba(0.3, 0.5, 0.8, 0.4),
});
if let Some(lang) = &self.lang {
let settings = SyntaxSettings {
lang: lang.clone(),
source: self.content.text(),
let editor_el: Element<'_, Message, Theme, iced_wgpu::Renderer> =
if let Some(lang) = &self.lang {
let settings = SyntaxSettings {
lang: lang.clone(),
source: self.content.text(),
};
editor
.highlight_with::<SyntaxHighlighter>(
settings,
|highlight, _theme| Format {
color: Some(syntax::highlight_color(highlight.kind)),
font: None,
},
)
.into()
} else {
editor.into()
};
editor
.highlight_with::<SyntaxHighlighter>(
settings,
|highlight, _theme| Format {
color: Some(syntax::highlight_color(highlight.kind)),
font: None,
},
)
.into()
} else {
editor.into()
}
let gutter = Gutter {
line_count: self.content.line_count(),
font_size: self.font_size,
scroll_offset: self.scroll_offset,
cursor_line: self.content.cursor().position.line,
top_pad,
};
let gw = gutter.gutter_width();
let gutter_canvas: Element<'_, Message, Theme, iced_wgpu::Renderer> =
canvas::Canvas::new(gutter)
.width(Length::Fixed(gw))
.height(Length::Fill)
.into();
iced_widget::row![gutter_canvas, editor_el]
.height(Length::Fill)
.into()
};
let mode_label = if self.preview { "Preview" } else { "Edit" };
@ -336,6 +373,84 @@ impl EditorState {
}
}
struct Gutter {
line_count: usize,
font_size: f32,
scroll_offset: f32,
cursor_line: usize,
top_pad: f32,
}
impl Gutter {
fn gutter_width(&self) -> f32 {
let digits = if self.line_count == 0 {
1
} else {
(self.line_count as f32).log10().floor() as usize + 1
};
let char_width = self.font_size * 0.6;
(digits.max(2) as f32 * char_width + 16.0).ceil()
}
}
impl canvas::Program<Message, Theme, iced_wgpu::Renderer> for Gutter {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced_wgpu::Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: iced_wgpu::core::mouse::Cursor,
) -> Vec<canvas::Geometry<iced_wgpu::Renderer>> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
let lh = self.font_size * 1.3;
frame.fill_rectangle(
Point::ORIGIN,
bounds.size(),
Color::from_rgb(0.06, 0.06, 0.08),
);
let first_visible = (self.scroll_offset / lh).floor() as usize;
let sub_pixel = self.scroll_offset - first_visible as f32 * lh;
let visible_count = (bounds.height / lh).ceil() as usize + 1;
let gw = self.gutter_width();
for i in 0..visible_count {
let line_idx = first_visible + i;
if line_idx >= self.line_count {
break;
}
let y = self.top_pad + i as f32 * lh - sub_pixel;
if y + lh < 0.0 || y > bounds.height {
continue;
}
let color = if line_idx == self.cursor_line {
Color::from_rgb(0.55, 0.55, 0.62)
} else {
Color::from_rgb(0.35, 0.35, 0.42)
};
frame.fill_text(canvas::Text {
content: format!("{}", line_idx + 1),
position: Point::new(gw - 8.0, y),
max_width: gw,
color,
size: Pixels(self.font_size),
line_height: LineHeight::Relative(1.3),
font: Font::MONOSPACE,
align_x: iced_wgpu::core::text::Alignment::Right,
align_y: alignment::Vertical::Top,
shaping: iced_wgpu::core::text::Shaping::Basic,
});
}
vec![frame.into_geometry()]
}
}
fn parse_let_binding(line: &str) -> Option<String> {
let rest = line.strip_prefix("let ")?;
let eq_pos = rest.find('=')?;