Acord/viewport/src/editor/mod.rs

1535 lines
61 KiB
Rust

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<Element<'_, Message, Theme, iced_wgpu::Renderer>> = 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::<TextBlock>()).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<Element<'_, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
if !single_text_block && !self.layout.is_empty() {
if !self.block_at(0).map(|b| b.as_any().is::<TextBlock>()).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<crate::selection::BlockId> = 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::<TextBlock>() {
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::<SyntaxHighlighter>(
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::<TableBlock>() {
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::<HeadingBlock>() {
let layered = <HeadingBlock as BlockTrait<Message>>::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::<HrBlock>() {
let layered = <HrBlock as BlockTrait<Message>>::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::<TreeBlock>() {
let layered = <TreeBlock as BlockTrait<Message>>::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<Element<'_, Message, Theme, iced_wgpu::Renderer>> {
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<Element<'_, Message, Theme, iced_wgpu::Renderer>> {
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::<TableBlock>()?;
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<AnchoredItem<'a, Message>> {
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::<SyntaxHighlighter>(
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<Element<'a, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
for (ri, row) in ct.rows.iter().enumerate() {
let is_header = ri == 0;
let cells: Vec<Element<'a, Message, Theme, iced_wgpu::Renderer>> = 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<Element<'_, Message, Theme, iced_wgpu::Renderer>> = 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<Element<'_, Message, Theme, iced_wgpu::Renderer>> = 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<Element<'_, Message, Theme, iced_wgpu::Renderer>> = 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<Element<'a, Message, Theme, iced_wgpu::Renderer>> = 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<String> {
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<Binding<Message>> {
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<String> {
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())
}