use iced::advanced::clipboard::Kind as ClipboardKind;
use iced::advanced::text::{Alignment, LineHeight, Renderer as _, Shaping, Text, Wrapping};
use iced::advanced::widget::operation::Focusable;
use iced::advanced::widget::{self, Operation, Widget};
use iced::advanced::{layout, mouse, renderer, Clipboard, Layout, Shell};
use iced::alignment::Vertical;
use iced::keyboard::key::Named;
use iced::keyboard::{Event as Keyboard, Key, Modifiers};
use iced::{border, window, Color, Element, Event, Font, Length, Pixels, Point, Rectangle, Shadow, Size, Vector};
use std::cell::Ref;
use std::ops::Range;
use std::time::{Duration, Instant};
use scrive_core::{
display_map, BufferRow, ColumnDir, CompletionKind, Document, FoldMap, Granularity,
HighlightSpan, HoverInfo, Motion, Point as BufPoint, PopupList, RevealMode, RowLayout, Severity,
SignatureInfo, HOVER_IDLE_DELAY_MS,
};
use crate::geo::{Geo, ScrollAnchor, CHIP_PILL_RADIUS, TEXT_PAD};
use crate::popup;
use crate::metrics::Metrics;
type OverviewLanes = (Vec<(u8, u32)>, Vec<Option<u32>>);
const DEFAULT_SIZE: f32 = 14.0;
const LINE_HEIGHT_RATIO: f32 = 1.43;
const GUTTER_PAD: f32 = 8.0;
const FOLD_GAP: f32 = 7.0;
const ARM_DASH: Color = Color::from_rgba8(0xfc, 0xe5, 0x66, 0.38);
const ARM_DASH_ACTIVE: Color = Color::from_rgba8(0xfc, 0xe5, 0x66, 0.85);
const ARM_FILL: Color = Color::from_rgba8(0xfc, 0xe5, 0x66, 0.07);
const ARM_DASH_LEN: f32 = 4.0;
const ARM_DASH_GAP: f32 = 3.0;
const ARM_STROKE: f32 = 1.0;
const ARM_RADIUS: f32 = 4.0;
const CHIP_HOVER_A: f32 = 0.10;
const SHOW_CTRL_COLLAPSE_AFFORDANCE: bool = false;
const TAB: u32 = display_map::default_tab_size();
const WHEEL_ROWS: f32 = 3.0;
const WHEEL_COLS: f32 = 3.0;
const CARET_WIDTH: f32 = 2.0;
const BLINK_MS: u128 = 500;
const LINE_HIGHLIGHT_A: f32 = 0.05;
pub const SCROLLBAR_WIDTH: f32 = 12.0;
const SCROLLBAR_MIN_THUMB: f32 = 24.0;
const SCROLLBAR_MARK_H: f32 = 2.0;
const SCROLLBAR_THUMB: Color = Color::from_rgba8(0x79, 0x79, 0x79, 0.4);
const SCROLLBAR_THUMB_ACTIVE: Color = Color::from_rgba8(0xbf, 0xbf, 0xbf, 0.4);
const VIEWPORT_TOKENIZE_MARGIN: u32 = 8;
const BRACKET_DEPTH: [Color; 3] = [
Color::from_rgb8(0xFF, 0xD7, 0x00), Color::from_rgb8(0xDA, 0x70, 0xD6), Color::from_rgb8(0x17, 0x9F, 0xFF), ];
const UNMATCHED_BRACKET: Color = Color::from_rgba8(0xFF, 0x12, 0x12, 0.8);
const GUIDE_WIDTH: f32 = 1.0;
const GUIDE_IDLE_A: f32 = 0.14;
const GUIDE_ACTIVE_A: f32 = 0.42;
const POPUP_SURFACE: Color = Color::from_rgb8(0x25, 0x25, 0x26);
const POPUP_BORDER: Color = Color::from_rgba8(0xCC, 0xCC, 0xCC, 0.2);
const POPUP_SELECT: Color = Color::from_rgb8(0x37, 0x37, 0x3d);
const CODE_PILL_BG: Color = Color::from_rgba8(0x0a, 0x0a, 0x0a, 0.4);
const HOVER_MAX_VISIBLE: usize = 12;
const HOVER_SB_W: f32 = 6.0;
const FIND_MATCH: Color = Color::from_rgba8(0xfc, 0xe5, 0x66, 0.22);
const FIND_MATCH_ACTIVE: Color = Color::from_rgba8(0xfc, 0xe5, 0x66, 0.48);
const FIND_SCOPE: Color = Color::from_rgba8(0xfc, 0xe5, 0x66, 0.07);
const INDENT_NEIGHBOR_SLACK: u32 = 256;
const FOLD_QUERY_SLACK: u32 = 2048;
const FOLD_BOX_SCAN_ROWS: u32 = 512;
const OCCURRENCE_MATCH: Color = Color::from_rgba8(0xf7, 0xf1, 0xff, 0.09);
const SQUIGGLE_HALF_PERIOD: f32 = 2.0;
const SQUIGGLE_AMPLITUDE: f32 = 1.5;
const SQUIGGLE_STROKE: f32 = 1.0;
fn default_line_height(size: f32) -> f32 {
(size * LINE_HEIGHT_RATIO).round()
}
fn blink_on(elapsed_ms: u128) -> bool {
(elapsed_ms / BLINK_MS).is_multiple_of(2)
}
#[derive(Clone, Debug, PartialEq)]
pub enum Action {
Type(char),
Backspace,
Delete,
DeleteWordBack,
DeleteWordForward,
Enter,
Tab,
Outdent,
ToggleComment,
DeleteLine,
InsertLine {
down: bool,
},
Cut,
Paste {
text: String,
entire_line: bool,
},
Move {
motion: Motion,
extend: bool,
},
PlaceCaret(u32),
DragSelect {
granularity: Granularity,
origin: u32,
head: u32,
},
AddCaret(u32),
AddNextOccurrence,
SelectAllOccurrences,
AddCaretVertical {
down: bool,
},
JumpToBracket,
ExpandSelection,
ShrinkSelection,
Collapse,
SelectAll,
Undo,
Redo,
ColumnSelect(ColumnDir),
ColumnDrag {
anchor: (u32, u32),
active: (u32, u32),
},
MoveLine {
down: bool,
},
CopyLine {
down: bool,
},
ViewportChanged(Range<u32>),
PopupUp,
PopupDown,
PopupAccept,
PopupClickAccept(u32),
PopupDismiss,
SnippetTab,
SnippetTabPrev,
SnippetCancel,
SignatureClose,
NextDiagnostic {
forward: bool,
},
HoverQuery(u32),
HoverDismiss,
ToggleFold {
opener: u32,
},
FoldAtCarets {
unfold: bool,
},
}
impl Action {
fn moves_caret(&self) -> bool {
!matches!(
self,
Action::PlaceCaret(_)
| Action::AddCaret(_)
| Action::Collapse
| Action::SelectAll
| Action::ViewportChanged(_)
| Action::PopupUp
| Action::PopupDown
| Action::PopupAccept
| Action::PopupClickAccept(_)
| Action::PopupDismiss
| Action::SnippetTab
| Action::SnippetTabPrev
| Action::SnippetCancel
| Action::SignatureClose
| Action::HoverQuery(_)
| Action::HoverDismiss
| Action::ToggleFold { .. }
| Action::FoldAtCarets { .. }
| Action::NextDiagnostic { .. }
| Action::JumpToBracket
| Action::ExpandSelection
| Action::ShrinkSelection
| Action::AddCaretVertical { .. }
| Action::SelectAllOccurrences
| Action::AddNextOccurrence
)
}
}
#[derive(Debug, Clone, Copy)]
struct Focus {
updated_at: Instant,
now: Instant,
}
#[derive(Copy, Clone, Debug)]
struct Drag {
granularity: Granularity,
origin: u32,
}
#[derive(Debug)]
struct State {
focus: Option<Focus>,
scroll: ScrollAnchor,
scroll_x: f32,
autoscroll: bool,
modifiers: Modifiers,
drag: Option<Drag>,
column_drag_anchor: Option<(u32, u32)>,
scrollbar_grab: Option<f32>,
hscrollbar_grab: Option<f32>,
last_reported_viewport: Range<u32>,
last_click: Option<mouse::Click>,
metrics: Metrics,
measured_font: Option<Font>,
last_reveal_seq: u64,
hover_pos: Option<Point>,
hover_rearm: bool,
hover_at: Option<Instant>,
hover_scroll: f32,
gutter_hover: bool,
gutter_hover_row: Option<u32>,
fold_preview: Option<u32>,
hover_chip: Option<u32>,
}
impl Default for State {
fn default() -> Self {
let now = Instant::now();
Self {
focus: Some(Focus { updated_at: now, now }),
scroll: ScrollAnchor::TOP,
scroll_x: 0.0,
autoscroll: false,
modifiers: Modifiers::default(),
drag: None,
column_drag_anchor: None,
scrollbar_grab: None,
hscrollbar_grab: None,
last_reported_viewport: u32::MAX..u32::MAX,
last_click: None,
metrics: Metrics { advance: DEFAULT_SIZE * 0.6, line_height: default_line_height(DEFAULT_SIZE), size: DEFAULT_SIZE },
measured_font: None,
last_reveal_seq: 0,
hover_pos: None,
hover_rearm: false,
hover_at: None,
hover_scroll: 0.0,
gutter_hover: false,
gutter_hover_row: None,
fold_preview: None,
hover_chip: None,
}
}
}
impl State {
fn is_focused(&self) -> bool {
self.focus.is_some()
}
fn focus(&mut self) {
let now = Instant::now();
self.focus = Some(Focus { updated_at: now, now });
}
fn unfocus(&mut self) {
self.focus = None;
}
fn ping(&mut self) {
if let Some(focus) = &mut self.focus {
let now = Instant::now();
focus.updated_at = now;
focus.now = now;
}
}
fn caret_on(&self) -> bool {
self.focus
.is_some_and(|f| blink_on(f.now.saturating_duration_since(f.updated_at).as_millis()))
}
}
impl Focusable for State {
fn is_focused(&self) -> bool {
State::is_focused(self)
}
fn focus(&mut self) {
State::focus(self);
}
fn unfocus(&mut self) {
State::unfocus(self);
}
}
#[allow(missing_debug_implementations)]
pub struct Editor<'a, Message> {
id: Option<widget::Id>,
doc: &'a Document,
on_action: Box<dyn Fn(Action) -> Message + 'a>,
popup: Option<&'a PopupList>,
snippet_active: bool,
signature: Option<&'a SignatureInfo>,
hover: Option<&'a HoverInfo>,
font: Font,
size: f32,
line_height: f32,
}
impl<'a, Message> Editor<'a, Message> {
pub fn new(doc: &'a Document, on_action: impl Fn(Action) -> Message + 'a) -> Self {
Self {
id: None,
doc,
on_action: Box::new(on_action),
popup: None,
snippet_active: false,
signature: None,
hover: None,
font: Font::MONOSPACE,
size: DEFAULT_SIZE,
line_height: default_line_height(DEFAULT_SIZE),
}
}
#[must_use]
pub fn popup(mut self, popup: Option<&'a PopupList>) -> Self {
self.popup = popup;
self
}
#[must_use]
pub fn snippet_active(mut self, active: bool) -> Self {
self.snippet_active = active;
self
}
#[must_use]
pub fn signature(mut self, signature: Option<&'a SignatureInfo>) -> Self {
self.signature = signature;
self
}
#[must_use]
pub fn hover(mut self, hover: Option<&'a HoverInfo>) -> Self {
self.hover = hover;
self
}
#[must_use]
pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
self.id = Some(id.into());
self
}
#[must_use]
pub fn font(mut self, font: Font) -> Self {
self.font = font;
self
}
#[must_use]
pub fn text_size(mut self, size: f32) -> Self {
self.size = size;
self.line_height = default_line_height(size);
self
}
#[must_use]
pub fn line_height(mut self, line_height: f32) -> Self {
self.line_height = line_height;
self
}
fn ensure_metrics(&self, state: &mut State) {
let stale = state.measured_font != Some(self.font)
|| state.metrics.size != self.size
|| state.metrics.line_height != self.line_height;
if stale {
state.metrics = Metrics::measure(self.font, self.size, self.line_height);
state.measured_font = Some(self.font);
}
}
fn gutter_width(&self, advance: f32) -> f32 {
let digits = digit_count(self.doc.buffer().line_count()).max(2);
2.0 * GUTTER_PAD + digits as f32 * advance + FOLD_GAP + advance
}
fn gutter_number_right(&self, advance: f32) -> f32 {
let digits = digit_count(self.doc.buffer().line_count()).max(2);
GUTTER_PAD + digits as f32 * advance
}
fn gutter_chevron_x(&self, advance: f32) -> f32 {
self.gutter_number_right(advance) + FOLD_GAP
}
fn offset_screen_x(&self, fold_map: &FoldMap, geo: &Geo, offset: u32) -> f32 {
let cells = fold_map
.display_position(self.doc.buffer(), offset, TAB)
.map_or(0.0, |p| p.x.cells());
geo.cell_x(cells)
}
fn popup_anchor(&self, fold_map: &FoldMap, geo: &Geo, offset: u32) -> (f32, f32, f32) {
let top = match fold_map.display_position(self.doc.buffer(), offset, TAB) {
Some(p) => geo.row_y(p.row),
None => {
let row = self.doc.buffer().offset_to_point(offset).row;
geo.row_y(fold_map.to_display_row(BufferRow(row)))
}
};
(self.offset_screen_x(fold_map, geo, offset), top, top + geo.line_h())
}
fn fold_map(&self) -> Ref<'_, FoldMap> {
self.doc.fold_map()
}
fn any_caret_on_screen(
&self,
buffer: &scrive_core::Buffer,
fold_map: &FoldMap,
top_rows: f64,
viewport_rows: f64,
) -> bool {
let first_buf = fold_map.to_buffer_row(fold_map.display_row_at(top_rows)).0;
let last_buf = fold_map.to_buffer_row(fold_map.display_row_at(top_rows + viewport_rows)).0;
let vis_start = buffer.point_to_offset(BufPoint { row: first_buf, col: 0 });
let vis_end = buffer.point_to_offset(BufPoint { row: last_buf, col: buffer.line_len(last_buf) });
let sels = self.doc.selections().all();
let band = top_rows.floor()..(top_rows + viewport_rows);
sels[visible_selection_span(sels, vis_start, vis_end)].iter().any(|s| {
fold_map
.display_position(buffer, s.head(), TAB)
.is_some_and(|p| band.contains(&f64::from(p.row.index())))
})
}
fn geo(&self, state: &State, bounds: Rectangle) -> Geo {
let Metrics { advance, line_height, .. } = state.metrics;
Geo::new(bounds, self.gutter_width(advance), advance, line_height, state.scroll_x, state.scroll)
}
fn collapsible_box_rect(&self, fold_map: &FoldMap, geo: &Geo, open: u32, close: u32, header: u32, last: u32) -> Option<Rectangle> {
let buffer = self.doc.buffer();
let code_left = geo.code_left();
if fold_map.is_folded(BufferRow(header)) {
return None; }
let y = geo.row_y(fold_map.to_display_row(BufferRow(header)));
if last > header {
let (mut lo, mut hi) = (u32::MAX, 0u32);
for r in header..=last.min(header.saturating_add(FOLD_BOX_SCAN_ROWS)) {
let l = buffer.line(r);
if l.trim().is_empty() {
continue;
}
lo = lo.min(line_indent_cells(&l));
hi = hi.max(fold_map.row_layout(buffer, BufferRow(r), TAB).width());
}
if lo == u32::MAX {
return None;
}
let x0 = (geo.cell_x(lo as f32) - 3.0).max(code_left + 1.0);
let x1 = geo.cell_x(hi as f32) + 3.0;
let yl = geo.row_y(fold_map.to_display_row(BufferRow(last)));
Some(Rectangle { x: x0, y: y - 1.0, width: (x1 - x0).max(geo.advance()), height: (yl + geo.line_h()) - y + 1.0 })
} else {
let xo = self.offset_screen_x(fold_map, geo, open);
let xc = self.offset_screen_x(fold_map, geo, close);
Some(geo.inline_halo(xo.min(xc), xo.max(xc), y))
}
}
fn armed_boxes(&self, geo: &Geo, pos: Point) -> Vec<(u32, u32, Rectangle)> {
let fold_map = self.fold_map();
let display = fold_map.display_row_at(geo.rows_from_top(pos.y));
let pr = fold_map.to_buffer_row(display).0;
self.doc
.collapsible_pairs_in_rows(pr.saturating_sub(FOLD_QUERY_SLACK)..pr + 1)
.into_iter()
.filter(|&(_, _, header, last)| pr >= header && pr <= last) .filter_map(|(open, close, header, last)| {
let rect = self.collapsible_box_rect(&fold_map, geo, open, close, header, last)?;
rect.contains(pos).then_some((open, close, rect))
})
.collect()
}
fn max_scroll_rows(&self, viewport_h: f32, line_height: f32) -> f64 {
(f64::from(self.fold_map().display_row_count())
- f64::from(viewport_h) / f64::from(line_height))
.max(0.0)
}
fn max_line_px(&self, advance: f32, line_h: f32, bounds: Rectangle, scroll_rows: f64) -> f32 {
let buffer = self.doc.buffer();
let fold_map = self.fold_map();
let window = fold_map
.display_window(scroll_rows, scroll_rows + f64::from(bounds.height) / f64::from(line_h));
fold_map
.visible_rows(window)
.map(|vr| match fold_map.header_layout(buffer, vr.buffer_row, TAB) {
Some(hl) => hl.width(),
None => fold_map.row_layout(buffer, vr.buffer_row, TAB).width(),
})
.max()
.unwrap_or(0) as f32
* advance
}
fn code_area_width(&self, bounds: Rectangle, advance: f32, line_h: f32, scroll_rows: f64) -> f32 {
let vsb = if self.scrollbar(bounds, line_h, scroll_rows).is_some() { SCROLLBAR_WIDTH } else { 0.0 };
(bounds.width - self.gutter_width(advance) - TEXT_PAD - vsb).max(0.0)
}
fn max_scroll_x(&self, bounds: Rectangle, advance: f32, line_h: f32, scroll_rows: f64) -> f32 {
(self.max_line_px(advance, line_h, bounds, scroll_rows) + TEXT_PAD
- self.code_area_width(bounds, advance, line_h, scroll_rows))
.max(0.0)
}
fn last_visible_row(&self, bounds: Rectangle, scroll_rows: f64, line_height: f32) -> u32 {
let fold_map = self.fold_map();
let rows = (scroll_rows + f64::from(bounds.height) / f64::from(line_height)).ceil()
+ f64::from(VIEWPORT_TOKENIZE_MARGIN);
fold_map.to_buffer_row(fold_map.display_row_at(rows)).0
}
fn scrollbar(&self, bounds: Rectangle, line_h: f32, scroll_rows: f64) -> Option<Scrollbar> {
let total_rows = f64::from(self.fold_map().display_row_count());
let viewport_rows = f64::from(bounds.height) / f64::from(line_h);
let max_scroll_rows = total_rows - viewport_rows;
if max_scroll_rows <= 0.0 {
return None;
}
let thumb_h = ((viewport_rows / total_rows) as f32 * bounds.height)
.max(SCROLLBAR_MIN_THUMB)
.min(bounds.height);
let thumb_y =
bounds.y + (scroll_rows / max_scroll_rows) as f32 * (bounds.height - thumb_h);
Some(Scrollbar {
x: bounds.x + bounds.width - SCROLLBAR_WIDTH,
track_top: bounds.y,
track_h: bounds.height,
thumb_y,
thumb_h,
max_scroll_rows,
})
}
fn hscrollbar(&self, bounds: Rectangle, advance: f32, line_h: f32, scroll_x: f32, scroll_rows: f64) -> Option<HScrollbar> {
let max_scroll = self.max_scroll_x(bounds, advance, line_h, scroll_rows);
if max_scroll <= 0.0 {
return None;
}
let track_left = bounds.x + self.gutter_width(advance);
let content_right = code_area_right(bounds, self.scrollbar(bounds, line_h, scroll_rows).as_ref());
let track_w = (content_right - track_left).max(0.0);
let code_area_w = self.code_area_width(bounds, advance, line_h, scroll_rows);
let content_w = code_area_w + max_scroll;
let thumb_w = (track_w * code_area_w / content_w).max(SCROLLBAR_MIN_THUMB).min(track_w);
let thumb_x = track_left + (scroll_x / max_scroll) * (track_w - thumb_w);
Some(HScrollbar {
y: bounds.y + bounds.height - SCROLLBAR_WIDTH,
track_left,
track_w,
thumb_x,
thumb_w,
max_scroll,
})
}
}
struct Scrollbar {
x: f32,
track_top: f32,
track_h: f32,
thumb_y: f32,
thumb_h: f32,
max_scroll_rows: f64,
}
impl Scrollbar {
fn contains_x(&self, x: f32) -> bool {
x >= self.x
}
fn thumb_contains_y(&self, y: f32) -> bool {
y >= self.thumb_y && y < self.thumb_y + self.thumb_h
}
fn scroll_rows_for_thumb_top(&self, top: f32) -> f64 {
let travel = self.track_h - self.thumb_h;
if travel <= 0.0 {
return 0.0;
}
(f64::from((top - self.track_top) / travel) * self.max_scroll_rows)
.clamp(0.0, self.max_scroll_rows)
}
}
struct HScrollbar {
y: f32,
track_left: f32,
track_w: f32,
thumb_x: f32,
thumb_w: f32,
max_scroll: f32,
}
impl HScrollbar {
fn contains_y(&self, y: f32) -> bool {
y >= self.y
}
fn thumb_contains_x(&self, x: f32) -> bool {
x >= self.thumb_x && x < self.thumb_x + self.thumb_w
}
fn scroll_for_thumb_left(&self, left: f32) -> f32 {
let travel = self.track_w - self.thumb_w;
if travel <= 0.0 {
return 0.0;
}
((left - self.track_left) / travel * self.max_scroll).clamp(0.0, self.max_scroll)
}
}
fn digit_count(n: u32) -> u32 {
let mut n = n.max(1);
let mut d = 0;
while n > 0 {
d += 1;
n /= 10;
}
d
}
#[must_use]
pub fn default_autoscroll_margin() -> u32 {
3
}
fn reveal_fit(scroll: f64, target_top: f64, target_bottom: f64, viewport: f64, margin: f64) -> f64 {
let m = margin.min(((viewport - (target_bottom - target_top)) / 2.0).max(0.0));
let top = (target_top - m).max(0.0);
let bottom = target_bottom + m;
match (top < scroll, bottom > scroll + viewport) {
(true, false) => top,
(false, true) => bottom - viewport,
_ => scroll, }
}
fn code_area_right(bounds: Rectangle, vscrollbar: Option<&Scrollbar>) -> f32 {
vscrollbar.map_or(bounds.x + bounds.width, |s| s.x)
}
fn visible_selection_span(sels: &[scrive_core::Selection], vis_start: u32, vis_end: u32) -> core::ops::Range<usize> {
let lo = sels.partition_point(|s| s.end() < vis_start);
let hi = sels.partition_point(|s| s.start() <= vis_end);
lo..hi
}
fn range_covered_by(sels: &[scrive_core::Selection], start: u32, end: u32) -> bool {
let i = sels.partition_point(|s| s.start() <= start);
i > 0 && sels[i - 1].end() >= end
}
impl<Message> Widget<Message, iced::Theme, iced::Renderer> for Editor<'_, Message> {
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
_renderer: &iced::Renderer,
operation: &mut dyn Operation,
) {
let state = tree.state.downcast_mut::<State>();
operation.focusable(self.id.as_ref(), layout.bounds(), state);
}
fn size(&self) -> Size<Length> {
Size::new(Length::Fill, Length::Fill)
}
fn layout(
&mut self,
tree: &mut widget::Tree,
_renderer: &iced::Renderer,
limits: &layout::Limits,
) -> layout::Node {
let size = limits.max();
let state = tree.state.downcast_mut::<State>();
self.ensure_metrics(state);
let (advance, line_h) = (state.metrics.advance, state.metrics.line_height);
let vp = Rectangle { x: 0.0, y: 0.0, width: size.width, height: size.height };
let jump = self.doc.reveal_seq() != state.last_reveal_seq;
if jump {
state.last_reveal_seq = self.doc.reveal_seq();
state.autoscroll = true;
}
if state.autoscroll {
let buffer = self.doc.buffer();
let head = self.doc.selections().newest().head();
let fold_map = self.fold_map();
if let Some(p) = fold_map.display_position(buffer, head, TAB) {
let caret_row = f64::from(p.row.index());
let viewport_rows = f64::from(size.height) / f64::from(line_h);
let cur = state.scroll.rows(line_h);
let mode = if jump { self.doc.reveal_mode() } else { RevealMode::Fit };
let fit = || {
reveal_fit(
cur,
caret_row,
caret_row + 1.0,
viewport_rows,
f64::from(default_autoscroll_margin()),
)
};
let rows = match mode {
RevealMode::Center => caret_row + 0.5 - viewport_rows / 2.0,
RevealMode::FitForce => fit(),
RevealMode::Fit if self.any_caret_on_screen(buffer, &fold_map, cur, viewport_rows) => cur,
RevealMode::Fit => fit(),
};
state.scroll = ScrollAnchor::from_rows(rows, line_h);
let cell = p.x.cells();
let code_w = self.code_area_width(vp, advance, line_h, state.scroll.rows(line_h));
state.scroll_x = reveal_fit(
f64::from(state.scroll_x),
f64::from((cell - 1.0).max(0.0) * advance),
f64::from((cell + 2.0) * advance),
f64::from(code_w),
0.0,
) as f32;
}
state.autoscroll = false;
}
state.scroll = ScrollAnchor::from_rows(
state.scroll.rows(line_h).min(self.max_scroll_rows(size.height, line_h)),
line_h,
);
state.scroll_x =
state.scroll_x.clamp(0.0, self.max_scroll_x(vp, advance, line_h, state.scroll.rows(line_h)));
layout::Node::new(size)
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut iced::Renderer,
theme: &iced::Theme,
_style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
_viewport: &Rectangle,
) {
use iced::advanced::Renderer as _; draw_budget::reset();
let state = tree.state.downcast_ref::<State>();
let (advance, line_h) = (state.metrics.advance, state.metrics.line_height);
let bounds = layout.bounds();
let palette = theme.extended_palette();
let geo = self.geo(state, bounds);
let buffer = self.doc.buffer();
let gutter_hover = cursor.position_over(bounds).is_some_and(|p| geo.in_gutter(p.x));
let scroll_x = state.scroll_x;
let sb = self.scrollbar(bounds, line_h, state.scroll.rows(line_h));
let content_right = code_area_right(bounds, sb.as_ref());
let code_left = geo.code_left();
let code_clip = Rectangle {
x: code_left,
y: bounds.y,
width: (content_right - code_left).max(0.0),
height: bounds.height,
};
fill(renderer, bounds, palette.background.base.color);
let text_color = palette.background.base.text;
let dim = palette.background.strong.color;
let selection_color = palette.background.strong.color;
let fold_map = self.fold_map();
let window = fold_map
.display_window(geo.rows_from_top(bounds.y), geo.rows_from_top(bounds.y + bounds.height));
let visible_y = |row: u32| -> Option<f32> {
let br = BufferRow(row);
if fold_map.is_folded(br) {
return None;
}
let dr = fold_map.to_display_row(br);
window.contains(&dr.index()).then(|| geo.row_y(dr))
};
let offset_xy = |off: u32| -> Option<(f32, f32)> {
let p = fold_map.display_position(buffer, off, TAB)?;
window.contains(&p.row.index()).then(|| (geo.cell_x(p.x.cells()), geo.row_y(p.row)))
};
let selections = self.doc.selections();
if selections.len() == 1 && selections.newest().is_empty() {
let row = buffer.offset_to_point(selections.newest().head()).row;
if let Some(y) = visible_y(row) {
let color = Color { a: LINE_HIGHLIGHT_A, ..text_color };
let x = geo.text_left();
fill(renderer, Rectangle { x, y, width: (content_right - x).max(0.0), height: line_h }, color);
}
}
let active_row = buffer.offset_to_point(selections.newest().head()).row;
for vr in fold_map.visible_rows(window.clone()) {
let row = vr.buffer_row.0;
let y = geo.row_y(vr.display_row);
let color = if row == active_row { text_color } else { dim };
self.draw_line(renderer, (row + 1).to_string(), Point::new(bounds.x + self.gutter_number_right(advance), y), color, Alignment::Right, bounds);
}
let hover_chevron_row = cursor
.position_over(bounds)
.filter(|p| geo.in_gutter(p.x))
.map(|p| fold_map.to_buffer_row(fold_map.display_row_at(geo.rows_from_top(p.y))).0);
let first_buf_row = fold_map.to_buffer_row(fold_map.display_row_at(f64::from(window.start))).0;
let last_buf_row = fold_map.to_buffer_row(fold_map.display_row_at(f64::from(window.end))).0;
for &(header, _) in &self.doc.foldable_ranges_in_rows(first_buf_row..last_buf_row + 1) {
let Some(y) = visible_y(header.0) else { continue };
let folded = fold_map.fold_at_header(header).is_some();
if !folded && !gutter_hover {
continue;
}
let glyph = if folded { crate::icon::CHEVRON_RIGHT } else { crate::icon::CHEVRON_DOWN };
let color = if hover_chevron_row == Some(header.0) { text_color } else { dim };
let origin = Point::new(bounds.x + self.gutter_chevron_x(advance), y);
self.draw_icon(renderer, glyph, origin, advance, color, bounds);
}
renderer.start_layer(code_clip);
let line_count = buffer.line_count();
let vis_start = buffer
.point_to_offset(BufPoint { row: fold_map.to_buffer_row(fold_map.display_row_at(f64::from(window.start))).0, col: 0 });
let vis_end = if window.end >= fold_map.display_row_count() {
buffer.len()
} else {
buffer.point_to_offset(BufPoint { row: fold_map.to_buffer_row(fold_map.display_row_at(f64::from(window.end))).0, col: 0 })
};
let sels = self.doc.selections().all();
for sel in &sels[visible_selection_span(sels, vis_start, vis_end)] {
if !sel.is_empty() {
self.draw_selection(renderer, &geo, sel.start(), sel.end(), selection_color);
}
}
if self.doc.find_query().is_none() {
for span in self.doc.caret_word_occurrences(vis_start..vis_end) {
self.draw_selection(renderer, &geo, span.start, span.end, OCCURRENCE_MATCH);
}
}
if let Some(scope) = self.doc.find_scope() {
let (s, e) = (scope.start.max(vis_start), scope.end.min(vis_end));
if s < e {
self.draw_selection(renderer, &geo, s, e, FIND_SCOPE);
}
}
for (span, is_active) in self.doc.find_matches_in(vis_start..vis_end) {
let color = if is_active { FIND_MATCH_ACTIVE } else { FIND_MATCH };
self.draw_selection(renderer, &geo, span.start, span.end, color);
}
let indent_size = TAB;
let indent_col = |row: u32| -> Option<u32> {
let l = buffer.line(row);
(!l.trim().is_empty()).then(|| line_indent_cells(&l))
};
let guide_lo = first_buf_row.saturating_sub(INDENT_NEIGHBOR_SLACK);
let guide_hi = (last_buf_row.saturating_add(INDENT_NEIGHBOR_SLACK)).min(line_count);
let level_at = |row: u32| -> u32 {
match indent_col(row) {
own @ Some(_) => display_map::indent_guide_level(own, None, None, indent_size),
None => {
let above = (guide_lo..row).rev().find_map(&indent_col);
let below = (row + 1..guide_hi).find_map(&indent_col);
display_map::indent_guide_level(None, above, below, indent_size)
}
}
};
let caret_row = buffer.offset_to_point(self.doc.selections().newest().head()).row;
let active =
display_map::active_indent_guide(caret_row, line_count, guide_lo..guide_hi, level_at);
for vr in fold_map.visible_rows(window.clone()) {
let row = vr.buffer_row.0;
let level = level_at(row);
let y = geo.row_y(vr.display_row);
for lvl in 1..=level {
let cell = (lvl - 1) * indent_size;
let gx = geo.cell_x(cell as f32);
let is_active = active.is_some_and(|(ind, s, e)| lvl == ind && row >= s && row <= e);
let alpha = if is_active { GUIDE_ACTIVE_A } else { GUIDE_IDLE_A };
fill(renderer, Rectangle { x: gx, y, width: GUIDE_WIDTH, height: line_h }, Color { a: alpha, ..text_color });
}
}
for vr in fold_map.visible_rows(window.clone()) {
let row = vr.buffer_row.0;
let y = geo.row_y(vr.display_row);
let line = buffer.line(row);
let origin = Point::new(geo.cell_x(0.0), y);
let spans = self.doc.highlight_line_spans(row);
let row_layout = fold_map.row_layout(buffer, BufferRow(row), TAB);
if !row_layout.is_plain() {
self.draw_row_inline(renderer, &line, spans, origin, &geo, &row_layout, dim, text_color, code_clip);
} else {
match spans {
Some(spans) if !spans.is_empty() => {
self.draw_spans(renderer, &line, spans, origin, advance, code_clip);
}
_ if !line.is_empty() => {
self.draw_line(renderer, expand_tabs(&line), origin, text_color, Alignment::Left, code_clip);
}
_ => {}
}
}
if vr.is_fold_header {
let hl = fold_map
.header_layout(buffer, BufferRow(row), TAB)
.expect("is_fold_header rows have a header layout");
let mid = geo.cell_x(hl.gap_center());
let hdr_end = buffer.point_to_offset(BufPoint { row, col: buffer.line_len(row) });
let dots = if self.range_selected(hdr_end, hdr_end + 1) {
text_color
} else {
fill_rounded(renderer, geo.chip_pill(mid, y), POPUP_SELECT, CHIP_PILL_RADIUS);
dim
};
self.draw_line(renderer, "…".to_string(), Point::new(mid, y), dots, Alignment::Center, code_clip);
let last_start = buffer.point_to_offset(BufPoint { row: hl.last_row().0, col: 0 });
for g in hl.tail_glyphs() {
let off = last_start + g.col;
let color = self.doc.brackets().at(off).map_or(
text_color,
|b| {
if b.partner.is_none() {
UNMATCHED_BRACKET
} else {
BRACKET_DEPTH[b.depth as usize % BRACKET_DEPTH.len()]
}
},
);
let x = geo.cell_x(g.cell as f32);
self.draw_line(renderer, g.ch.to_string(), Point::new(x, y), color, Alignment::Left, code_clip);
}
}
}
for vr in fold_map.visible_rows(window.clone()) {
let row = vr.buffer_row.0;
let Some(y) = visible_y(row) else { continue };
let row_start = buffer.point_to_offset(BufPoint { row, col: 0 });
let row_end = if row + 1 < line_count {
buffer.point_to_offset(BufPoint { row: row + 1, col: 0 })
} else {
buffer.len() + 1 };
let row_layout = fold_map.row_layout(buffer, BufferRow(row), TAB);
for br in self.doc.brackets().in_range_iter(row_start..row_end) {
let col = br.offset - row_start;
let Some(ch) = buffer.char_at(br.offset) else { continue };
if row_layout.glyph_hidden(col) {
continue;
}
let x = geo.cell_x(row_layout.display_cell(col) as f32);
let color = if br.partner.is_none() {
UNMATCHED_BRACKET
} else {
BRACKET_DEPTH[br.depth as usize % BRACKET_DEPTH.len()]
};
self.draw_line(renderer, ch.to_string(), Point::new(x, y), color, Alignment::Left, code_clip);
}
}
let match_box = Color { a: 0.45, ..text_color };
if let Some((a, b)) = self.doc.brackets().active_pair(self.doc.selections().newest().head()) {
for off in [a, b] {
let Some((x, y)) = offset_xy(off) else { continue };
fill_border(renderer, Rectangle { x, y, width: advance, height: line_h }, match_box, 1.0);
}
}
let mut diags: Vec<(u32, u32, u32, u32, Severity, Color)> = self
.doc
.diagnostics_in(vis_start..vis_end)
.map(|(span, sev, _msg)| {
let (sp, ep) = (buffer.offset_to_point(span.start), buffer.offset_to_point(span.end));
(span.start, span.end, sp.row, ep.row, sev, severity_color(sev))
})
.collect();
diags.sort_by_key(|&(.., sev, _)| sev);
for vr in fold_map.visible_rows(window.clone()) {
draw_budget::bump_rows(1);
let row = vr.buffer_row.0;
let Some(row_y) = visible_y(row) else { continue };
for &(start, end, sp_row, ep_row, _sev, color) in &diags {
if row < sp_row || row > ep_row {
continue;
}
let row_start = if row == sp_row { start } else { buffer.point_to_offset(BufPoint { row, col: 0 }) };
let row_end = if row == ep_row { end } else { buffer.point_to_offset(BufPoint { row, col: buffer.line_len(row) }) };
if row_start == row_end && start != end {
continue;
}
let x0 = self.offset_screen_x(&fold_map, &geo, row_start);
let x1 = self.offset_screen_x(&fold_map, &geo, row_end).max(x0 + advance);
let baseline = row_y + line_h - SQUIGGLE_AMPLITUDE - 0.5;
squiggle_spans(x0, x1, baseline, |rect| fill(renderer, rect, color));
}
}
#[cfg(debug_assertions)]
{
let vp_rows = u64::from(window.end.saturating_sub(window.start));
debug_assert!(
draw_budget::rows() <= 1024 * (vp_rows + 1),
"draw visited {} rows for a ~{vp_rows}-row viewport — a per-frame path went O(document) (draw budget)",
draw_budget::rows(),
);
}
if SHOW_CTRL_COLLAPSE_AFFORDANCE && state.modifiers.command() {
if let Some(pos) = cursor.position_over(bounds) {
let active = self
.armed_boxes(&geo, pos)
.into_iter()
.min_by_key(|&(o, c, _)| c - o)
.map(|(o, ..)| o);
let first_buf = fold_map.to_buffer_row(fold_map.display_row_at(f64::from(window.start))).0;
let last_buf = fold_map.to_buffer_row(fold_map.display_row_at(f64::from(window.end.saturating_sub(1)))).0;
let mut active_rect = None;
let query = first_buf.saturating_sub(FOLD_QUERY_SLACK)..last_buf + 1;
for (open, close, header, last_row) in self.doc.collapsible_pairs_in_rows(query) {
if last_row < first_buf || header > last_buf {
continue; }
let Some(rect) = self.collapsible_box_rect(&fold_map, &geo, open, close, header, last_row) else {
continue;
};
if active == Some(open) {
active_rect = Some(rect); } else {
stroke_dashed_rounded_rect(renderer, rect, ARM_DASH, ARM_RADIUS, ARM_DASH_LEN, ARM_DASH_GAP, ARM_STROKE);
}
}
if let Some(rect) = active_rect {
fill_rounded(renderer, rect, ARM_FILL, ARM_RADIUS);
stroke_dashed_rounded_rect(renderer, rect, ARM_DASH_ACTIVE, ARM_RADIUS, ARM_DASH_LEN, ARM_DASH_GAP, ARM_STROKE);
}
}
}
if !state.modifiers.command() {
if let Some(opener) = state.hover_chip {
if let Some(pill) = self.chip_pill_rect(&fold_map, &geo, opener) {
fill_rounded(renderer, pill, Color { a: CHIP_HOVER_A, ..text_color }, CHIP_PILL_RADIUS);
}
}
}
if state.caret_on() {
let sels = self.doc.selections().all();
for sel in &sels[visible_selection_span(sels, vis_start, vis_end)] {
if let Some((x, y)) = offset_xy(sel.head()) {
let r = Rectangle { x: x - CARET_WIDTH / 2.0, y: y + 1.0, width: CARET_WIDTH, height: line_h - 2.0 };
fill(renderer, r, text_color);
}
}
}
renderer.end_layer();
renderer.start_layer(bounds);
if let Some(sb) = sb {
let total = fold_map.display_row_count().max(1) as f32;
let mark_span = bounds.height - SCROLLBAR_MARK_H;
let mark_y = |row: u32| bounds.y + (fold_map.to_display_row(BufferRow(row)).index() as f32 / total) * mark_span;
let lane_w = SCROLLBAR_WIDTH / 3.0;
let denom = mark_span.max(1.0);
let max_disp = fold_map.max_display_row().index();
let px_to_offset = |p: f32| -> u32 {
let t = ((p - 0.5 - bounds.y) / denom * total).ceil();
if t <= 0.0 {
return 0; }
let d = t as u32;
if d > max_disp {
return buffer.len().saturating_add(1);
}
let brow = fold_map.to_buffer_row(fold_map.display_row_at(f64::from(d))).0;
buffer.point_to_offset(BufPoint { row: brow, col: 0 })
};
let p_lo = bounds.y.floor() as i32;
let p_hi = (bounds.y + mark_span).ceil() as i32;
let bucket_bounds: Vec<u32> =
(p_lo..=p_hi + 1).map(|p| px_to_offset(p as f32)).collect();
thread_local! {
static OVERVIEW_LANES: std::cell::RefCell<OverviewLanes> =
const { std::cell::RefCell::new((Vec::new(), Vec::new())) };
}
OVERVIEW_LANES.with(|cell| {
let (sev_marks, find_marks) = &mut *cell.borrow_mut();
self.doc.overview_marks(&bucket_bounds, sev_marks, find_marks);
for start in find_marks.iter().flatten() {
let y = mark_y(buffer.offset_to_point(*start).row);
fill(renderer, Rectangle { x: sb.x + lane_w, y, width: lane_w, height: SCROLLBAR_MARK_H }, FIND_MATCH_ACTIVE);
}
for &(enc, off) in sev_marks.iter() {
if enc < 2 {
continue;
}
let sev = match enc {
2 => Severity::Info,
3 => Severity::Warning,
_ => Severity::Error, };
let y = mark_y(buffer.offset_to_point(off).row);
fill(renderer, Rectangle { x: sb.x + 2.0 * lane_w, y, width: lane_w, height: SCROLLBAR_MARK_H }, severity_color(sev));
}
});
let thumb = if state.scrollbar_grab.is_some() { SCROLLBAR_THUMB_ACTIVE } else { SCROLLBAR_THUMB };
fill(renderer, Rectangle { x: sb.x, y: sb.thumb_y, width: SCROLLBAR_WIDTH, height: sb.thumb_h }, thumb);
}
if let Some(hb) = self.hscrollbar(bounds, advance, line_h, scroll_x, state.scroll.rows(line_h)) {
let thumb = if state.hscrollbar_grab.is_some() { SCROLLBAR_THUMB_ACTIVE } else { SCROLLBAR_THUMB };
fill(renderer, Rectangle { x: hb.thumb_x, y: hb.y, width: hb.thumb_w, height: SCROLLBAR_WIDTH }, thumb);
}
if let Some(info) = self.hover {
self.draw_hover(renderer, info, &geo, text_color, state.hover_scroll);
}
if let Some(sig) = self.signature {
self.draw_signature(renderer, sig, &geo, text_color);
}
if let Some(opener) = state.fold_preview {
self.draw_fold_preview(renderer, &geo, opener, text_color);
}
if let Some(list) = self.popup.filter(|l| !l.filtered.is_empty()) {
self.draw_popup(renderer, list, &geo, text_color);
}
renderer.end_layer();
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &iced::Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
_viewport: &Rectangle,
) {
let bounds = layout.bounds();
let state = tree.state.downcast_mut::<State>();
self.ensure_metrics(state);
let (advance, line_h) = (state.metrics.advance, state.metrics.line_height);
let last_visible = self.last_visible_row(bounds, state.scroll.rows(line_h), line_h);
let first_visible = {
let fm = self.fold_map();
fm.to_buffer_row(fm.display_row_at(state.scroll.rows(line_h))).0
}
.saturating_sub(VIEWPORT_TOKENIZE_MARGIN);
let visible = first_visible..last_visible + 1;
if visible != state.last_reported_viewport {
state.last_reported_viewport = visible.clone();
shell.publish((self.on_action)(Action::ViewportChanged(visible)));
}
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
let Some(pos) = cursor.position_over(bounds) else {
state.unfocus();
return;
};
state.focus();
state.fold_preview = None; let geo = self.geo(state, bounds);
if let Some(list) = self.popup.filter(|l| !l.filtered.is_empty()) {
let (origin, sz) = self.popup_layout(list, &geo);
if (Rectangle { x: origin.x, y: origin.y, width: sz.width, height: sz.height }).contains(pos) {
let n = list.filtered.len();
let visible = n.min(popup::POPUP_MAX_VISIBLE);
if let Some(row) = popup::row_at(origin.y, pos.y, line_h, visible) {
let idx = popup::window_start(list.selected as usize, n) + row;
shell.publish((self.on_action)(Action::PopupClickAccept(idx as u32)));
}
shell.capture_event();
return;
}
}
if let Some(sb) = self.scrollbar(bounds, line_h, state.scroll.rows(line_h)) {
if sb.contains_x(pos.x) {
let grab = if sb.thumb_contains_y(pos.y) {
pos.y - sb.thumb_y
} else {
let half = sb.thumb_h / 2.0;
state.scroll = ScrollAnchor::from_rows(sb.scroll_rows_for_thumb_top(pos.y - half), line_h);
half
};
state.scrollbar_grab = Some(grab);
shell.request_redraw();
shell.capture_event();
return;
}
}
if let Some(hb) = self.hscrollbar(bounds, advance, line_h, state.scroll_x, state.scroll.rows(line_h)) {
if hb.contains_y(pos.y) {
let grab = if hb.thumb_contains_x(pos.x) {
pos.x - hb.thumb_x
} else {
let half = hb.thumb_w / 2.0;
state.scroll_x = hb.scroll_for_thumb_left(pos.x - half);
half
};
state.hscrollbar_grab = Some(grab);
shell.request_redraw();
shell.capture_event();
return;
}
}
if geo.in_gutter(pos.x) {
let fold_map = self.fold_map();
let display = fold_map.display_row_at(geo.rows_from_top(pos.y));
let row = fold_map.to_buffer_row(display).0;
if let Some(opener) = self.doc.block_opener_on_row(row) {
shell.publish((self.on_action)(Action::ToggleFold { opener }));
shell.capture_event();
return;
}
}
if !state.modifiers.command() && !state.modifiers.shift() && !state.modifiers.alt() {
if let Some(opener) = self.collapsed_chip_at(&geo, pos) {
shell.publish((self.on_action)(Action::ToggleFold { opener }));
shell.capture_event();
return;
}
}
if SHOW_CTRL_COLLAPSE_AFFORDANCE && state.modifiers.command() && !state.modifiers.shift() && !state.modifiers.alt() {
let target = self
.armed_boxes(&geo, pos)
.into_iter()
.min_by_key(|&(o, c, _)| c - o)
.map(|(o, ..)| o);
if let Some(opener) = target {
shell.publish((self.on_action)(Action::ToggleFold { opener }));
shell.capture_event();
return;
}
}
let offset = self.hit_test(&geo, pos);
if state.modifiers.alt() && state.modifiers.shift() {
let anchor = self.hit_cell(&geo, pos);
state.column_drag_anchor = Some(anchor);
shell.publish((self.on_action)(Action::ColumnDrag { anchor, active: anchor }));
} else if state.modifiers.alt() {
shell.publish((self.on_action)(Action::AddCaret(offset)));
} else if state.modifiers.shift() {
let origin = self.doc.selections().newest().tail();
state.drag = Some(Drag { granularity: Granularity::Char, origin });
shell.publish((self.on_action)(Action::DragSelect {
granularity: Granularity::Char,
origin,
head: offset,
}));
} else {
let click = mouse::Click::new(pos, mouse::Button::Left, state.last_click);
state.last_click = Some(click);
let granularity = match click.kind() {
mouse::click::Kind::Single => Granularity::Char,
mouse::click::Kind::Double => Granularity::Word,
mouse::click::Kind::Triple => Granularity::Line,
};
state.drag = Some(Drag { granularity, origin: offset });
let action = if granularity == Granularity::Char {
Action::PlaceCaret(offset)
} else {
Action::DragSelect { granularity, origin: offset, head: offset }
};
shell.publish((self.on_action)(action));
}
shell.capture_event();
}
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
let geo = self.geo(state, bounds);
let over_gutter = cursor.position_over(bounds).is_some_and(|p| geo.in_gutter(p.x));
if over_gutter != state.gutter_hover {
state.gutter_hover = over_gutter;
shell.request_redraw();
}
if SHOW_CTRL_COLLAPSE_AFFORDANCE && state.modifiers.command() {
shell.request_redraw();
}
if state.fold_preview.take().is_some() {
shell.request_redraw();
}
let chip = (!state.modifiers.command())
.then(|| cursor.position_over(bounds).and_then(|p| self.collapsed_chip_at(&geo, p)))
.flatten();
if chip != state.hover_chip {
state.hover_chip = chip;
shell.request_redraw();
}
let gutter_row = cursor.position_over(bounds).filter(|p| geo.in_gutter(p.x)).map(|p| {
let fm = self.fold_map();
fm.to_buffer_row(fm.display_row_at(geo.rows_from_top(p.y))).0
});
if gutter_row != state.gutter_hover_row {
state.gutter_hover_row = gutter_row;
shell.request_redraw();
}
if let Some(grab) = state.scrollbar_grab {
if let (Some(sb), Some(pos)) =
(self.scrollbar(bounds, line_h, state.scroll.rows(line_h)), cursor.position())
{
state.scroll = ScrollAnchor::from_rows(sb.scroll_rows_for_thumb_top(pos.y - grab), line_h);
shell.request_redraw();
}
shell.capture_event();
} else if let Some(grab) = state.hscrollbar_grab {
if let (Some(hb), Some(pos)) = (
self.hscrollbar(bounds, advance, line_h, state.scroll_x, state.scroll.rows(line_h)),
cursor.position(),
) {
state.scroll_x = hb.scroll_for_thumb_left(pos.x - grab);
shell.request_redraw();
}
shell.capture_event();
} else if let (Some(anchor), Some(pos)) = (state.column_drag_anchor, cursor.position()) {
let active = self.hit_cell(&geo, pos);
state.autoscroll = true;
shell.publish((self.on_action)(Action::ColumnDrag { anchor, active }));
shell.capture_event();
} else if let (Some(drag), Some(pos)) = (state.drag, cursor.position()) {
let head = self.hit_test(&geo, pos);
state.autoscroll = true;
shell.publish((self.on_action)(Action::DragSelect {
granularity: drag.granularity,
origin: drag.origin,
head,
}));
shell.capture_event();
} else if let Some(pos) =
cursor.position_over(bounds).filter(|p| !geo.in_gutter(p.x))
{
let off = self.hit_test(&geo, pos);
let still_in = self.hover.is_some_and(|h| {
(off >= h.range.start && off < h.range.end)
|| self.hover_layout(h, &geo).rect.contains(pos)
});
if !still_in {
if self.hover.is_some() {
shell.publish((self.on_action)(Action::HoverDismiss));
}
state.hover_pos = Some(pos);
state.hover_rearm = true;
state.hover_scroll = 0.0; shell.request_redraw();
}
} else {
state.hover_pos = None;
state.hover_at = None;
state.hover_scroll = 0.0;
if self.hover.is_some() {
shell.publish((self.on_action)(Action::HoverDismiss));
}
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let ended_drag = state.drag.take().is_some();
let ended_box = state.column_drag_anchor.take().is_some();
let ended_sb = state.scrollbar_grab.take().is_some();
let ended_hsb = state.hscrollbar_grab.take().is_some();
if ended_drag || ended_box || ended_sb || ended_hsb {
shell.capture_event();
}
}
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
let geo = self.geo(state, bounds);
if let (Some(list), Some(pos)) =
(self.popup.filter(|l| !l.filtered.is_empty()), cursor.position_over(bounds))
{
let (origin, sz) = self.popup_layout(list, &geo);
if (Rectangle { x: origin.x, y: origin.y, width: sz.width, height: sz.height }).contains(pos) {
let up = matches!(delta, mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } if *y > 0.0);
shell.publish((self.on_action)(if up { Action::PopupUp } else { Action::PopupDown }));
shell.capture_event();
return;
}
}
if let (Some(info), Some(pos)) = (self.hover, cursor.position_over(bounds)) {
let l = self.hover_layout(info, &geo);
if l.overflow && l.rect.contains(pos) {
let dy = match delta {
mouse::ScrollDelta::Lines { y, .. } => y * line_h,
mouse::ScrollDelta::Pixels { y, .. } => *y,
};
let raw = (state.hover_scroll - dy).clamp(0.0, l.max_scroll);
state.hover_scroll = (raw / line_h).round() * line_h;
shell.request_redraw();
shell.capture_event();
return;
}
}
if state.scrollbar_grab.is_some()
|| state.hscrollbar_grab.is_some()
|| cursor.position_over(bounds).is_none()
{
return;
}
let (dx, dy) = match delta {
mouse::ScrollDelta::Lines { x, y } => (x * advance * WHEEL_COLS, y * line_h * WHEEL_ROWS),
mouse::ScrollDelta::Pixels { x, y } => (*x, *y),
};
let (dx, dy) = if state.modifiers.shift() { (dy, 0.0) } else { (dx, dy) };
if dx.abs() > dy.abs() {
let max_x = self.max_scroll_x(bounds, advance, line_h, state.scroll.rows(line_h));
state.scroll_x = (state.scroll_x - dx).clamp(0.0, max_x);
} else {
let max_rows = self.max_scroll_rows(bounds.height, line_h);
state.scroll = ScrollAnchor::from_rows(
(state.scroll.rows(line_h) - f64::from(dy) / f64::from(line_h))
.min(max_rows),
line_h,
);
}
shell.request_redraw();
shell.capture_event();
}
Event::Keyboard(Keyboard::KeyPressed { key, text, modifiers, physical_key, .. }) => {
if !state.is_focused() {
return;
}
if !modifiers.command() && !modifiers.control() && !modifiers.alt() {
if let Some(ch) = numpad_text(physical_key, text.as_deref()) {
state.autoscroll = true;
state.ping();
shell.publish((self.on_action)(Action::Type(ch)));
shell.capture_event();
return;
}
}
if self.popup.is_some() {
let plain = !modifiers.command() && !modifiers.alt();
let popup_action = match key {
Key::Named(Named::ArrowUp) if plain => Some(Action::PopupUp),
Key::Named(Named::ArrowDown) if plain => Some(Action::PopupDown),
Key::Named(Named::Enter | Named::Tab) if plain => Some(Action::PopupAccept),
Key::Named(Named::Escape) => Some(Action::PopupDismiss),
_ => None,
};
if let Some(action) = popup_action {
state.ping();
shell.publish((self.on_action)(action));
shell.capture_event();
return;
}
}
if self.signature.is_some() && matches!(key, Key::Named(Named::Escape)) {
state.ping();
shell.publish((self.on_action)(Action::SignatureClose));
shell.capture_event();
return;
}
if self.snippet_active {
let snip = match key {
Key::Named(Named::Tab) if modifiers.shift() => Some(Action::SnippetTabPrev),
Key::Named(Named::Tab) => Some(Action::SnippetTab),
Key::Named(Named::Escape) => Some(Action::SnippetCancel),
_ => None,
};
if let Some(action) = snip {
state.ping();
shell.publish((self.on_action)(action));
shell.capture_event();
return;
}
}
if modifiers.command() && !modifiers.alt() {
if let Key::Character(c) = key {
match c.as_str() {
"c" => {
self.write_clipboard(clipboard);
shell.capture_event();
return;
}
"x" => {
self.write_clipboard(clipboard);
state.autoscroll = true;
state.ping();
shell.publish((self.on_action)(Action::Cut));
shell.capture_event();
return;
}
"v" => {
if let Some(pasted) = clipboard.read(ClipboardKind::Standard) {
state.autoscroll = true;
state.ping();
let entire_line = crate::clipboard::is_entire_line(&pasted);
shell.publish((self.on_action)(Action::Paste {
text: pasted,
entire_line,
}));
}
shell.capture_event();
return;
}
_ => {}
}
}
}
if modifiers.command() && !modifiers.alt() {
if let Key::Character(c) = key {
let unfold = matches!(c.as_str(), "]" | "}");
if unfold || matches!(c.as_str(), "[" | "{") {
shell.publish((self.on_action)(Action::FoldAtCarets { unfold }));
shell.capture_event();
return;
}
}
}
if let Key::Named(named @ (Named::PageUp | Named::PageDown)) = key {
let page = ((bounds.height / line_h).floor() as u32).max(1);
let motion = if *named == Named::PageUp {
Motion::PageUp(page)
} else {
Motion::PageDown(page)
};
state.autoscroll = true;
state.ping();
shell.publish((self.on_action)(Action::Move { motion, extend: modifiers.shift() }));
shell.capture_event();
return;
}
if let Some(action) = interpret_key(key, text.as_deref(), *modifiers) {
if action.moves_caret() {
state.autoscroll = true;
}
state.ping();
shell.publish((self.on_action)(action));
shell.capture_event();
}
}
Event::Keyboard(Keyboard::ModifiersChanged(mods)) => {
if SHOW_CTRL_COLLAPSE_AFFORDANCE && mods.command() != state.modifiers.command() {
shell.request_redraw();
}
state.modifiers = *mods;
}
Event::Window(window::Event::Unfocused) => {
state.drag = None;
state.column_drag_anchor = None;
state.scrollbar_grab = None;
state.hscrollbar_grab = None;
}
Event::Window(window::Event::RedrawRequested(now)) => {
if let Some(focus) = &mut state.focus {
focus.now = *now;
let elapsed = now.saturating_duration_since(focus.updated_at).as_millis();
let until = BLINK_MS - elapsed % BLINK_MS;
shell.request_redraw_at(*now + Duration::from_millis(until as u64));
}
if state.is_focused() {
if state.hover_rearm {
state.hover_rearm = false;
let at = *now + Duration::from_millis(HOVER_IDLE_DELAY_MS);
state.hover_at = Some(at);
shell.request_redraw_at(at);
}
if state.hover_at.is_some_and(|at| *now >= at) {
state.hover_at = None;
if let Some(pos) = state.hover_pos {
let geo = self.geo(state, bounds);
if let Some(opener) = self.collapsed_chip_at(&geo, pos) {
if state.fold_preview != Some(opener) {
state.fold_preview = Some(opener);
shell.request_redraw();
}
if self.hover.is_some() {
shell.publish((self.on_action)(Action::HoverDismiss));
}
} else {
let off = self.hit_test(&geo, pos);
shell.publish((self.on_action)(Action::HoverQuery(off)));
}
}
}
} else {
state.hover_at = None;
state.hover_rearm = false;
}
}
_ => {}
}
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
_viewport: &Rectangle,
_renderer: &iced::Renderer,
) -> mouse::Interaction {
let state = tree.state.downcast_ref::<State>();
let advance = state.metrics.advance;
let bounds = layout.bounds();
if state.scrollbar_grab.is_some() || state.hscrollbar_grab.is_some() {
return mouse::Interaction::default();
}
let line_h = state.metrics.line_height;
let geo = self.geo(state, bounds);
if SHOW_CTRL_COLLAPSE_AFFORDANCE && state.modifiers.command() {
if let Some(p) = cursor.position_over(bounds).filter(|p| !geo.in_gutter(p.x)) {
if !self.armed_boxes(&geo, p).is_empty() {
return mouse::Interaction::Pointer;
}
}
}
if !state.modifiers.command() {
if let Some(p) = cursor.position_over(bounds).filter(|p| !geo.in_gutter(p.x)) {
if self.collapsed_chip_at(&geo, p).is_some() {
return mouse::Interaction::Pointer;
}
}
}
match cursor.position_over(bounds) {
Some(p) if geo.in_gutter(p.x) => {
let fold_map = self.fold_map();
let display = fold_map.display_row_at(geo.rows_from_top(p.y));
let row = fold_map.to_buffer_row(display).0;
if self.doc.foldable_ranges_in_rows(row..row + 1).iter().any(|(h, _)| h.0 == row) {
mouse::Interaction::Pointer
} else {
mouse::Interaction::default()
}
}
Some(p)
if !geo.in_gutter(p.x)
&& !self.scrollbar(bounds, line_h, state.scroll.rows(line_h)).is_some_and(|sb| sb.contains_x(p.x))
&& !self
.hscrollbar(bounds, advance, line_h, state.scroll_x, state.scroll.rows(line_h))
.is_some_and(|hb| hb.contains_y(p.y)) =>
{
mouse::Interaction::Text
}
_ => mouse::Interaction::default(),
}
}
}
#[cfg(any(test, debug_assertions))]
pub(crate) mod draw_budget {
use std::sync::atomic::{AtomicU64, Ordering};
static ROWS: AtomicU64 = AtomicU64::new(0);
pub(crate) fn reset() {
ROWS.store(0, Ordering::Relaxed);
}
pub(crate) fn bump_rows(n: u64) {
ROWS.fetch_add(n, Ordering::Relaxed);
}
pub(crate) fn rows() -> u64 {
ROWS.load(Ordering::Relaxed)
}
}
#[cfg(not(any(test, debug_assertions)))]
pub(crate) mod draw_budget {
#[inline(always)]
pub(crate) fn reset() {}
#[inline(always)]
pub(crate) fn bump_rows(_n: u64) {}
}
impl<Message> Editor<'_, Message> {
fn popup_layout(&self, list: &PopupList, geo: &Geo) -> (Point, Size) {
let fold_map = self.fold_map();
let (anchor_x, row_top, row_bottom) = self.popup_anchor(&fold_map, geo, list.anchor);
let size = popup::extent(list, geo.advance(), geo.line_h());
let origin = popup::place(anchor_x, row_top, row_bottom, size, geo.bounds());
(origin, size)
}
fn hover_layout(&self, info: &HoverInfo, geo: &Geo) -> HoverLayout {
let (bounds, advance, line_h) = (geo.bounds(), geo.advance(), geo.line_h());
let pad_x = 8.0_f32; let pad_y = popup::POPUP_PAD;
let md_lines: Vec<Vec<(String, MdStyle)>> = info.markdown.lines().map(parse_md_runs).collect();
let vis_len = |runs: &[(String, MdStyle)]| runs.iter().map(|(t, _)| t.chars().count()).sum::<usize>();
let cells = md_lines.iter().map(|r| vis_len(r)).max().unwrap_or(0).clamp(popup::POPUP_MIN_CH, popup::POPUP_MAX_CH);
let lines: Vec<Vec<(String, MdStyle)>> = md_lines.iter().flat_map(|r| wrap_runs(r, cells)).collect();
let total = lines.len().max(1);
let visible = total.min(HOVER_MAX_VISIBLE);
let overflow = total > visible;
let inner_w = cells as f32 * advance;
let sb_w = if overflow { HOVER_SB_W } else { 0.0 };
let width = inner_w + 2.0 * pad_x + sb_w;
let height = visible as f32 * line_h + 2.0 * pad_y;
let fold_map = self.fold_map();
let (word_x, word_top, word_bottom) = self.popup_anchor(&fold_map, geo, info.range.start);
let y = if word_top - height >= bounds.y { word_top - height } else { word_bottom };
let x = word_x.clamp(bounds.x, (bounds.x + bounds.width - width).max(bounds.x));
HoverLayout {
rect: Rectangle { x, y, width, height },
lines,
visible,
max_scroll: (total - visible) as f32 * line_h,
pad_x,
pad_y,
overflow,
}
}
fn draw_hover(
&self,
renderer: &mut iced::Renderer,
info: &HoverInfo,
geo: &Geo,
text_color: Color,
hover_scroll: f32,
) {
let (advance, line_h) = (geo.advance(), geo.line_h());
let l = self.hover_layout(info, geo);
fill_panel(renderer, l.rect, POPUP_SURFACE, POPUP_BORDER, 8.0);
let scroll = hover_scroll.clamp(0.0, l.max_scroll);
let first = (scroll / line_h).round() as usize;
for (vi, line) in l.lines.iter().skip(first).take(l.visible).enumerate() {
let ly = l.rect.y + l.pad_y + vi as f32 * line_h;
let mut col = 0usize;
for (run, st) in line {
let rx = l.rect.x + l.pad_x + col as f32 * advance;
let n = run.chars().count();
if *st == MdStyle::Code {
let pill = Rectangle { x: rx - 2.0, y: ly + 1.0, width: n as f32 * advance + 4.0, height: line_h - 2.0 };
fill_rounded(renderer, pill, CODE_PILL_BG, 3.0);
}
let font = if *st == MdStyle::Bold {
Font { weight: iced::font::Weight::Bold, ..self.font }
} else {
self.font
};
self.draw_run(renderer, run.clone(), Point::new(rx, ly), text_color, font, l.rect);
col += n;
}
}
if l.overflow {
let track_h = l.visible as f32 * line_h;
let thumb_h = (l.visible as f32 / l.lines.len() as f32 * track_h).max(HOVER_SB_W);
let frac = if l.max_scroll > 0.0 { scroll / l.max_scroll } else { 0.0 };
let thumb_y = l.rect.y + l.pad_y + frac * (track_h - thumb_h);
let sbx = l.rect.x + l.rect.width - HOVER_SB_W - 1.0;
fill_rounded(renderer, Rectangle { x: sbx, y: thumb_y, width: HOVER_SB_W, height: thumb_h }, SCROLLBAR_THUMB, HOVER_SB_W / 2.0);
}
}
#[allow(clippy::too_many_arguments)]
fn draw_signature(
&self,
renderer: &mut iced::Renderer,
sig: &SignatureInfo,
geo: &Geo,
text_color: Color,
) {
let (bounds, advance, line_h) = (geo.bounds(), geo.advance(), geo.line_h());
let fold_map = self.fold_map();
let head = self.doc.selections().newest().head();
let (caret_x, caret_top, caret_bottom) = self.popup_anchor(&fold_map, geo, head);
let dim = Color { a: 0.7, ..text_color };
let active = Color::from_rgb8(0x66, 0xd9, 0xef);
let label_cells = sig.label.chars().count();
let doc_cells = sig.doc.as_ref().map_or(0, |d| d.chars().count());
let cells = label_cells.max(doc_cells).clamp(popup::POPUP_MIN_CH, popup::POPUP_MAX_CH);
let width = cells as f32 * advance + 2.0 * popup::POPUP_PAD;
let rows = if sig.doc.is_some() { 2 } else { 1 };
let height = rows as f32 * line_h + 2.0 * popup::POPUP_PAD;
let y = if caret_top - height >= bounds.y { caret_top - height } else { caret_bottom };
let x = caret_x.clamp(bounds.x, (bounds.x + bounds.width - width).max(bounds.x));
let rect = Rectangle { x, y, width, height };
fill_panel(renderer, rect, POPUP_SURFACE, POPUP_BORDER, 8.0);
let tx = x + popup::POPUP_PAD;
let ty = y + popup::POPUP_PAD;
self.draw_line(renderer, sig.label.clone(), Point::new(tx, ty), dim, Alignment::Left, rect);
if let Some(range) = sig.active_param() {
let (s, e) = (range.start as usize, range.end as usize);
if let Some(sub) = sig.label.get(s..e) {
let prefix = sig.label[..s].chars().count();
let px = tx + prefix as f32 * advance;
self.draw_line(renderer, sub.to_string(), Point::new(px, ty), active, Alignment::Left, rect);
}
}
if let Some(doc) = &sig.doc {
let dy = ty + line_h;
self.draw_line(renderer, doc.clone(), Point::new(tx, dy), Color { a: 0.5, ..text_color }, Alignment::Left, rect);
}
}
fn draw_popup(&self, renderer: &mut iced::Renderer, list: &PopupList, geo: &Geo, text_color: Color) {
let line_h = geo.line_h();
let (origin, size) = self.popup_layout(list, geo);
let dim = Color { a: 0.6, ..text_color };
let rect = Rectangle { x: origin.x, y: origin.y, width: size.width, height: size.height };
fill_panel(renderer, rect, POPUP_SURFACE, POPUP_BORDER, 8.0);
let n = list.filtered.len();
let visible = n.min(popup::POPUP_MAX_VISIBLE);
let start = popup::window_start(list.selected as usize, n);
let row_x = origin.x + popup::POPUP_PAD;
let right = origin.x + size.width - popup::POPUP_PAD;
for (vis, &fi) in list.filtered[start..start + visible].iter().enumerate() {
let item = &list.items[fi as usize];
let y = popup::row_y(origin.y, vis, line_h);
if start + vis == list.selected as usize {
fill(renderer, Rectangle { x: origin.x, y, width: size.width, height: line_h }, POPUP_SELECT);
}
self.draw_line(renderer, item.label.clone(), Point::new(row_x, y), kind_color(item.kind), Alignment::Left, rect);
if let Some(detail) = &item.detail {
self.draw_line(renderer, detail.clone(), Point::new(right, y), dim, Alignment::Right, rect);
}
}
let doc_lines = popup::doc_lines(list, popup::width_cells(list));
if doc_lines > 0 {
if let Some(doc) = popup::selected_doc(list) {
let y = popup::row_y(origin.y, visible, line_h); fill(renderer, Rectangle { x: origin.x, y, width: size.width, height: 1.0 }, POPUP_BORDER);
renderer.fill_text(
Text {
content: doc.to_string(),
bounds: Size::new(size.width - 2.0 * popup::POPUP_PAD, doc_lines as f32 * line_h),
size: Pixels(self.size),
line_height: LineHeight::Absolute(Pixels(line_h)),
font: self.font,
align_x: Alignment::Left,
align_y: Vertical::Top,
shaping: Shaping::Basic,
wrapping: Wrapping::Word,
},
Point::new(row_x, y + 1.0),
dim,
rect,
);
}
}
}
fn range_selected(&self, start: u32, end: u32) -> bool {
range_covered_by(self.doc.selections().all(), start, end)
}
fn draw_selection(&self, renderer: &mut iced::Renderer, geo: &Geo, start: u32, end: u32, color: Color) {
let buffer = self.doc.buffer();
let fold_map = self.fold_map();
let (a, b) = (buffer.offset_to_point(start), buffer.offset_to_point(end));
if a.row == b.row {
self.draw_wash_row(renderer, &fold_map, geo, a.row, a, b, start, end, color);
return;
}
let bounds = geo.bounds();
let window = fold_map
.display_window(geo.rows_from_top(bounds.y), geo.rows_from_top(bounds.y + bounds.height));
let sel = a.row..=b.row;
for vr in fold_map.visible_rows(window) {
if sel.contains(&vr.buffer_row.0) {
self.draw_wash_row(renderer, &fold_map, geo, vr.buffer_row.0, a, b, start, end, color);
}
if let Some(tail) = vr.last_folded {
if sel.contains(&tail.0) {
self.draw_fold_tail_wash(renderer, &fold_map, geo, tail.0, a, b, color);
}
}
}
}
#[allow(clippy::too_many_arguments)] fn draw_wash_row(&self, renderer: &mut iced::Renderer, fold_map: &FoldMap, geo: &Geo, row: u32, a: BufPoint, b: BufPoint, start: u32, end: u32, color: Color) {
draw_budget::bump_rows(1);
if fold_map.is_folded(BufferRow(row)) {
self.draw_fold_tail_wash(renderer, fold_map, geo, row, a, b, color);
return;
}
let (bounds, advance, line_h) = (geo.bounds(), geo.advance(), geo.line_h());
let buffer = self.doc.buffer();
let y = geo.row_y(fold_map.to_display_row(BufferRow(row)));
if y + line_h < bounds.y || y > bounds.y + bounds.height {
return;
}
let x0 = if row == a.row { self.offset_screen_x(fold_map, geo, start) } else { geo.cell_x(0.0) };
let x1 = if row == b.row {
self.offset_screen_x(fold_map, geo, end)
} else if let Some(hl) = fold_map.header_layout(buffer, BufferRow(row), TAB) {
geo.cell_x(hl.tail_cell() as f32)
} else {
let line_end = buffer.point_to_offset(scrive_core::Point::new(row, buffer.line_len(row)));
self.offset_screen_x(fold_map, geo, line_end) + advance * 0.5
};
fill(renderer, Rectangle { x: x0, y, width: (x1 - x0).max(1.0), height: line_h }, color);
}
#[allow(clippy::too_many_arguments)] fn draw_fold_tail_wash(&self, renderer: &mut iced::Renderer, fold_map: &FoldMap, geo: &Geo, tail_row: u32, a: BufPoint, b: BufPoint, color: Color) {
draw_budget::bump_rows(1); let (bounds, advance, line_h) = (geo.bounds(), geo.advance(), geo.line_h());
let buffer = self.doc.buffer();
let Some(hdr) = fold_map.header_of_tail(BufferRow(tail_row)) else { return };
let Some(hl) = fold_map.header_layout(buffer, hdr, TAB) else { return };
let lead = hl.tail_start_col();
let sel_a = if tail_row == a.row { a.col } else { 0 };
let sel_b = if tail_row == b.row { b.col } else { buffer.line_len(tail_row) };
let s_col = sel_a.max(lead); let y = geo.row_y(fold_map.to_display_row(hdr));
let onscreen = y + line_h >= bounds.y && y <= bounds.y + bounds.height;
if s_col <= sel_b && onscreen {
let (Some(c0), Some(c1)) = (hl.tail_col_cell(s_col), hl.tail_col_cell(sel_b)) else {
return;
};
let x0 = geo.cell_x(c0 as f32);
let x1 = geo.cell_x(c1 as f32) + if tail_row == b.row { 0.0 } else { advance * 0.5 };
fill(renderer, Rectangle { x: x0, y, width: (x1 - x0).max(1.0), height: line_h }, color);
}
}
fn write_clipboard(&self, clipboard: &mut dyn Clipboard) {
let (text, entire_line) = self.doc.clipboard_payload();
if !text.is_empty() {
let exported = crate::clipboard::export_eol(&text);
crate::clipboard::record(&exported, entire_line);
clipboard.write(ClipboardKind::Standard, exported);
}
}
fn hit_cell(&self, geo: &Geo, pos: Point) -> (u32, u32) {
let fold_map = self.fold_map();
let row = fold_map.to_buffer_row(fold_map.display_row_at(geo.rows_from_top(pos.y))).0;
(row, scrive_core::virtual_cell(geo.x_cell(pos.x)))
}
fn hit_test(&self, geo: &Geo, pos: Point) -> u32 {
let fold_map = self.fold_map();
let row = fold_map.to_buffer_row(fold_map.display_row_at(geo.rows_from_top(pos.y)));
fold_map.hit_row(self.doc.buffer(), row, geo.x_cell(pos.x), scrive_core::Bias::Left, TAB)
}
#[allow(clippy::too_many_arguments)]
fn draw_row_inline(
&self,
renderer: &mut iced::Renderer,
line: &str,
spans: Option<&[HighlightSpan]>,
origin: Point,
geo: &Geo,
row_layout: &RowLayout<'_>,
dim: Color,
text_color: Color,
clip: Rectangle,
) {
let advance = geo.advance();
let seg = |renderer: &mut iced::Renderer, text: &str, start_col: u32, color: Color| {
if text.is_empty() || row_layout.glyph_hidden(start_col) {
return;
}
let x = origin.x + row_layout.display_cell(start_col) as f32 * advance;
self.draw_line(renderer, expand_tabs(text), Point::new(x, origin.y), color, Alignment::Left, clip);
};
match spans {
Some(spans) if !spans.is_empty() => {
for s in spans {
let fg = s.style.fg;
let text = &line[s.range.start as usize..s.range.end as usize];
seg(renderer, text, s.range.start, Color::from_rgb8(fg.r, fg.g, fg.b));
}
}
_ => {
let mut cursor = 0u32;
for chip in row_layout.chips() {
seg(renderer, &line[cursor as usize..=chip.open_col as usize], cursor, text_color);
cursor = chip.close_col;
}
seg(renderer, &line[cursor as usize..], cursor, text_color);
}
}
let row_start = row_layout.row_start();
for chip in row_layout.chips() {
let mid = origin.x + chip.center * advance;
let dots = if self.range_selected(row_start + chip.open_col + 1, row_start + chip.close_col) {
text_color
} else {
fill_rounded(renderer, geo.chip_pill(mid, origin.y), POPUP_SELECT, CHIP_PILL_RADIUS);
dim
};
self.draw_line(renderer, "…".to_string(), Point::new(mid, origin.y), dots, Alignment::Center, clip);
}
}
fn collapsed_chip_rect(&self, fold_map: &FoldMap, geo: &Geo, opener: u32) -> Option<Rectangle> {
if !self.doc.folds().is_folded(opener) {
return None;
}
let buffer = self.doc.buffer();
let b = self.doc.brackets().at(opener).filter(|b| b.open)?;
let close = b.partner?;
let header = buffer.offset_to_point(opener).row;
let last = buffer.offset_to_point(close).row;
if fold_map.is_folded(BufferRow(header)) {
return None;
}
let code_left = geo.code_left();
let y = geo.row_y(fold_map.to_display_row(BufferRow(header)));
if last > header {
let hl = fold_map.header_layout(buffer, BufferRow(header), TAB)?;
let x0 = geo.cell_x((hl.head_cells() as f32 - 1.0).max(0.0)) - 2.0;
let last_start = buffer.point_to_offset(BufPoint { row: last, col: 0 });
let close_cell = hl.tail_col_cell(close - last_start).unwrap_or_else(|| hl.tail_cell());
let x1 = geo.cell_x(close_cell as f32 + 1.0) + 2.0;
Some(Rectangle {
x: x0.max(code_left + 1.0),
y: y + 1.0,
width: (x1 - x0).max(geo.advance()),
height: geo.line_h() - 2.0,
})
} else {
fold_map.inline_fold_at(opener)?;
let xo = self.offset_screen_x(fold_map, geo, opener);
let xc = self.offset_screen_x(fold_map, geo, close);
Some(geo.inline_halo(xo.min(xc), xo.max(xc), y))
}
}
fn chip_pill_rect(&self, fold_map: &FoldMap, geo: &Geo, opener: u32) -> Option<Rectangle> {
if !self.doc.folds().is_folded(opener) {
return None;
}
let buffer = self.doc.buffer();
let b = self.doc.brackets().at(opener).filter(|b| b.open)?;
let close = b.partner?;
let header = buffer.offset_to_point(opener).row;
let last = buffer.offset_to_point(close).row;
if fold_map.is_folded(BufferRow(header)) {
return None;
}
let y = geo.row_y(fold_map.to_display_row(BufferRow(header)));
let mid = if last > header {
geo.cell_x(fold_map.header_layout(buffer, BufferRow(header), TAB)?.gap_center())
} else {
let row_start = buffer.point_to_offset(BufPoint { row: header, col: 0 });
let layout = fold_map.row_layout(buffer, BufferRow(header), TAB);
let chip = layout.chips().find(|c| c.open_col == opener - row_start)?;
geo.cell_x(chip.center)
};
Some(geo.chip_pill(mid, y))
}
fn collapsed_chip_at(&self, geo: &Geo, pos: Point) -> Option<u32> {
let fold_map = self.fold_map();
let row = fold_map.to_buffer_row(fold_map.display_row_at(geo.rows_from_top(pos.y))).0;
self.doc
.foldable_pairs_in_rows(row..row + 1)
.into_iter()
.map(|(open, ..)| open)
.find(|&o| self.collapsed_chip_rect(&fold_map, geo, o).is_some_and(|r| r.contains(pos)))
}
fn draw_fold_preview(&self, renderer: &mut iced::Renderer, geo: &Geo, opener: u32, text_color: Color) {
let (bounds, advance, line_h) = (geo.bounds(), geo.advance(), geo.line_h());
if !self.doc.folds().is_folded(opener) {
return; }
let buffer = self.doc.buffer();
let Some(b) = self.doc.brackets().at(opener).filter(|b| b.open) else { return };
let Some(close) = b.partner else { return };
let header = buffer.offset_to_point(opener).row;
let last = buffer.offset_to_point(close).row;
let fold_map = self.fold_map();
if fold_map.is_folded(BufferRow(header)) {
return; }
const MAX_ROWS: usize = 18;
let total = if last > header { (last - header) as usize } else { 1 };
let rows: Vec<u32> = if last > header {
(header + 1..=last).take(MAX_ROWS).collect()
} else {
vec![header]
};
let shown = rows.len(); let more = total - shown;
let mut min_cells = u32::MAX;
let mut max_cells = 0u32;
for &r in &rows[..shown] {
let l = buffer.line(r);
if l.trim().is_empty() {
continue;
}
min_cells = min_cells.min(line_indent_cells(&l));
max_cells = max_cells.max(display_map::expand(&l, l.len() as u32, TAB));
}
let min_cells = if min_cells == u32::MAX { 0 } else { min_cells };
let content_cells = max_cells.saturating_sub(min_cells);
let pad = 8.0_f32;
let code_w = (bounds.width - geo.gutter() - SCROLLBAR_WIDTH).max(advance * 8.0);
let width = (content_cells as f32 * advance + 2.0 * pad).clamp(advance * 8.0, code_w);
let height = (shown + usize::from(more > 0)) as f32 * line_h + 2.0 * pad;
let Some(chip) = self.collapsed_chip_rect(&fold_map, geo, opener) else {
return; };
let code_left = geo.code_left();
let x = chip.x.clamp(code_left, (bounds.x + bounds.width - width - 4.0).max(code_left));
let line_top = geo.row_y(fold_map.to_display_row(BufferRow(header)));
let below = line_top + line_h + 2.0;
let y = if below + height <= bounds.y + bounds.height { below } else { (line_top - height - 2.0).max(bounds.y) };
let rect = Rectangle { x, y, width, height };
fill_panel(renderer, rect, POPUP_SURFACE, POPUP_BORDER, 4.0);
let sx = rect.x + pad - min_cells as f32 * advance;
for (i, &r) in rows[..shown].iter().enumerate() {
let ry = rect.y + pad + i as f32 * line_h;
match self.doc.highlight_line_spans(r) {
Some(spans) if !spans.is_empty() => self.draw_spans(renderer, &buffer.line(r), spans, Point::new(sx, ry), advance, rect),
_ => {
let line = buffer.line(r);
let l = line.trim_start();
if !l.is_empty() {
self.draw_line(renderer, expand_tabs(l), Point::new(rect.x + pad, ry), text_color, Alignment::Left, rect);
}
}
}
}
if more > 0 {
let ry = rect.y + pad + shown as f32 * line_h;
let msg = format!("… {more} more line{}", if more == 1 { "" } else { "s" });
self.draw_line(renderer, msg, Point::new(rect.x + pad, ry), Color { a: 0.6, ..text_color }, Alignment::Left, rect);
}
}
fn draw_spans(
&self,
renderer: &mut iced::Renderer,
line: &str,
spans: &[HighlightSpan],
origin: Point,
advance: f32,
clip: Rectangle,
) {
for span in spans {
let text = &line[span.range.start as usize..span.range.end as usize];
if text.is_empty() {
continue;
}
let cell = display_map::expand(line, span.range.start, TAB);
let x = origin.x + cell as f32 * advance;
let fg = span.style.fg;
self.draw_line(
renderer,
expand_tabs(text),
Point::new(x, origin.y),
Color::from_rgb8(fg.r, fg.g, fg.b),
Alignment::Left,
clip,
);
}
}
fn draw_line(
&self,
renderer: &mut iced::Renderer,
content: String,
position: Point,
color: Color,
align: Alignment,
clip: Rectangle,
) {
renderer.fill_text(
Text {
content,
bounds: Size::new(f32::INFINITY, self.line_height),
size: Pixels(self.size),
line_height: LineHeight::Absolute(Pixels(self.line_height)),
font: self.font,
align_x: align,
align_y: Vertical::Top,
shaping: Shaping::Basic,
wrapping: Wrapping::None,
},
position,
color,
clip,
);
}
fn draw_icon(
&self,
renderer: &mut iced::Renderer,
glyph: char,
origin: Point,
width: f32,
color: Color,
clip: Rectangle,
) {
renderer.fill_text(
Text {
content: glyph.to_string(),
bounds: Size::new(f32::INFINITY, self.line_height),
size: Pixels(self.size),
line_height: LineHeight::Absolute(Pixels(self.line_height)),
font: crate::CODICON,
align_x: Alignment::Center,
align_y: Vertical::Top,
shaping: Shaping::Basic,
wrapping: Wrapping::None,
},
Point::new(origin.x + width / 2.0, origin.y),
color,
clip,
);
}
fn draw_run(
&self,
renderer: &mut iced::Renderer,
content: String,
position: Point,
color: Color,
font: Font,
clip: Rectangle,
) {
renderer.fill_text(
Text {
content,
bounds: Size::new(f32::INFINITY, self.line_height),
size: Pixels(self.size),
line_height: LineHeight::Absolute(Pixels(self.line_height)),
font,
align_x: Alignment::Left,
align_y: Vertical::Top,
shaping: Shaping::Basic,
wrapping: Wrapping::None,
},
position,
color,
clip,
);
}
}
fn expand_tabs(line: &str) -> String {
if !line.contains('\t') {
return line.to_owned();
}
let mut out = String::with_capacity(line.len());
let mut cell = 0u32;
for ch in line.chars() {
if ch == '\t' {
let w = display_map::tab_width(cell, TAB);
for _ in 0..w {
out.push(' ');
}
cell += w;
} else {
out.push(ch);
cell += 1;
}
}
out
}
fn numpad_text(physical: &iced::keyboard::key::Physical, text: Option<&str>) -> Option<char> {
use iced::keyboard::key::{Code, Physical};
let is_numpad = matches!(
physical,
Physical::Code(
Code::Numpad0
| Code::Numpad1
| Code::Numpad2
| Code::Numpad3
| Code::Numpad4
| Code::Numpad5
| Code::Numpad6
| Code::Numpad7
| Code::Numpad8
| Code::Numpad9
| Code::NumpadDecimal
| Code::NumpadComma
| Code::NumpadAdd
| Code::NumpadSubtract
| Code::NumpadMultiply
| Code::NumpadDivide
| Code::NumpadEqual
)
);
if !is_numpad {
return None;
}
let ch = text?.chars().next()?;
(!ch.is_control()).then_some(ch)
}
fn interpret_key(key: &Key, text: Option<&str>, mods: Modifiers) -> Option<Action> {
let extend = mods.shift();
let mv = |motion| Some(Action::Move { motion, extend });
let column = mods.control() && mods.shift() && mods.alt();
match key {
Key::Named(Named::ArrowUp) if column => Some(Action::ColumnSelect(ColumnDir::Up)),
Key::Named(Named::ArrowDown) if column => Some(Action::ColumnSelect(ColumnDir::Down)),
Key::Named(Named::ArrowLeft) if column => Some(Action::ColumnSelect(ColumnDir::Left)),
Key::Named(Named::ArrowRight) if column => Some(Action::ColumnSelect(ColumnDir::Right)),
Key::Named(Named::ArrowUp) if mods.control() && mods.alt() && !mods.shift() => {
Some(Action::AddCaretVertical { down: false })
}
Key::Named(Named::ArrowDown) if mods.control() && mods.alt() && !mods.shift() => {
Some(Action::AddCaretVertical { down: true })
}
Key::Named(Named::ArrowRight) if mods.alt() && !mods.control() && mods.shift() => {
Some(Action::ExpandSelection)
}
Key::Named(Named::ArrowLeft) if mods.alt() && !mods.control() && mods.shift() => {
Some(Action::ShrinkSelection)
}
Key::Named(Named::ArrowUp) if mods.alt() && !mods.control() && !mods.shift() => {
Some(Action::MoveLine { down: false })
}
Key::Named(Named::ArrowDown) if mods.alt() && !mods.control() && !mods.shift() => {
Some(Action::MoveLine { down: true })
}
Key::Named(Named::ArrowUp) if mods.alt() && !mods.control() && mods.shift() => {
Some(Action::CopyLine { down: false })
}
Key::Named(Named::ArrowDown) if mods.alt() && !mods.control() && mods.shift() => {
Some(Action::CopyLine { down: true })
}
Key::Named(Named::ArrowLeft) if mods.control() => mv(Motion::WordLeft),
Key::Named(Named::ArrowRight) if mods.control() => mv(Motion::WordRight),
Key::Named(Named::ArrowLeft) => mv(Motion::Left),
Key::Named(Named::ArrowRight) => mv(Motion::Right),
Key::Named(Named::ArrowUp) => mv(Motion::Up),
Key::Named(Named::ArrowDown) => mv(Motion::Down),
Key::Named(Named::Home) if mods.control() => mv(Motion::DocStart),
Key::Named(Named::End) if mods.control() => mv(Motion::DocEnd),
Key::Named(Named::Home) => mv(Motion::LineStart),
Key::Named(Named::End) => mv(Motion::LineEnd),
Key::Named(Named::Backspace) if mods.control() => Some(Action::DeleteWordBack),
Key::Named(Named::Delete) if mods.control() => Some(Action::DeleteWordForward),
Key::Named(Named::Backspace) => Some(Action::Backspace),
Key::Named(Named::Delete) => Some(Action::Delete),
Key::Named(Named::Enter) if mods.control() && !mods.alt() => {
Some(Action::InsertLine { down: !mods.shift() })
}
Key::Named(Named::Enter) if !mods.alt() => Some(Action::Enter),
Key::Named(Named::Enter) => None,
Key::Named(Named::Tab) if mods.shift() => Some(Action::Outdent),
Key::Named(Named::Tab) => Some(Action::Tab),
Key::Named(Named::Space) => Some(Action::Type(' ')),
Key::Named(Named::Escape) => Some(Action::Collapse),
Key::Named(Named::F8) => Some(Action::NextDiagnostic { forward: !mods.shift() }),
Key::Character(c) if mods.control() && !mods.alt() && c.as_str() == "d" => {
Some(Action::AddNextOccurrence)
}
Key::Character(c)
if mods.control() && mods.shift() && !mods.alt() && c.as_str().eq_ignore_ascii_case("l") =>
{
Some(Action::SelectAllOccurrences)
}
Key::Character(c) if mods.control() && !mods.alt() && c.as_str() == "a" => {
Some(Action::SelectAll)
}
Key::Character(c) if mods.control() && !mods.alt() && c.as_str() == "/" => {
Some(Action::ToggleComment)
}
Key::Character(c)
if mods.control() && mods.shift() && !mods.alt() && c.as_str().eq_ignore_ascii_case("k") =>
{
Some(Action::DeleteLine)
}
Key::Character(c) if mods.control() && !mods.alt() && matches!(c.as_str(), "\\" | "|") => {
Some(Action::JumpToBracket)
}
Key::Character(c) if mods.control() && !mods.alt() && c.as_str().eq_ignore_ascii_case("z") => {
Some(if mods.shift() { Action::Redo } else { Action::Undo })
}
Key::Character(c) if mods.control() && !mods.alt() && c.as_str().eq_ignore_ascii_case("y") => {
Some(Action::Redo)
}
_ => {
if (mods.control() && !mods.alt()) || mods.command() {
return None;
}
let ch = text?.chars().next()?;
(!ch.is_control()).then_some(Action::Type(ch))
}
}
}
fn line_indent_cells(line: &str) -> u32 {
let ws = (line.len() - line.trim_start().len()) as u32;
display_map::expand(line, ws, TAB)
}
fn kind_color(kind: CompletionKind) -> Color {
match kind {
CompletionKind::Keyword | CompletionKind::Construct => Color::from_rgb8(0xff, 0x61, 0x8d), CompletionKind::Type => Color::from_rgb8(0x66, 0xd9, 0xef), CompletionKind::Param | CompletionKind::Field => Color::from_rgb8(0xfd, 0x97, 0x1f), CompletionKind::Value | CompletionKind::Event => Color::from_rgb8(0xa6, 0xe2, 0x2e), CompletionKind::Symbol | CompletionKind::Method => Color::from_rgb8(0xae, 0x81, 0xff), }
}
fn severity_color(sev: Severity) -> Color {
match sev {
Severity::Error => Color::from_rgb8(0xff, 0x61, 0x69), Severity::Warning => Color::from_rgb8(0xfc, 0xe5, 0x66), Severity::Info => Color::from_rgb8(0x5a, 0xd4, 0xe6), Severity::Hint => Color::from_rgb8(0x94, 0x8a, 0xe3), }
}
fn squiggle_spans(x0: f32, x1: f32, baseline: f32, mut emit: impl FnMut(Rectangle)) {
use std::f32::consts::PI;
let wave = |x: f32| SQUIGGLE_AMPLITUDE * (PI * (x - x0) / SQUIGGLE_HALF_PERIOD).sin();
let mut x = x0;
while x < x1 {
let next = (x + 1.0).min(x1);
let (ya, yb) = (baseline + wave(x), baseline + wave(next));
let top = ya.min(yb) - SQUIGGLE_STROKE / 2.0;
let height = (ya - yb).abs() + SQUIGGLE_STROKE;
emit(Rectangle { x, y: top, width: next - x, height });
x = next;
}
}
fn fill(renderer: &mut iced::Renderer, bounds: Rectangle, color: Color) {
use iced::advanced::Renderer as _;
renderer.fill_quad(
renderer::Quad { bounds, border: border::rounded(0), shadow: Shadow::default(), snap: true },
color,
);
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum MdStyle {
Plain,
Bold,
Code,
}
fn parse_md_runs(line: &str) -> Vec<(String, MdStyle)> {
let mut runs = Vec::new();
let mut cur = String::new();
let mut style = MdStyle::Plain;
let mut push = |cur: &mut String, style: MdStyle| {
if !cur.is_empty() {
runs.push((std::mem::take(cur), style));
}
};
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'*' if chars.peek() == Some(&'*') => {
chars.next(); push(&mut cur, style);
style = if style == MdStyle::Bold { MdStyle::Plain } else { MdStyle::Bold };
}
'`' => {
push(&mut cur, style);
style = if style == MdStyle::Code { MdStyle::Plain } else { MdStyle::Code };
}
_ => cur.push(c),
}
}
if !cur.is_empty() {
runs.push((cur, style));
}
runs
}
fn coalesce_chars(chars: Vec<(char, MdStyle)>) -> Vec<(String, MdStyle)> {
let mut segs: Vec<(String, MdStyle)> = Vec::new();
for (c, s) in chars {
match segs.last_mut() {
Some((t, st)) if *st == s => t.push(c),
_ => segs.push((c.to_string(), s)),
}
}
segs
}
fn wrap_runs(runs: &[(String, MdStyle)], max_cells: usize) -> Vec<Vec<(String, MdStyle)>> {
let max = max_cells.max(1);
let flat: Vec<(char, MdStyle)> =
runs.iter().flat_map(|(t, s)| t.chars().map(move |c| (c, *s))).collect();
let mut lines: Vec<Vec<(char, MdStyle)>> = Vec::new();
let mut cur: Vec<(char, MdStyle)> = Vec::new();
let trim = |line: &mut Vec<(char, MdStyle)>| {
while line.last().map(|&(c, _)| c) == Some(' ') {
line.pop();
}
};
let mut i = 0;
while i < flat.len() {
if flat[i].0 == ' ' {
if cur.len() < max {
cur.push(flat[i]); }
i += 1;
continue;
}
let start = i;
while i < flat.len() && flat[i].0 != ' ' {
i += 1;
}
let word = &flat[start..i];
if word.len() > max {
for &ch in word {
if cur.len() >= max {
lines.push(std::mem::take(&mut cur));
}
cur.push(ch);
}
} else {
if cur.len() + word.len() > max {
trim(&mut cur);
lines.push(std::mem::take(&mut cur));
}
cur.extend_from_slice(word);
}
}
trim(&mut cur);
lines.push(cur);
lines.into_iter().map(coalesce_chars).collect()
}
struct HoverLayout {
rect: Rectangle,
lines: Vec<Vec<(String, MdStyle)>>,
visible: usize,
max_scroll: f32,
pad_x: f32,
pad_y: f32,
overflow: bool,
}
fn fill_rounded(renderer: &mut iced::Renderer, bounds: Rectangle, color: Color, radius: f32) {
use iced::advanced::Renderer as _;
renderer.fill_quad(
renderer::Quad { bounds, border: border::rounded(radius), shadow: Shadow::default(), snap: true },
color,
);
}
fn fill_panel(renderer: &mut iced::Renderer, bounds: Rectangle, fill: Color, border_color: Color, radius: f32) {
use iced::advanced::Renderer as _;
renderer.fill_quad(
renderer::Quad {
bounds,
border: border::rounded(radius).color(border_color).width(1.0),
shadow: Shadow {
color: Color::from_rgba8(0, 0, 0, 0.36),
offset: Vector::new(0.0, 2.0),
blur_radius: 8.0,
},
snap: true,
},
fill,
);
}
fn fill_border(renderer: &mut iced::Renderer, bounds: Rectangle, color: Color, width: f32) {
use iced::advanced::Renderer as _;
renderer.fill_quad(
renderer::Quad {
bounds,
border: border::rounded(2).color(color).width(width),
shadow: Shadow::default(),
snap: true,
},
Color::TRANSPARENT,
);
}
#[allow(clippy::too_many_arguments)] fn stroke_dashed_rounded_rect(renderer: &mut iced::Renderer, rect: Rectangle, color: Color, radius: f32, dash: f32, gap: f32, w: f32) {
if rect.width <= 0.0 || rect.height <= 0.0 {
return;
}
let (x, y, ww, hh) = (rect.x, rect.y, rect.width, rect.height);
let r = radius.min(ww * 0.5).min(hh * 0.5).max(0.0);
let step = (dash + gap).max(0.1);
let mut sx = x + r;
while sx < x + ww - r {
let dw = dash.min(x + ww - r - sx);
fill(renderer, Rectangle { x: sx, y, width: dw, height: w }, color);
fill(renderer, Rectangle { x: sx, y: y + hh - w, width: dw, height: w }, color);
sx += step;
}
let mut sy = y + r;
while sy < y + hh - r {
let dh = dash.min(y + hh - r - sy);
fill(renderer, Rectangle { x, y: sy, width: w, height: dh }, color);
fill(renderer, Rectangle { x: x + ww - w, y: sy, width: w, height: dh }, color);
sy += step;
}
if r > 0.5 {
use core::f32::consts::{FRAC_PI_2, PI};
let half = w * 0.5;
let arcs = [
(x + ww - r, y + r, -FRAC_PI_2, 0.0), (x + ww - r, y + hh - r, 0.0, FRAC_PI_2), (x + r, y + hh - r, FRAC_PI_2, PI), (x + r, y + r, PI, PI + FRAC_PI_2), ];
for (cx, cy, a0, a1) in arcs {
let steps = (r * (a1 - a0).abs()).ceil().max(1.0) as u32;
for k in 0..=steps {
let a = a0 + (a1 - a0) * (k as f32 / steps as f32);
fill(renderer, Rectangle { x: cx + r * a.cos() - half, y: cy + r * a.sin() - half, width: w, height: w }, color);
}
}
}
}
impl<'a, Message: 'a> From<Editor<'a, Message>>
for Element<'a, Message, iced::Theme, iced::Renderer>
{
fn from(editor: Editor<'a, Message>) -> Self {
Element::new(editor)
}
}
#[cfg(test)]
mod tests {
use super::*;
use iced::advanced::widget::operation;
#[test]
fn digit_count_is_right() {
assert_eq!(digit_count(0), 1);
assert_eq!(digit_count(9), 1);
assert_eq!(digit_count(10), 2);
assert_eq!(digit_count(1000), 4);
}
#[test]
fn visible_selection_span_matches_a_brute_force_filter() {
use scrive_core::SelectionSet;
let ranges = [(0u32, 3u32), (10, 10), (20, 25), (40, 40), (55, 60), (80, 80)];
let set = SelectionSet::from_ranges(&ranges, 0);
let sels = set.all();
for vis_start in 0..90u32 {
for vis_end in vis_start..90u32 {
let span = visible_selection_span(sels, vis_start, vis_end);
let brute: Vec<usize> = (0..sels.len())
.filter(|&i| sels[i].end() >= vis_start && sels[i].start() <= vis_end)
.collect();
let got: Vec<usize> = span.clone().collect();
assert_eq!(got, brute, "window [{vis_start}, {vis_end}]");
}
}
}
#[test]
fn range_covered_by_matches_a_brute_force_scan() {
use scrive_core::SelectionSet;
let ranges = [(0u32, 3u32), (10, 10), (20, 25), (40, 40), (55, 60)];
let set = SelectionSet::from_ranges(&ranges, 0);
let sels = set.all();
for start in 0..65u32 {
for end in start..65u32 {
let got = range_covered_by(sels, start, end);
let brute = sels.iter().any(|s| s.start() <= start && s.end() >= end);
assert_eq!(got, brute, "range [{start}, {end}]");
}
}
}
#[test]
fn numpad_text_prefers_glyph_over_nav() {
use iced::keyboard::key::{Code, Physical};
assert_eq!(numpad_text(&Physical::Code(Code::Numpad2), Some("2")), Some('2'));
assert_eq!(numpad_text(&Physical::Code(Code::NumpadDecimal), Some(".")), Some('.'));
assert_eq!(numpad_text(&Physical::Code(Code::NumpadAdd), Some("+")), Some('+'));
assert_eq!(numpad_text(&Physical::Code(Code::Numpad2), None), None);
assert_eq!(numpad_text(&Physical::Code(Code::Digit2), Some("2")), None);
assert_eq!(numpad_text(&Physical::Code(Code::NumpadEnter), Some("\r")), None);
}
#[test]
fn wrap_runs_wraps_at_word_boundaries() {
use MdStyle::*;
let text = |line: &Vec<(String, MdStyle)>| line.iter().map(|(t, _)| t.as_str()).collect::<String>();
let w = wrap_runs(&[("hello world foo".to_string(), Plain)], 8);
assert_eq!(w.iter().map(text).collect::<Vec<_>>(), vec!["hello", "world", "foo"]);
let h = wrap_runs(&[("verylongword".to_string(), Plain)], 4);
assert_eq!(h.iter().map(text).collect::<Vec<_>>(), vec!["very", "long", "word"]);
let s = wrap_runs(&[("a".to_string(), Bold), (" b".to_string(), Plain)], 40);
assert_eq!(s.len(), 1);
assert_eq!(s[0], vec![("a".to_string(), Bold), (" b".to_string(), Plain)]);
}
#[test]
fn parse_md_runs_splits_bold_and_code() {
use MdStyle::*;
let runs = parse_md_runs("**name** `id { … }` — handle it.");
assert_eq!(
runs,
vec![
("name".to_string(), Bold),
(" ".to_string(), Plain),
("id { … }".to_string(), Code),
(" — handle it.".to_string(), Plain),
]
);
assert_eq!(parse_md_runs("just words"), vec![("just words".to_string(), Plain)]);
assert_eq!(parse_md_runs("a * b"), vec![("a * b".to_string(), Plain)]);
}
#[test]
fn reveal_fit_scrolls_with_margin_and_holds() {
assert_eq!(reveal_fit(100.0, 40.0, 60.0, 300.0, 0.0), 40.0); assert_eq!(reveal_fit(0.0, 600.0, 620.0, 300.0, 0.0), 320.0); assert_eq!(reveal_fit(100.0, 200.0, 220.0, 300.0, 0.0), 100.0); assert_eq!(reveal_fit(0.0, 240.0, 260.0, 300.0, 60.0), 20.0);
assert_eq!(reveal_fit(0.0, 100.0, 120.0, 300.0, 60.0), 0.0);
assert_eq!(reveal_fit(100.0, 40.0, 60.0, 300.0, 60.0), 0.0);
assert_eq!(reveal_fit(0.0, 200.0, 280.0, 100.0, 60.0), 190.0);
assert_eq!(reveal_fit(50.0, 0.0, 400.0, 300.0, 0.0), 50.0);
}
#[test]
fn squiggle_spans_trace_the_wave_within_amplitude() {
let collect = |x0, x1, baseline| {
let mut v = Vec::new();
squiggle_spans(x0, x1, baseline, |r| v.push(r));
v
};
let spans = collect(0.0, 8.0, 10.0);
assert!(!spans.is_empty());
assert!(spans.iter().all(|r| (r.width - 1.0).abs() < 1e-3));
assert!((spans[0].x - 0.0).abs() < 1e-3);
let top = spans.iter().map(|r| r.y).fold(f32::MAX, f32::min);
let bot = spans.iter().map(|r| r.y + r.height).fold(f32::MIN, f32::max);
assert!((top - (10.0 - SQUIGGLE_AMPLITUDE - SQUIGGLE_STROKE / 2.0)).abs() < 0.2, "reaches −amp");
assert!((bot - (10.0 + SQUIGGLE_AMPLITUDE + SQUIGGLE_STROKE / 2.0)).abs() < 0.2, "reaches +amp");
assert!(collect(5.0, 5.0, 10.0).is_empty(), "zero width → no spans");
}
#[test]
fn scrollbar_thumb_map_round_trips_and_clamps() {
let sb = Scrollbar { x: 288.0, track_top: 0.0, track_h: 300.0, thumb_h: 100.0, thumb_y: 100.0, max_scroll_rows: 700.0 };
assert_eq!(sb.scroll_rows_for_thumb_top(100.0), 350.0);
assert_eq!(sb.scroll_rows_for_thumb_top(-50.0), 0.0);
assert_eq!(sb.scroll_rows_for_thumb_top(500.0), 700.0);
assert!(sb.contains_x(290.0) && !sb.contains_x(287.0));
assert!(sb.thumb_contains_y(150.0) && !sb.thumb_contains_y(200.0) && !sb.thumb_contains_y(99.0));
}
#[test]
fn hscrollbar_thumb_map_round_trips_and_clamps() {
let hb = HScrollbar { y: 288.0, track_left: 0.0, track_w: 300.0, thumb_w: 100.0, thumb_x: 100.0, max_scroll: 700.0 };
assert_eq!(hb.scroll_for_thumb_left(100.0), 350.0);
assert_eq!(hb.scroll_for_thumb_left(-50.0), 0.0);
assert_eq!(hb.scroll_for_thumb_left(500.0), 700.0);
assert!(hb.contains_y(290.0) && !hb.contains_y(287.0));
assert!(hb.thumb_contains_x(150.0) && !hb.thumb_contains_x(200.0) && !hb.thumb_contains_x(99.0));
}
#[test]
fn severity_order_is_ascending_so_error_paints_last() {
assert!(Severity::Hint < Severity::Info);
assert!(Severity::Info < Severity::Warning);
assert!(Severity::Warning < Severity::Error);
}
#[test]
fn interpret_typing_and_chords() {
let none = Modifiers::default();
assert_eq!(interpret_key(&Key::Character("a".into()), Some("a"), none), Some(Action::Type('a')));
assert_eq!(interpret_key(&Key::Character("q".into()), Some("q"), Modifiers::CTRL), None);
assert_eq!(
interpret_key(&Key::Character("a".into()), Some("a"), Modifiers::CTRL),
Some(Action::SelectAll)
);
assert_eq!(
interpret_key(&Key::Named(Named::ArrowRight), None, Modifiers::SHIFT),
Some(Action::Move { motion: Motion::Right, extend: true })
);
assert_eq!(
interpret_key(&Key::Named(Named::ArrowLeft), None, Modifiers::CTRL),
Some(Action::Move { motion: Motion::WordLeft, extend: false })
);
}
#[test]
fn ctrl_backspace_delete_are_word_deletes() {
assert_eq!(
interpret_key(&Key::Named(Named::Backspace), None, Modifiers::CTRL),
Some(Action::DeleteWordBack)
);
assert_eq!(
interpret_key(&Key::Named(Named::Delete), None, Modifiers::CTRL),
Some(Action::DeleteWordForward)
);
assert_eq!(
interpret_key(&Key::Named(Named::Backspace), None, Modifiers::default()),
Some(Action::Backspace)
);
}
#[test]
fn ctrl_home_end_jump_to_document_ends() {
assert_eq!(
interpret_key(&Key::Named(Named::Home), None, Modifiers::CTRL),
Some(Action::Move { motion: Motion::DocStart, extend: false })
);
assert_eq!(
interpret_key(&Key::Named(Named::End), None, Modifiers::CTRL | Modifiers::SHIFT),
Some(Action::Move { motion: Motion::DocEnd, extend: true })
);
assert_eq!(
interpret_key(&Key::Named(Named::Home), None, Modifiers::default()),
Some(Action::Move { motion: Motion::LineStart, extend: false })
);
}
#[test]
fn undo_redo_chords_interpret() {
assert_eq!(
interpret_key(&Key::Character("z".into()), Some("z"), Modifiers::CTRL),
Some(Action::Undo)
);
assert_eq!(
interpret_key(&Key::Character("Z".into()), Some("Z"), Modifiers::CTRL | Modifiers::SHIFT),
Some(Action::Redo) );
assert_eq!(
interpret_key(&Key::Character("y".into()), Some("y"), Modifiers::CTRL),
Some(Action::Redo)
);
}
#[test]
fn ctrl_d_and_escape_interpret() {
assert_eq!(
interpret_key(&Key::Character("d".into()), Some("d"), Modifiers::CTRL),
Some(Action::AddNextOccurrence)
);
assert_ne!(
interpret_key(&Key::Character("d".into()), Some("d"), Modifiers::CTRL | Modifiers::ALT),
Some(Action::AddNextOccurrence)
);
assert_eq!(
interpret_key(&Key::Named(Named::Escape), None, Modifiers::default()),
Some(Action::Collapse)
);
assert_eq!(interpret_key(&Key::Named(Named::Tab), None, Modifiers::default()), Some(Action::Tab));
assert_eq!(
interpret_key(&Key::Named(Named::Tab), None, Modifiers::SHIFT),
Some(Action::Outdent)
);
}
#[test]
fn ctrl_slash_is_toggle_comment() {
let ctrl = Modifiers::CTRL;
assert_eq!(
interpret_key(&Key::Character("/".into()), Some("/"), ctrl),
Some(Action::ToggleComment)
);
assert_eq!(
interpret_key(&Key::Character("/".into()), Some("/"), Modifiers::empty()),
Some(Action::Type('/'))
);
assert_eq!(interpret_key(&Key::Character("/".into()), Some("/"), ctrl | Modifiers::ALT), None);
}
#[test]
fn ctrl_shift_alt_arrow_is_column_select() {
let csa = Modifiers::CTRL | Modifiers::SHIFT | Modifiers::ALT;
assert_eq!(
interpret_key(&Key::Named(Named::ArrowDown), None, csa),
Some(Action::ColumnSelect(ColumnDir::Down))
);
assert_eq!(
interpret_key(&Key::Named(Named::ArrowRight), None, csa),
Some(Action::ColumnSelect(ColumnDir::Right))
);
assert_eq!(
interpret_key(&Key::Named(Named::ArrowLeft), None, Modifiers::CTRL | Modifiers::SHIFT),
Some(Action::Move { motion: Motion::WordLeft, extend: true })
);
}
#[test]
fn alt_arrow_moves_and_copies_lines() {
assert_eq!(
interpret_key(&Key::Named(Named::ArrowDown), None, Modifiers::ALT),
Some(Action::MoveLine { down: true })
);
assert_eq!(
interpret_key(&Key::Named(Named::ArrowUp), None, Modifiers::ALT),
Some(Action::MoveLine { down: false })
);
assert_eq!(
interpret_key(&Key::Named(Named::ArrowDown), None, Modifiers::ALT | Modifiers::SHIFT),
Some(Action::CopyLine { down: true })
);
assert_eq!(
interpret_key(
&Key::Named(Named::ArrowDown),
None,
Modifiers::CTRL | Modifiers::SHIFT | Modifiers::ALT
),
Some(Action::ColumnSelect(ColumnDir::Down))
);
}
#[test]
fn clipboard_payload_feeds_write_clipboard() {
use scrive_core::{Document, Selection, SelectionId, SelectionSet};
let mut doc = Document::new("foo\nbar\nbaz").unwrap();
let mut set = SelectionSet::new(0);
set.set_single(Selection::from_anchor(SelectionId(0), 0, 3)); set.add_selection(4, 7); set.add_caret(9); doc.set_selections(set);
assert_eq!(doc.clipboard_payload(), ("foo\nbar".to_string(), false));
}
#[test]
fn gesture_autoscroll_policy() {
assert!(!Action::AddNextOccurrence.moves_caret()); assert!(!Action::AddCaret(0).moves_caret()); assert!(!Action::Collapse.moves_caret()); assert!(!Action::PlaceCaret(0).moves_caret());
assert!(Action::Type('x').moves_caret());
assert!(!Action::NextDiagnostic { forward: true }.moves_caret());
assert!(!Action::JumpToBracket.moves_caret());
assert!(!Action::ExpandSelection.moves_caret());
assert!(!Action::AddCaretVertical { down: true }.moves_caret());
assert!(!Action::SelectAllOccurrences.moves_caret());
assert!(Action::DragSelect { granularity: Granularity::Char, origin: 0, head: 5 }
.moves_caret());
assert!(!Action::ViewportChanged(0..42).moves_caret());
}
#[test]
fn last_visible_row_tracks_scroll_and_clamps() {
use scrive_core::Document;
let text = vec!["x"; 100].join("\n");
let doc = Document::new(&text).unwrap();
let ed = Editor::new(&doc, |_: Action| ());
let bounds = Rectangle { x: 0.0, y: 0.0, width: 300.0, height: 200.0 };
assert_eq!(ed.last_visible_row(bounds, 0.0, 10.0), 28);
assert_eq!(ed.last_visible_row(bounds, 50.0, 10.0), 78);
assert_eq!(ed.last_visible_row(bounds, 500.0, 10.0), 99);
}
#[test]
fn blink_phase_toggles_each_interval() {
assert!(blink_on(0)); assert!(blink_on(499)); assert!(!blink_on(500)); assert!(!blink_on(999));
assert!(blink_on(1000)); assert!(blink_on(1499));
assert!(!blink_on(1500));
}
#[test]
fn focus_shows_solid_caret_unfocus_hides_it() {
let mut st = State::default(); assert!(st.is_focused());
assert!(st.caret_on()); st.unfocus();
assert!(!st.is_focused());
assert!(!st.caret_on()); st.focus();
assert!(st.caret_on()); }
#[test]
fn focus_operation_targets_by_id() {
let id = widget::Id::from("editor");
let other = widget::Id::from("other");
let bounds = Rectangle::new(Point::ORIGIN, Size::new(1.0, 1.0));
let mut st = State::default();
operation::focusable::focus::<()>(other).focusable(Some(&id), bounds, &mut st);
assert!(!st.is_focused());
operation::focusable::focus::<()>(id.clone()).focusable(Some(&id), bounds, &mut st);
assert!(st.is_focused());
operation::focusable::focus::<()>(id).focusable(None, bounds, &mut st);
assert!(!st.is_focused());
}
fn folded_doc() -> Document {
let text = "x\na {\nb\nc\n}\nword here\n";
let mut doc = Document::new(text).unwrap();
let opener = text.find('{').unwrap() as u32;
assert!(doc.toggle_fold_opener(opener));
doc
}
fn test_geo() -> Geo {
Geo::new(
Rectangle { x: 0.0, y: 0.0, width: 800.0, height: 600.0 },
50.0,
10.0,
10.0,
0.0,
ScrollAnchor::TOP,
)
}
#[test]
fn collapsed_chip_at_windows_to_the_hovered_row() {
let doc = folded_doc(); let ed = Editor::new(&doc, |_: Action| ());
let geo = test_geo();
let fm = ed.fold_map();
let opener = doc.buffer().text().find('{').unwrap() as u32;
let rect = ed.collapsed_chip_rect(&fm, &geo, opener).expect("collapsed header has a chip");
let center = Point::new(rect.x + rect.width / 2.0, rect.y + rect.height / 2.0);
assert_eq!(ed.collapsed_chip_at(&geo, center), Some(opener), "hover over the chip finds it");
assert_eq!(ed.collapsed_chip_at(&geo, Point::new(rect.x + rect.width + 200.0, center.y)), None);
assert_eq!(ed.collapsed_chip_at(&geo, Point::new(center.x, 5.0)), None);
}
#[test]
fn block_fold_hover_target_excludes_text_after_the_closer() {
let text = "x\na {\nb\nc\n} trailing text after brace\n";
let mut doc = Document::new(text).unwrap();
let opener = text.find('{').unwrap() as u32;
assert!(doc.toggle_fold_opener(opener)); let ed = Editor::new(&doc, |_: Action| ());
let geo = test_geo();
let fm = ed.fold_map();
let rect = ed.collapsed_chip_rect(&fm, &geo, opener).expect("collapsed header has a chip");
let cy = rect.y + rect.height / 2.0;
assert_eq!(
ed.collapsed_chip_at(&geo, Point::new(rect.x + rect.width / 2.0, cy)),
Some(opener),
);
assert!(
rect.width < 12.0 * geo.advance(),
"hover box stops at the closer, not the trailing text ({}px)",
rect.width
);
assert_eq!(
ed.collapsed_chip_at(&geo, Point::new(geo.cell_x(16.0), cy)),
None,
"text after the closing brace is not part of the fold's hover area"
);
}
#[test]
fn draw_budget_ctrl_a_over_folded_doc_stays_windowed() {
use iced::advanced::{clipboard, mouse, renderer};
use iced::{Font, Pixels, Point as IPoint, Size};
use iced_runtime::user_interface::{Cache, UserInterface};
const N: usize = 3000;
let mut text = String::with_capacity(N * 20);
for i in 0..N {
text.push_str("fn f");
text.push_str(&i.to_string());
text.push_str("() {\n body\n}\n");
}
let mut doc = Document::new(&text).expect("doc fits");
let opens: Vec<u32> = doc.collapsible_pairs().into_iter().map(|(o, ..)| o).collect();
assert!(opens.len() >= N, "every block is foldable ({} of {N})", opens.len());
doc.set_selections(scrive_core::SelectionSet::from_offsets(&opens));
doc.fold_at_carets(false); doc.select_all();
iced_tiny_skia::graphics::text::font_system()
.write()
.expect("font system lock")
.load_font(std::borrow::Cow::Borrowed(crate::CODICON_FONT));
let mut r = iced_renderer::fallback::Renderer::Secondary(
iced_tiny_skia::Renderer::new(Font::default(), Pixels(14.0)),
);
let (w, h) = (500.0_f32, 320.0_f32);
let cursor = mouse::Cursor::Available(IPoint::new(w / 2.0, h / 2.0));
let element: iced::Element<'_, (), iced::Theme, iced::Renderer> =
Editor::new(&doc, |_: Action| ()).into();
let mut ui = UserInterface::build(element, Size::new(w, h), Cache::new(), &mut r);
let mut msgs: Vec<()> = Vec::new();
ui.update(
&[iced::Event::Window(iced::window::Event::RedrawRequested(std::time::Instant::now()))],
cursor,
&mut r,
&mut clipboard::Null,
&mut msgs,
);
ui.draw(&mut r, &iced::Theme::Dark, &renderer::Style::default(), cursor);
let rows = draw_budget::rows();
assert!((8..400).contains(&rows), "Ctrl+A wash over a folded {N}-block doc visited {rows} rows — expected O(viewport), not O(document)");
}
#[test]
fn popup_anchors_are_display_space_below_a_fold() {
use scrive_core::{HoverInfo, PopupList, Selection, SelectionId, SelectionSet};
let mut doc = folded_doc();
let head = doc.buffer().point_to_offset(BufPoint::new(5, 0));
let mut set = SelectionSet::new(0);
set.set_single(Selection::from_anchor(SelectionId(0), head, head));
doc.set_selections(set);
let ed = Editor::new(&doc, |_: Action| ());
let geo = test_geo();
let fm = ed.fold_map();
let (x, top, bottom) = ed.popup_anchor(&fm, &geo, head);
assert_eq!((top, bottom), (20.0, 30.0), "display row 2, not buffer row 5");
assert_eq!(x, 56.0, "gutter 50 + TEXT_PAD 6 + col 0");
let list = PopupList { items: vec![], filtered: vec![], selected: 0, anchor: head };
let (origin, _) = ed.popup_layout(&list, &geo);
assert_eq!(origin.y, 30.0, "completion sits below the DISPLAY row");
let info = HoverInfo { markdown: "hi".into(), range: head..head + 4 };
let l = ed.hover_layout(&info, &geo);
assert_eq!(l.rect.y, top - l.rect.height, "hover sits above the DISPLAY row");
}
#[test]
fn hit_test_inverts_offset_screen_x_across_fold_geometry() {
let text = "f([a, b]) {\ninner\n}\nafter\n";
let mut doc = Document::new(text).unwrap();
let inline_open = text.find('[').unwrap() as u32;
let close = text.find(']').unwrap() as u32;
let block_open = text.find('{').unwrap() as u32;
assert!(doc.toggle_fold_opener(inline_open));
assert!(doc.toggle_fold_opener(block_open));
let ed = Editor::new(&doc, |_: Action| ());
let geo = test_geo();
let fm = ed.fold_map();
let buffer = doc.buffer();
let tail = buffer.point_to_offset(BufPoint::new(2, 0)); let after = buffer.point_to_offset(BufPoint::new(3, 2)); for off in [0, inline_open + 1, close, block_open, tail, after] {
let p = fm.display_position(buffer, off, TAB).expect("landable offset");
let pos = Point::new(ed.offset_screen_x(&fm, &geo, off), geo.row_y(p.row) + 5.0);
assert_eq!(ed.hit_test(&geo, pos), off, "offset {off} round-trips");
}
}
#[test]
fn deep_scroll_row_positions_stay_exact() {
let rows: u32 = 5_900_000;
let doc = Document::new(&"x\n".repeat(rows as usize)).unwrap();
let ed = Editor::new(&doc, |_: Action| ());
let fm = ed.fold_map();
let line_h = 19.0_f32;
let bounds = Rectangle { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
let max_rows = f64::from(rows) + 1.0 - f64::from(bounds.height) / f64::from(line_h);
let anchor = ScrollAnchor::from_rows(max_rows, line_h);
let geo = Geo::new(bounds, 50.0, 10.0, line_h, 0.0, anchor);
let first = fm.display_row_at(geo.rows_from_top(bounds.y));
for i in 0..30 {
let row = fm.to_display_row(BufferRow(first.index() + i));
let next = fm.to_display_row(BufferRow(first.index() + i + 1));
let step = geo.row_y(next) - geo.row_y(row);
assert!((step - line_h).abs() < 0.01, "row {i}: step {step}, want {line_h}");
let hit = fm.display_row_at(geo.rows_from_top(geo.row_y(row) + 1.0));
assert_eq!(hit, row, "row {i} round-trips");
}
assert!((anchor.rows(line_h) - max_rows).abs() < 1e-6);
}
#[test]
fn max_line_px_shrinks_when_the_widest_lines_fold() {
let vp = Rectangle { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
let text = "f() {\n a_very_long_interior_line_wwwwwwwwwwwwwww\n}\nshort\n";
let mut doc = Document::new(text).unwrap();
let unfolded = Editor::new(&doc, |_: Action| ()).max_line_px(10.0, 20.0, vp, 0.0);
let opener = text.find('{').unwrap() as u32;
assert!(doc.toggle_fold_opener(opener));
let ed = Editor::new(&doc, |_: Action| ());
let folded = ed.max_line_px(10.0, 20.0, vp, 0.0);
let fm = ed.fold_map();
let hl = fm.header_layout(doc.buffer(), BufferRow(0), TAB).unwrap();
assert_eq!(folded, hl.width() as f32 * 10.0);
assert!(folded < unfolded, "collapsing the widest line shrinks the h-range");
}
#[test]
fn max_line_px_is_viewport_scoped() {
let mut text = String::from("ab\ncd\n");
text.push_str(&"x".repeat(200));
text.push('\n');
let doc = Document::new(&text).unwrap();
let ed = Editor::new(&doc, |_: Action| ());
let two_rows = Rectangle { x: 0.0, y: 0.0, width: 800.0, height: 40.0 };
assert_eq!(ed.max_line_px(10.0, 20.0, two_rows, 0.0), 2.0 * 10.0, "only visible rows count");
assert_eq!(ed.max_line_px(10.0, 20.0, two_rows, 2.0), 200.0 * 10.0);
}
}