1535 lines
61 KiB
Rust
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())
|
|
}
|
|
|