use std::collections::HashSet; pub mod auto_pair; mod blocks_ops; mod content; mod eval; mod find; mod free_layer; mod mode; mod sidecar_io; mod state; mod text_ops; mod types; mod undo; mod update; pub use state::EditorState; #[cfg(all(not(target_os = "ios"), feature = "native-shell"))] pub use sidecar_io::write_clipboard_image_to_cache; pub use types::{ Anchor, ComputedImage, ComputedTable, ComputedTree, ContextMenuState, FindState, FreeNodeId, FreePlacement, ImageCacheEntry, InlinePressState, InlineResult, LayoutMode, LineIndicator, MenuCategory, Message, PromoteDragState, RenderMode, ResizeDragState, SettingsView, ShellAction, TableIndex, DOC_SCROLLABLE_ID, ERROR_PREFIX, FIND_INPUT_ID, REPLACE_INPUT_ID, RESULT_PREFIX, }; use types::{ LayerItem, IMAGE_VPAD, md_style, }; #[cfg(any(target_os = "linux", target_os = "windows"))] use types::{cat_btn_width, MENU_CATS}; use iced_wgpu::core::keyboard::{self}; use iced_wgpu::core::keyboard::key; use iced_wgpu::core::text::Wrapping; use iced_wgpu::core::{ border, alignment, Background, Border, Color, Element, Font, Length, Padding, Shadow, Theme, }; use iced_widget::container; use iced_widget::markdown; use iced_widget::MouseArea; use crate::text_widget::{self, AnchoredItem, Binding, KeyPress, Motion, Status}; use iced_widget::text_input; use iced_wgpu::core::text::highlighter::Format; use iced_wgpu::core::widget::Id as WidgetId; use crate::block::{Block as BlockTrait, ViewCtx}; use crate::heading_block::HeadingBlock; use crate::hr_block::HrBlock; use crate::oklab; use crate::palette; use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings, compute_line_decors}; use crate::table_block::{self, TableBlock, TableMessage}; use crate::text_block::TextBlock; use crate::tree_block::TreeBlock; impl EditorState { pub fn view(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let main_content: Element<'_, Message, Theme, iced_wgpu::Renderer> = if self.preview { let settings = markdown::Settings::with_text_size(self.font_size, md_style()); let preview = markdown::view(&self.parsed, settings) .map(Message::MarkdownLink); iced_widget::container( iced_widget::scrollable( iced_widget::container(preview) .padding(Padding { top: 38.0, right: 16.0, bottom: 16.0, left: 16.0 }) ) .height(Length::Fill) ) .width(Length::Fill) .height(Length::Fill) .style(|_theme: &Theme| { let p = palette::current(); container::Style { background: Some(Background::Color(p.base)), border: Border::default(), text_color: Some(p.text), shadow: Shadow::default(), snap: false, } }) .into() } else { let editor = self.view_blocks(); match self.build_free_overlay() { Some(overlay) => iced_widget::stack![editor, overlay].into(), None => editor, } }; let mode_label = match self.render_mode { RenderMode::Live => "Live", RenderMode::Editor => "Editor", RenderMode::View => "View", }; let cursor = self.content().cursor(); let line = cursor.position.line + 1; let col = cursor.position.column + 1; let render_mode = self.render_mode; let status_bar = iced_widget::container( iced_widget::row([ iced_widget::text(format!("{mode_label} Ln {line}, Col {col}")) .font(Font::MONOSPACE) .size(11.0) .color(oklab::lighten_for_size(Color::WHITE, 11.0)) .into(), ]) ) .width(Length::Fill) .padding(Padding { top: 3.0, right: 10.0, bottom: 3.0, left: 10.0 }) .style(move |_theme: &Theme| { let bg = match render_mode { RenderMode::Live => Color::from_rgb(0.643, 0.184, 0.996), RenderMode::Editor => Color::from_rgb(0.278, 0.745, 0.988), RenderMode::View => Color::from_rgb(0.996, 0.306, 0.910), }; container::Style { background: Some(Background::Color(bg)), border: Border::default(), text_color: None, shadow: Shadow::default(), snap: false, } }); let mut col_items: Vec> = Vec::new(); #[cfg(any(target_os = "linux", target_os = "windows"))] col_items.push(self.menu_strip()); col_items.push(main_content); if self.find.visible { col_items.push(self.find_bar()); } col_items.push(status_bar.into()); let body: Element<'_, Message, Theme, iced_wgpu::Renderer> = iced_widget::column(col_items) .width(Length::Fill) .height(Length::Fill) .into(); if self.settings_open { return iced_widget::stack![body, self.settings_panel()].into(); } #[cfg(any(target_os = "linux", target_os = "windows"))] if let Some(cat) = self.menu_open { return iced_widget::stack![body, self.menu_dropdown(cat)].into(); } body } fn view_blocks(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let has_computed_layers = !self.eval_results.is_empty() || !self.computed_tables.is_empty() || !self.computed_trees.is_empty(); let single_text_block = self.block_count() == 1 && self.block_at(0).map(|b| b.as_any().is::()).unwrap_or(false) && !has_computed_layers; #[cfg(any(target_os = "linux", target_os = "windows"))] let title_bar_h = 0.0_f32; #[cfg(not(any(target_os = "linux", target_os = "windows")))] let title_bar_h = 38.0_f32; let mut block_elements: Vec> = Vec::new(); if !single_text_block && !self.layout.is_empty() { if !self.block_at(0).map(|b| b.as_any().is::()).unwrap_or(true) { block_elements.push( iced_widget::container(iced_widget::text("")) .height(Length::Fixed(title_bar_h)) .width(Length::Fill) .into() ); } } let lang_for_block = self.lang_str(); let hidden_blocks: HashSet = self .free_placements .keys() .filter_map(|id| match id { FreeNodeId::Block(bid) => Some(*bid), _ => None, }) .flat_map(|bid| self.module_block_ids(bid)) .collect(); let mut global_line = 0usize; for (bi, &block_id) in self.layout.iter().enumerate() { if hidden_blocks.contains(&block_id) { continue; } let block = self.registry.get(&block_id).unwrap(); let any = block.as_any(); if let Some(tb) = any.downcast_ref::() { let block_idx = bi; let line_h = self.font_size * 1.3; if single_text_block { let is_focused = bi == self.focused_block; let cursor_line = tb.content.cursor().position.line; let text = tb.content.text(); let decors = compute_line_decors(&text); let anchored_items = self.build_anchored_items(tb.id); let editor = text_widget::TextEditor::new(&tb.content) .id(block_editor_id(tb.id)) .on_action(move |action| Message::BlockAction(block_idx, action)) .font(syntax::EDITOR_FONT) .size(self.font_size) .height(Length::Fill) .padding(Padding { top: title_bar_h, right: 8.0, bottom: 8.0, left: 8.0 }) .wrapping(Wrapping::Word) .key_binding(macos_key_binding) .anchored(anchored_items) .show_gutter(true) .gutter_offset(0) .focused(is_focused) .cursor_line(if is_focused { Some(cursor_line) } else { None }) .line_indicator(self.line_indicator) .gutter_rainbow(self.gutter_rainbow) .line_decors(decors) .style(|_theme, _status| { let p = palette::current(); text_widget::Style { background: Background::Color(p.base), border: Border::default(), placeholder: p.overlay0, value: p.text, selection: Color { a: 0.4, ..p.blue }, } }); let settings = SyntaxSettings { lang: lang_for_block.clone(), source: tb.content.text(), user_idents: self.cached_user_idents.clone(), rules: self.syntax_rules.clone(), heavy_token: self.heavy_token, }; let editor_el: Element<'_, Message, Theme, iced_wgpu::Renderer> = editor .highlight_with::( settings, |highlight, _theme| Format { color: Some(syntax::highlight_color(highlight.kind)), font: syntax::highlight_font(highlight.kind), }, ) .into(); block_elements.push(editor_el); } else { let top_pad = if bi == 0 { title_bar_h } else { 0.0 }; let line_count = tb.content.line_count(); let this_global_line = global_line; global_line += line_count; let _ = line_h; // text_widget::layout owns the height now let _ = lang_for_block; // build_text_block_widget reads lang_str directly block_elements.push(self.build_text_block_widget(tb, block_idx, this_global_line, top_pad)); } continue; } if let Some(tab) = any.downcast_ref::() { let block_idx = bi; let editing_cell = match self.editing.as_ref() { Some(path) if path.block_id == tab.id => match &path.inner { crate::selection::InnerPath::Cell { row, col } => Some((*row, *col)), _ => None, }, _ => None, }; block_elements.push( table_block::table_view( tab, editing_cell, self.font_size, &self.computed_cells, move |tmsg| Message::TableMsg(block_idx, tmsg), ) ); global_line += if tab.rows.is_empty() { 0 } else { tab.rows.len() + 1 }; continue; } let ctx: ViewCtx<'_, Message> = ViewCtx { block_index: bi, selection: &self.selection, focus: self.focus.as_ref(), editing: self.editing.as_ref(), font_size: self.font_size, is_dark: true, on_text_action: |idx, action| Message::BlockAction(idx, action), on_table_msg: |idx, tmsg| Message::TableMsg(idx, tmsg), computed_cells: &self.computed_cells, }; if let Some(hb) = any.downcast_ref::() { let layered = >::view(hb, &ctx); block_elements.push(self.wrap_block_with_promote(layered.base, hb.id)); global_line += 1; continue; } if let Some(hr) = any.downcast_ref::() { let layered = >::view(hr, &ctx); block_elements.push(self.wrap_block_with_promote(layered.base, hr.id)); global_line += 1; continue; } if let Some(tree) = any.downcast_ref::() { let layered = >::view(tree, &ctx); block_elements.push(layered.base); global_line += 1; continue; } } let inner: Element<'_, Message, Theme, iced_wgpu::Renderer> = if block_elements.is_empty() { iced_widget::container(iced_widget::text("")) .width(Length::Fill) .height(Length::Fill) .into() } else if single_text_block { block_elements.remove(0) } else { iced_widget::scrollable( iced_widget::column(block_elements) .width(Length::Fill) ) .id(WidgetId::new(DOC_SCROLLABLE_ID)) .height(Length::Fill) .into() }; let inner: Element<'_, Message, Theme, iced_wgpu::Renderer> = if self.all_blocks_selected { let p = palette::current(); iced_widget::container(inner) .width(Length::Fill) .height(Length::Fill) .style(move |_theme: &Theme| iced_widget::container::Style { background: Some(Background::Color(Color { a: 0.18, ..p.blue })), border: Border::default(), text_color: None, shadow: iced_wgpu::core::Shadow::default(), snap: false, }) .into() } else { inner }; let with_minimap: Element<'_, Message, Theme, iced_wgpu::Renderer> = if let Some(overlay) = self.minimap_overlay() { iced_widget::stack![inner, overlay].into() } else { inner }; let with_ctx: Element<'_, Message, Theme, iced_wgpu::Renderer> = if let Some(menu_state) = &self.context_menu { iced_widget::stack![with_minimap, self.context_menu_view(menu_state)].into() } else { with_minimap }; if let Some(popup) = self.spillover_view() { iced_widget::stack![with_ctx, popup].into() } else { with_ctx } } /// builds the right-edge minimap as a clickable AST declaration list. fn minimap_overlay(&self) -> Option> { if !self.minimap_enabled { return None; } if self.render_mode != RenderMode::Editor { return None; } if self.cached_minimap_lines.is_empty() { return None; } let suppressed = self.minimap_hover_only && !self.minimap_hovered; let data = crate::minimap::MinimapData { entries: self.cached_minimap_lines.clone(), hovered: self.minimap_hovered, suppressed, }; let strip_w = self.font_size * 12.0; let map = crate::minimap::minimap(data, strip_w, self.font_size, Message::MinimapJump); let hover_zone = iced_widget::mouse_area(map) .on_enter(Message::MinimapHover(true)) .on_exit(Message::MinimapHover(false)); let aligned = iced_widget::container(hover_zone) .width(Length::Fill) .height(Length::Fill) .align_x(iced_wgpu::core::alignment::Horizontal::Right); Some(aligned.into()) } /// renders the spillover popup for the first table with an open spillover cell. fn spillover_view(&self) -> Option> { let p = palette::current(); let cell_text = self.layout.iter() .filter_map(|id| self.registry.get(id)) .find_map(|block| { let tb = block.as_any().downcast_ref::()?; let (r, c) = tb.spillover?; tb.rows.get(r).and_then(|row| row.get(c)).cloned() })?; let copy_btn = iced_widget::button( iced_widget::text("Copy") .size(11.0) .font(syntax::EDITOR_FONT) ) .padding(Padding { top: 2.0, right: 8.0, bottom: 2.0, left: 8.0 }) .style(context_menu_item_style) .on_press(Message::CopyLiteral(cell_text.clone())); let close_btn = iced_widget::button( iced_widget::text("\u{2715}") .size(11.0) .font(syntax::EDITOR_FONT) ) .padding(Padding { top: 2.0, right: 8.0, bottom: 2.0, left: 8.0 }) .style(context_menu_item_style) .on_press(Message::FocusedTableOp(TableMessage::CloseSpillover)); let header = iced_widget::row![ iced_widget::Space::new().width(Length::Fill).height(Length::Shrink), copy_btn, close_btn, ] .spacing(4.0) .align_y(iced_wgpu::core::Alignment::Center); let body = iced_widget::scrollable( iced_widget::container( iced_widget::text(cell_text) .size(self.font_size) .font(syntax::EDITOR_FONT) .color(p.text) ) .padding(Padding { top: 6.0, right: 12.0, bottom: 6.0, left: 12.0 }) .width(Length::Fill) ) .height(Length::Fixed(220.0)); let popup = iced_widget::container( iced_widget::column![header, body].spacing(2.0) ) .padding(Padding { top: 6.0, right: 6.0, bottom: 6.0, left: 6.0 }) .width(Length::Fixed(420.0)) .style(move |_theme: &Theme| iced_widget::container::Style { background: Some(Background::Color(p.surface0)), border: Border { color: p.surface1, width: 1.0, radius: 4.0.into(), }, text_color: Some(p.text), shadow: iced_wgpu::core::Shadow::default(), snap: false, }); let popup_el: Element<'_, Message, Theme, iced_wgpu::Renderer> = popup.into(); let v_spacer = iced_widget::Space::new() .width(Length::Shrink) .height(Length::Fixed(60.0)); let h_spacer = iced_widget::Space::new() .width(Length::Fixed(120.0)) .height(Length::Shrink); Some( iced_widget::column![ v_spacer, iced_widget::row![h_spacer, popup_el] ] .into() ) } /// returns (after_line, height) offset pairs for a block's anchored items fn item_offsets(&self, block_id: crate::selection::BlockId) -> Vec<(usize, f32)> { let lh = self.line_height(); self.collect_layer_items(block_id) .iter() .map(|(line, item)| (*line, item.element_height(lh, self.font_size))) .collect() } /// returns layer items for a block sorted by anchor line fn collect_layer_items(&self, block_id: crate::selection::BlockId) -> Vec<(usize, LayerItem<'_>)> { let mut items: Vec<(usize, LayerItem<'_>)> = Vec::new(); for r in &self.eval_results { if r.anchor.block_id == block_id { items.push((r.anchor.after_line, LayerItem::Inline(r))); } } for ct in &self.computed_tables { if ct.anchor.block_id == block_id { let id = FreeNodeId::Table(ct.anchor.block_id, ct.anchor.after_line); if self.free_placements.contains_key(&id) { continue; } items.push((ct.anchor.after_line, LayerItem::Table(ct))); } } for ct in &self.computed_trees { if ct.anchor.block_id == block_id { let id = FreeNodeId::Tree(ct.anchor.block_id, ct.anchor.after_line); if self.free_placements.contains_key(&id) { continue; } items.push((ct.anchor.after_line, LayerItem::Tree(ct))); } } for img in &self.computed_images { if img.anchor.block_id == block_id { let id = FreeNodeId::Image(img.anchor.block_id, img.anchor.after_line, img.src.clone()); if self.free_placements.contains_key(&id) { continue; } items.push((img.anchor.after_line, LayerItem::Image(img))); } } items.sort_by_key(|(line, _)| *line); items } /// builds anchored child elements for the text widget compositor fn build_anchored_items<'a>( &'a self, block_id: crate::selection::BlockId, ) -> Vec> { let p = palette::current(); let lh = self.line_height(); let items = self.collect_layer_items(block_id); let mut anchored = Vec::with_capacity(items.len()); for (after_line, item) in &items { match item { LayerItem::Inline(r) => { let inner = if r.is_error { iced_widget::container( iced_widget::text(&r.text) .font(syntax::EDITOR_FONT) .size(self.font_size) .color(oklab::lighten_for_size(p.red, self.font_size)) ) .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) .width(Length::Fill) } else { let value = r.text .strip_prefix(RESULT_PREFIX) .unwrap_or(&r.text) .to_string(); let arrow_color = oklab::lighten_for_size(palette::eval_arrow_color(), self.font_size); let value_color = oklab::lighten_for_size(palette::eval_value_color(), self.font_size); let bold = Font { weight: iced_wgpu::core::font::Weight::Bold, ..syntax::EDITOR_FONT }; let row = iced_widget::row![ iced_widget::text("→ ") .font(syntax::EDITOR_FONT) .size(self.font_size) .color(arrow_color), iced_widget::text(value) .font(bold) .size(self.font_size) .color(value_color), iced_widget::text(" ←") .font(syntax::EDITOR_FONT) .size(self.font_size) .color(arrow_color), ] .spacing(0.0); iced_widget::container(row) .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) .width(Length::Fill) }; let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = if r.is_error { inner.into() } else { let bid = r.anchor.block_id; let line = r.anchor.after_line; MouseArea::new(inner) .on_press(Message::InlineResultPress { block_id: bid, after_line: line }) .on_release(Message::InlineResultRelease) .on_double_click(Message::InlineResultDoubleClick { block_id: bid, after_line: line }) .into() }; anchored.push(AnchoredItem { after_line: *after_line, height: item.element_height(lh, self.font_size), element: el, }); } LayerItem::Table(ct) => { let inner = self.build_computed_table_widget(ct); let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = iced_widget::container(inner) .padding(Padding { top: 4.0, right: 8.0, bottom: 4.0, left: 40.0 }) .width(Length::Fill) .into(); anchored.push(AnchoredItem { after_line: *after_line, height: item.element_height(lh, self.font_size), element: el, }); } LayerItem::Tree(ct) => { let el = crate::tree_block::build(&ct.data, self.font_size); anchored.push(AnchoredItem { after_line: *after_line, height: item.element_height(lh, self.font_size), element: el, }); } LayerItem::Image(img) => { let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = if let Some(entry) = self.image_cache.get(&img.src) { iced_widget::container( iced_widget::image(entry.handle.clone()) .width(Length::Fill) .height(Length::Fixed(img.display_height)) ) .padding(Padding { top: IMAGE_VPAD, right: 8.0, bottom: IMAGE_VPAD, left: 40.0 }) .width(Length::Fill) .into() } else { iced_widget::container( iced_widget::text(format!("[image: {}]", img.alt)) .font(syntax::EDITOR_FONT) .size(self.font_size) .color(p.overlay0) ) .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) .width(Length::Fill) .into() }; let wrapped = self.wrap_image_with_promote( el, img.anchor.block_id, img.anchor.after_line, img.src.clone(), ); anchored.push(AnchoredItem { after_line: *after_line, height: item.element_height(lh, self.font_size), element: wrapped, }); } } } anchored } /// builds the text-editor widget for a text block at a layout index. fn build_text_block_widget<'a>( &'a self, tb: &'a TextBlock, block_idx: usize, this_global_line: usize, top_pad: f32, ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { let is_focused = block_idx == self.focused_block; let anchored_items = self.build_anchored_items(tb.id); let cursor_line = tb.content.cursor().position.line; let text = tb.content.text(); let decors = compute_line_decors(&text); let lang_for_block = self.lang_str(); let editor = text_widget::TextEditor::new(&tb.content) .id(block_editor_id(tb.id)) .on_action(move |action| Message::BlockAction(block_idx, action)) .font(syntax::EDITOR_FONT) .size(self.font_size) .height(Length::Shrink) .padding(Padding { top: top_pad, right: 8.0, bottom: 4.0, left: 8.0 }) .wrapping(Wrapping::Word) .key_binding(macos_key_binding) .anchored(anchored_items) .show_gutter(true) .gutter_offset(this_global_line) .focused(is_focused) .cursor_line(if is_focused { Some(cursor_line) } else { None }) .line_indicator(self.line_indicator) .gutter_rainbow(self.gutter_rainbow) .line_decors(decors) .style(|_theme, _status| { let p = palette::current(); text_widget::Style { background: Background::Color(p.base), border: Border::default(), placeholder: p.overlay0, value: p.text, selection: Color { a: 0.4, ..p.blue }, } }); let settings = SyntaxSettings { lang: lang_for_block, source: tb.content.text(), user_idents: self.cached_user_idents.clone(), rules: self.syntax_rules.clone(), heavy_token: self.heavy_token, }; editor .highlight_with::( settings, |highlight, _theme| Format { color: Some(syntax::highlight_color(highlight.kind)), font: syntax::highlight_font(highlight.kind), }, ) .into() } /// builds a column of cell rows from a computed table. fn build_computed_table_widget<'a>( &self, ct: &'a ComputedTable, ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { let p = palette::current(); let mut table_rows: Vec> = Vec::new(); for (ri, row) in ct.rows.iter().enumerate() { let is_header = ri == 0; let cells: Vec> = row.iter() .enumerate() .map(|(ci, cell)| { let cw = ct.col_widths.get(ci).copied().unwrap_or(80.0); let mut txt = iced_widget::text(cell) .font(syntax::EDITOR_FONT) .size(self.font_size) .color(oklab::lighten_for_size(p.text, self.font_size)); if is_header { txt = txt.font(Font { weight: iced_wgpu::core::font::Weight::Bold, ..syntax::EDITOR_FONT }); } iced_widget::container(txt) .width(Length::Fixed(cw)) .padding(Padding { top: 2.0, right: 8.0, bottom: 2.0, left: 8.0 }) .style(move |_theme: &Theme| { let bg_alpha = if is_header { 0.12 } else { 0.06 }; container::Style { background: Some(Background::Color(Color { a: bg_alpha, ..p.surface1 })), border: Border { color: p.surface1, width: 0.5, radius: border::Radius::default() }, text_color: None, shadow: Shadow::default(), snap: false, } }) .into() }) .collect(); table_rows.push(iced_widget::row(cells).into()); } iced_widget::column(table_rows).into() } /// builds the context menu overlay for a right-clicked cell fn context_menu_view( &self, state: &ContextMenuState, ) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let p = palette::current(); let block_idx = state.block_idx; let item = |label: &str, msg: Message| -> Element<'_, Message, Theme, iced_wgpu::Renderer> { iced_widget::button( iced_widget::text(label.to_string()) .size(12.0) .font(syntax::EDITOR_FONT) ) .width(Length::Fill) .padding(Padding { top: 4.0, right: 12.0, bottom: 4.0, left: 12.0 }) .style(context_menu_item_style) .on_press(msg) .into() }; let separator: Element<'_, Message, Theme, iced_wgpu::Renderer> = iced_widget::container(iced_widget::text("")) .width(Length::Fill) .height(Length::Fixed(1.0)) .style(move |_theme: &Theme| iced_widget::container::Style { background: Some(Background::Color(p.surface1)), border: Border::default(), text_color: None, shadow: iced_wgpu::core::Shadow::default(), snap: false, }) .into(); let menu_items: Vec> = vec![ item( "Insert row above", Message::FocusedTableOp(TableMessage::InsertRowAbove), ), item( "Insert row below", Message::FocusedTableOp(TableMessage::InsertRowBelow), ), item("Delete row", Message::FocusedTableOp(TableMessage::DeleteRow)), separator, item( "Insert column left", Message::FocusedTableOp(TableMessage::InsertColLeft), ), item( "Insert column right", Message::FocusedTableOp(TableMessage::InsertColRight), ), item( "Delete column", Message::FocusedTableOp(TableMessage::DeleteCol), ), iced_widget::container(iced_widget::text("")) .width(Length::Fill) .height(Length::Fixed(1.0)) .style(move |_theme: &Theme| iced_widget::container::Style { background: Some(Background::Color(p.surface1)), border: Border::default(), text_color: None, shadow: iced_wgpu::core::Shadow::default(), snap: false, }) .into(), item( "Select all", Message::TableMsg(block_idx, TableMessage::SelectAll), ), { let wrap_on = self.table_block_at(block_idx) .map(|tb| tb.wrap) .unwrap_or(true); item( if wrap_on { "Wrap: on" } else { "Wrap: off" }, Message::TableMsg(block_idx, TableMessage::ToggleWrap), ) }, item("Delete table", Message::DeleteCurrentTable), ]; let menu = iced_widget::container( iced_widget::column(menu_items).spacing(0.0).width(Length::Fixed(180.0)) ) .style(move |_theme: &Theme| iced_widget::container::Style { background: Some(Background::Color(p.surface0)), border: Border { color: p.surface1, width: 1.0, radius: 4.0.into(), }, text_color: Some(p.text), shadow: iced_wgpu::core::Shadow::default(), snap: false, }); let menu_element: Element<'_, Message, Theme, iced_wgpu::Renderer> = menu.into(); let v_spacer = iced_widget::Space::new() .width(Length::Shrink) .height(Length::Fixed(state.y)); let h_spacer = iced_widget::Space::new() .width(Length::Fixed(state.x)) .height(Length::Shrink); iced_widget::column![ v_spacer, iced_widget::row![h_spacer, menu_element] ] .into() } #[cfg(any(target_os = "linux", target_os = "windows"))] fn menu_strip(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let p = palette::current(); let f = self.font_size; let char_w = f * 0.6; let cat_pad_x = f * 0.85; let strip_pad_y = f * 0.18; let strip_label_size = f * 0.92; let mut row: Vec> = Vec::new(); for (cat, label) in MENU_CATS { let active = self.menu_open == Some(cat); row.push( iced_widget::button( iced_widget::text(label.to_string()) .size(strip_label_size) .font(syntax::EDITOR_FONT) ) .width(Length::Fixed(cat_btn_width(label, char_w, cat_pad_x))) .padding(Padding { top: strip_pad_y, right: cat_pad_x, bottom: strip_pad_y, left: cat_pad_x }) .style(move |_t: &Theme, _s| iced_widget::button::Style { background: if active { Some(Background::Color(p.surface1)) } else { None }, text_color: p.text, border: Border::default(), shadow: Shadow::default(), snap: false, }) .on_press(Message::ToggleMenu(cat)) .into() ); } iced_widget::container(iced_widget::row(row).spacing(0.0)) .width(Length::Fill) .style(move |_t: &Theme| iced_widget::container::Style { background: Some(Background::Color(p.mantle)), border: Border::default(), text_color: Some(p.text), shadow: Shadow::default(), snap: false, }) .into() } /// returns the dropdown panel for the open category, anchored under the strip button. #[cfg(any(target_os = "linux", target_os = "windows"))] fn menu_dropdown(&self, cat: MenuCategory) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let p = palette::current(); let f = self.font_size; let char_w = f * 0.6; let cat_pad_x = f * 0.85; let strip_pad_y = f * 0.18; let strip_label_size = f * 0.92; let item_pad_x = f * 0.95; let item_pad_y = f * 0.32; let dropdown_radius = f * 0.30; let separator_h = (f * 0.08).max(1.0); let label_size = f * 0.85; let hint_size = f * 0.78; let strip_h = strip_label_size * 1.3 + strip_pad_y * 2.0; let item = |label: &str, shortcut: &str, msg: Message| -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let label_w = iced_widget::text(label.to_string()) .size(label_size) .font(syntax::EDITOR_FONT) .width(Length::Fill); let hint_w = iced_widget::text(shortcut.to_string()) .size(hint_size) .font(syntax::EDITOR_FONT) .color(p.overlay0); iced_widget::button( iced_widget::row![label_w, hint_w].spacing(f) ) .width(Length::Fill) .padding(Padding { top: item_pad_y, right: item_pad_x, bottom: item_pad_y, left: item_pad_x }) .style(context_menu_item_style) .on_press(msg) .into() }; let sep = || -> Element<'_, Message, Theme, iced_wgpu::Renderer> { iced_widget::container(iced_widget::text("")) .width(Length::Fill) .height(Length::Fixed(separator_h)) .style(move |_t: &Theme| iced_widget::container::Style { background: Some(Background::Color(p.surface1)), border: Border::default(), text_color: None, shadow: Shadow::default(), snap: false, }) .into() }; let items: Vec> = match cat { MenuCategory::File => vec![ item("New Note", "Ctrl+N", Message::Shell(ShellAction::NewNote)), item("Open...", "Ctrl+O", Message::Shell(ShellAction::Open)), item("Documents...", "Alt+B", Message::Shell(ShellAction::ToggleBrowser)), sep(), item("Save", "Ctrl+S", Message::Shell(ShellAction::Save)), item("Save As...", "Ctrl+Shift+S", Message::Shell(ShellAction::SaveAs)), sep(), item("Export as Rust Library", "Ctrl+Shift+E", Message::Shell(ShellAction::ExportCrate)), item("Print...", "Ctrl+P", Message::Shell(ShellAction::Print)), sep(), item("Settings...", "Ctrl+,", Message::Shell(ShellAction::Settings)), item("Quit", "Ctrl+Q", Message::Shell(ShellAction::Quit)), ], MenuCategory::Edit => vec![ item("Undo", "Ctrl+Z", Message::Undo), item("Redo", "Ctrl+Shift+Z", Message::Redo), sep(), item("Bold", "Ctrl+B", Message::ToggleBold), item("Italic", "Ctrl+I", Message::ToggleItalic), item("Insert Table", "Ctrl+T", Message::InsertTable), sep(), item("Find...", "Ctrl+F", Message::ToggleFind), ], MenuCategory::Render => vec![ item("Live", "", Message::SetRenderMode(RenderMode::Live)), item("Editor", "", Message::SetRenderMode(RenderMode::Editor)), item("View", "", Message::SetRenderMode(RenderMode::View)), sep(), item("Evaluate", "Ctrl+E", Message::SmartEval), ], MenuCategory::Mode => vec![ item("Free", if matches!(self.layout_mode, LayoutMode::Free) { "•" } else { "" }, Message::SetLayoutMode(LayoutMode::Free)), item("Relative", if matches!(self.layout_mode, LayoutMode::Relative) { "•" } else { "" }, Message::SetLayoutMode(LayoutMode::Relative)), item("Anchored", if matches!(self.layout_mode, LayoutMode::Anchored) { "•" } else { "" }, Message::SetLayoutMode(LayoutMode::Anchored)), sep(), item("Snapping", if self.snapping { "✓" } else { "" }, Message::ToggleSnapping), ], MenuCategory::View => vec![ item("Zoom In", "Ctrl+=", Message::ZoomIn), item("Zoom Out", "Ctrl+-", Message::ZoomOut), item("Reset Zoom", "Ctrl+Shift+0", Message::ZoomReset), ], }; let mut x_offset = 0.0_f32; for (c, label) in MENU_CATS { if c == cat { break; } x_offset += cat_btn_width(label, char_w, cat_pad_x); } let dropdown_width = { let max_label_chars = match cat { MenuCategory::File => "Export as Rust Library".len(), MenuCategory::Edit => "Insert Table".len(), MenuCategory::Render => "Evaluate".len(), MenuCategory::Mode => "Anchored".len(), MenuCategory::View => "Reset Zoom".len(), }; let max_hint_chars = 13_usize; // widest hint string in chars (max_label_chars + max_hint_chars) as f32 * char_w + item_pad_x * 2.0 + f }; let panel = iced_widget::container( iced_widget::column(items).spacing(0.0).width(Length::Fixed(dropdown_width)) ) .style(move |_t: &Theme| iced_widget::container::Style { background: Some(Background::Color(p.surface0)), border: Border { color: p.surface1, width: 1.0, radius: dropdown_radius.into(), }, text_color: Some(p.text), shadow: Shadow::default(), snap: false, }); iced_widget::column![ iced_widget::Space::new().width(Length::Shrink).height(Length::Fixed(strip_h)), iced_widget::row![ iced_widget::Space::new().width(Length::Fixed(x_offset)).height(Length::Shrink), panel, ], ] .into() } fn settings_panel(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let p = palette::current(); let f = self.font_size; let item_pad_x = f * 0.95; let item_pad_y = f * 0.32; let panel_radius = f * 0.30; let label_size = f * 0.92; let title_size = f * 1.05; let row_gap = f * 0.55; let panel_width = f * 28.0; let title = iced_widget::text("Settings") .size(title_size) .font(syntax::EDITOR_FONT) .color(p.text); let theme_row = self.settings_segment_row( "Theme", label_size, &[ ("Auto", "auto"), ("Light", "light"), ("Dark", "dark"), ], &self.settings_view.theme_mode, |v| Message::Shell(ShellAction::SetThemeMode(v.to_string())), ); let line_row = self.settings_segment_row( "Line indicator", label_size, &[ ("On", "on"), ("Off", "off"), ("Vim", "vim"), ], &self.settings_view.line_indicator, |v| Message::Shell(ShellAction::SetLineIndicator(v.to_string())), ); let rainbow_row = self.settings_segment_row( "Gutter rainbow", label_size, &[ ("Off", "false"), ("On", "true"), ], if self.settings_view.gutter_rainbow { "true" } else { "false" }, |v| Message::Shell(ShellAction::SetGutterRainbow(v == "true")), ); let dir_label = iced_widget::text("Auto-save folder") .size(label_size) .font(syntax::EDITOR_FONT) .color(p.text) .width(Length::Fill); let dir_value = iced_widget::text(self.settings_view.auto_save_dir.clone()) .size(label_size) .font(syntax::EDITOR_FONT) .color(p.subtext0) .width(Length::Fill); let dir_btn = iced_widget::button( iced_widget::text("Choose…") .size(label_size) .font(syntax::EDITOR_FONT) ) .padding(Padding { top: item_pad_y * 0.6, right: item_pad_x * 0.7, bottom: item_pad_y * 0.6, left: item_pad_x * 0.7 }) .on_press(Message::Shell(ShellAction::PickAutoSaveDir)) .style(context_menu_item_style); let dir_row: Element<'_, Message, Theme, iced_wgpu::Renderer> = iced_widget::column![ dir_label, iced_widget::row![dir_value, dir_btn].spacing(f * 0.5), ] .spacing(f * 0.2) .into(); let close_btn = iced_widget::button( iced_widget::text("Close") .size(label_size) .font(syntax::EDITOR_FONT) ) .padding(Padding { top: item_pad_y * 0.6, right: item_pad_x, bottom: item_pad_y * 0.6, left: item_pad_x }) .on_press(Message::Shell(ShellAction::Settings)) .style(context_menu_item_style); let panel = iced_widget::container( iced_widget::column![ title, theme_row, line_row, rainbow_row, dir_row, iced_widget::row![ iced_widget::Space::new().width(Length::Fill).height(Length::Shrink), close_btn, ], ] .spacing(row_gap) .width(Length::Fixed(panel_width)) ) .padding(Padding { top: f, right: f, bottom: f, left: f }) .style(move |_t: &Theme| iced_widget::container::Style { background: Some(Background::Color(p.surface0)), border: Border { color: p.surface1, width: 1.0, radius: panel_radius.into(), }, text_color: Some(p.text), shadow: Shadow::default(), snap: false, }); iced_widget::container(panel) .width(Length::Fill) .height(Length::Fill) .center_x(Length::Fill) .center_y(Length::Fill) .style(move |_t: &Theme| iced_widget::container::Style { background: Some(Background::Color(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.4 })), border: Border::default(), text_color: None, shadow: Shadow::default(), snap: false, }) .into() } fn settings_segment_row<'a>( &'a self, label: &str, label_size: f32, options: &[(&str, &'a str)], current: &str, msg_for: impl Fn(&'a str) -> Message, ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { let p = palette::current(); let f = self.font_size; let mut buttons: Vec> = Vec::new(); for (display, value) in options { let active = *value == current; let display = display.to_string(); let value = *value; buttons.push( iced_widget::button( iced_widget::text(display) .size(label_size) .font(syntax::EDITOR_FONT) ) .padding(Padding { top: f * 0.18, right: f * 0.55, bottom: f * 0.18, left: f * 0.55 }) .style(move |_t: &Theme, _s| iced_widget::button::Style { background: if active { Some(Background::Color(p.surface2)) } else { Some(Background::Color(p.surface1)) }, text_color: if active { p.text } else { p.subtext0 }, border: Border { color: p.surface2, width: 1.0, radius: (f * 0.18).into() }, shadow: Shadow::default(), snap: false, }) .on_press(msg_for(value)) .into() ); } let label_w = iced_widget::text(label.to_string()) .size(label_size) .font(syntax::EDITOR_FONT) .color(p.text) .width(Length::Fill); iced_widget::row![ label_w, iced_widget::row(buttons).spacing(f * 0.25), ] .spacing(f) .into() } fn find_bar(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let p = palette::current(); let search_input = text_input::TextInput::new("Find...", &self.find.query) .on_input(Message::FindQueryChanged) .on_submit(Message::FindNext) .id(WidgetId::new(FIND_INPUT_ID)) .font(Font::MONOSPACE) .size(13.0) .padding(Padding { top: 3.0, right: 6.0, bottom: 3.0, left: 6.0 }) .width(Length::FillPortion(3)) .style(find_input_style); let replace_input = text_input::TextInput::new("Replace...", &self.find.replacement) .on_input(Message::ReplaceQueryChanged) .on_submit(Message::ReplaceOne) .id(WidgetId::new(REPLACE_INPUT_ID)) .font(Font::MONOSPACE) .size(13.0) .padding(Padding { top: 3.0, right: 6.0, bottom: 3.0, left: 6.0 }) .width(Length::FillPortion(3)) .style(find_input_style); let match_label = if let Some(ref err) = self.find.regex_error { let short = if err.len() > 20 { format!("{}...", &err[..20]) } else { err.clone() }; short } else if self.find.matches.is_empty() { if self.find.query.is_empty() { String::new() } else { "0/0".into() } } else { format!("{}/{}", self.find.current + 1, self.find.matches.len()) }; let label: Element<'_, Message, Theme, iced_wgpu::Renderer> = iced_widget::text(match_label) .font(Font::MONOSPACE) .size(11.0) .color(oklab::lighten_for_size(p.overlay1, 11.0)) .into(); let btn = |txt: String, msg: Message| -> Element<'_, Message, Theme, iced_wgpu::Renderer> { iced_widget::button( iced_widget::text(txt).font(Font::MONOSPACE).size(11.0) ) .on_press(msg) .padding(Padding { top: 2.0, right: 6.0, bottom: 2.0, left: 6.0 }) .style(find_btn_style) .into() }; let regex_label = if self.find.regex_mode { ".*" } else { "Aa" }; let row = iced_widget::row![ btn(regex_label.into(), Message::ToggleRegex), search_input, label, btn("Prev".into(), Message::FindPrev), btn("Next".into(), Message::FindNext), replace_input, btn("Repl".into(), Message::ReplaceOne), btn("All".into(), Message::ReplaceAll), btn("X".into(), Message::HideFind), ] .spacing(4.0) .align_y(alignment::Vertical::Center); iced_widget::container(row) .width(Length::Fill) .padding(Padding { top: 4.0, right: 8.0, bottom: 4.0, left: 8.0 }) .style(|_theme: &Theme| { let p = palette::current(); container::Style { background: Some(Background::Color(p.mantle)), border: Border::default(), text_color: None, shadow: Shadow::default(), snap: false, } }) .into() } } fn find_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { let p = palette::current(); text_input::Style { background: Background::Color(p.surface0), border: Border { color: p.surface2, width: 1.0, radius: 3.0.into(), }, icon: p.overlay2, placeholder: p.overlay0, value: p.text, selection: Color { a: 0.4, ..p.blue }, } } fn find_btn_style( _theme: &Theme, _status: iced_widget::button::Status, ) -> iced_widget::button::Style { let p = palette::current(); iced_widget::button::Style { background: Some(Background::Color(p.surface1)), text_color: p.text, border: Border { color: p.surface2, width: 1.0, radius: 3.0.into(), }, shadow: Shadow::default(), snap: false, } } fn context_menu_item_style( _theme: &Theme, status: iced_widget::button::Status, ) -> iced_widget::button::Style { let p = palette::current(); let bg = match status { iced_widget::button::Status::Hovered => Some(Background::Color(p.surface1)), iced_widget::button::Status::Pressed => Some(Background::Color(p.surface2)), _ => None, }; iced_widget::button::Style { background: bg, text_color: p.text, border: Border::default(), shadow: Shadow::default(), snap: false, } } fn is_result_line(line: &str) -> bool { let trimmed = line.trim_start(); trimmed.starts_with(RESULT_PREFIX) || trimmed.starts_with(ERROR_PREFIX) } fn strip_result_lines(text: &str) -> String { let lines: Vec<&str> = text.lines().filter(|l| !is_result_line(l)).collect(); let mut result = lines.join("\n"); if text.ends_with('\n') { result.push('\n'); } result } fn block_editor_id(block_id: u64) -> WidgetId { WidgetId::from(format!("block_editor_{block_id}")) } /// finds the first empty [] slot and replaces the contents with the given address. fn splice_first_empty_slot(text: &str, addr: &str) -> Option { let bytes = text.as_bytes(); let mut i = 0; while i < bytes.len() { if bytes[i] == b'[' { let mut j = i + 1; while j < bytes.len() && bytes[j].is_ascii_whitespace() { j += 1; } if j < bytes.len() && bytes[j] == b']' { let mut out = String::with_capacity(text.len() + addr.len()); out.push_str(&text[..i + 1]); out.push_str(addr); out.push_str(&text[j..]); return Some(out); } } i += 1; } None } fn macos_key_binding(key_press: KeyPress) -> Option> { let KeyPress { key, modifiers, status, .. } = &key_press; if !matches!(status, Status::Focused { .. }) { return None; } match key.as_ref() { keyboard::Key::Character("z") if modifiers.logo() && modifiers.shift() => { Some(Binding::Custom(Message::Redo)) } keyboard::Key::Character("z") if modifiers.logo() => { Some(Binding::Custom(Message::Undo)) } keyboard::Key::Character("=" | "+") if modifiers.logo() => { Some(Binding::Custom(Message::ZoomIn)) } keyboard::Key::Character("-") if modifiers.logo() => { Some(Binding::Custom(Message::ZoomOut)) } keyboard::Key::Character("[") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::BRACKET) => { Some(Binding::Custom(Message::AutoPair("[", "]"))) } keyboard::Key::Character("{") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::BRACE) => { Some(Binding::Custom(Message::AutoPair("{", "}"))) } keyboard::Key::Character("(") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::PAREN) => { Some(Binding::Custom(Message::AutoPair("(", ")"))) } keyboard::Key::Character("'") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::SINGLE) => { Some(Binding::Custom(Message::AutoPair("'", "'"))) } keyboard::Key::Character("\"") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::DOUBLE) => { Some(Binding::Custom(Message::AutoPair("\"", "\""))) } keyboard::Key::Character("`") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::BACKTICK) => { Some(Binding::Custom(Message::AutoPair("`", "`"))) } keyboard::Key::Named(key::Named::Backspace) if modifiers.alt() => { Some(Binding::Sequence(vec![ Binding::Select(Motion::WordLeft), Binding::Backspace, ])) } keyboard::Key::Named(key::Named::Delete) if modifiers.alt() => { Some(Binding::Sequence(vec![ Binding::Select(Motion::WordRight), Binding::Delete, ])) } keyboard::Key::Named(key::Named::ArrowUp) if modifiers.logo() && modifiers.shift() => { Some(Binding::Select(Motion::DocumentStart)) } keyboard::Key::Named(key::Named::ArrowDown) if modifiers.logo() && modifiers.shift() => { Some(Binding::Select(Motion::DocumentEnd)) } keyboard::Key::Named(key::Named::ArrowUp) if modifiers.logo() => { Some(Binding::Move(Motion::DocumentStart)) } keyboard::Key::Named(key::Named::ArrowDown) if modifiers.logo() => { Some(Binding::Move(Motion::DocumentEnd)) } keyboard::Key::Named(key::Named::Tab) if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && modifiers.shift() => { Some(Binding::Custom(Message::OutdentTab)) } keyboard::Key::Named(key::Named::Tab) if !modifiers.logo() && !modifiers.alt() && !modifiers.control() => { Some(Binding::Custom(Message::IndentTab)) } _ => Binding::from_key_press(key_press), } } fn lang_from_extension(ext: &str) -> Option { let lang = match ext { "rs" => "rust", "c" | "h" => "c", "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp", "js" | "mjs" | "cjs" => "javascript", "jsx" => "jsx", "ts" | "mts" | "cts" => "typescript", "tsx" => "tsx", "py" => "python", "go" => "go", "rb" => "ruby", "sh" | "bash" | "zsh" => "bash", "java" => "java", "html" | "htm" => "html", "css" => "css", "scss" => "scss", "less" => "less", "json" => "json", "lua" => "lua", "php" => "php", "toml" => "toml", "yaml" | "yml" => "yaml", "swift" => "swift", "zig" => "zig", "sql" => "sql", "mk" => "make", // cordial uses a dedicated line-classifier, no tree-sitter binding. "cord" | "cordial" => return None, _ => return None, }; Some(lang.to_string()) }