diff --git a/viewport/Cargo.toml b/viewport/Cargo.toml index 4f0235e..7ef741f 100644 --- a/viewport/Cargo.toml +++ b/viewport/Cargo.toml @@ -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" diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index 1c2bf6a..c8319a3 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -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, + 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(); } @@ -145,6 +154,14 @@ impl EditorState { 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)); + } + let auto_indent = if let text_editor::Action::Edit(text_editor::Edit::Enter) = &action { let cursor = self.content.cursor(); let line_text = self.content.line(cursor.position.line) @@ -178,7 +195,6 @@ impl EditorState { } else { None }; - self.content.perform(action); if let Some(indent) = auto_indent { @@ -286,12 +302,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 { @@ -302,23 +319,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::( + settings, + |highlight, _theme| Format { + color: Some(syntax::highlight_color(highlight.kind)), + font: None, + }, + ) + .into() + } else { + editor.into() }; - editor - .highlight_with::( - 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" }; @@ -394,6 +431,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 for Gutter { + type State = (); + + fn draw( + &self, + _state: &(), + renderer: &iced_wgpu::Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: iced_wgpu::core::mouse::Cursor, + ) -> Vec> { + 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 { let rest = line.strip_prefix("let ")?; let eq_pos = rest.find('=')?;