use std::cell::RefCell;
use std::fmt::Write;
use std::ops::Range;
use std::sync::LazyLock;
use eframe::egui;
use typst::syntax::Span;
use crate::themes::colorhash::RAL_CATEGORICAL;
use crate::themes::ral::RAL_COLORS;
use super::typst_render::painter;
use super::typst_render::world::GorbieWorld;
struct TypstState {
world: GorbieWorld,
}
impl TypstState {
fn new() -> Self {
Self {
world: GorbieWorld::new(),
}
}
}
thread_local! {
static TYPST_STATE: RefCell<TypstState> = RefCell::new(TypstState::new());
}
pub fn typst(ui: &mut egui::Ui, source: &str) {
TYPST_STATE.with(|state| {
let mut state = state.borrow_mut();
render_typst(ui, &mut state, source, 0);
});
}
pub fn typst_math_inline(ui: &mut egui::Ui, expr: &str) {
let fg = typst_rgb(ui.visuals().text_color());
let size = ui.style()
.text_styles
.get(&egui::TextStyle::Body)
.map_or(15.0, |d| d.size);
let preamble = format!(
"#set page(width: auto, height: auto, margin: 0pt)\n\
#set text(size: {size}pt, fill: {fg})\n"
);
let source = format!("{preamble}${expr}$");
TYPST_STATE.with(|state| {
let mut state = state.borrow_mut();
render_typst(ui, &mut state, &source, preamble.len());
});
}
pub fn typst_math_display(ui: &mut egui::Ui, expr: &str) {
let fg = typst_rgb(ui.visuals().text_color());
let size = ui.style()
.text_styles
.get(&egui::TextStyle::Body)
.map_or(15.0, |d| d.size);
let preamble = format!(
"#set page(width: auto, height: auto, margin: 0pt)\n\
#set text(size: {size}pt, fill: {fg})\n"
);
let source = format!("{preamble}$ {expr} $");
TYPST_STATE.with(|state| {
let mut state = state.borrow_mut();
render_typst(ui, &mut state, &source, preamble.len());
});
}
pub fn typst_math_fn() -> impl Fn(&mut egui::Ui, &str, bool) {
|ui: &mut egui::Ui, expr: &str, display: bool| {
if display {
typst_math_display(ui, expr);
} else {
typst_math_inline(ui, expr);
}
}
}
pub fn typst_with_preamble(ui: &mut egui::Ui, content: &str) {
let width = ui.available_width();
let pad = crate::card_ctx::GRID_EDGE_PAD;
let body_size = ui.style()
.text_styles
.get(&egui::TextStyle::Body)
.map_or(15.0, |d| d.size);
let palette = ral_preamble(ui);
let preamble = format!(
"{palette}\
#set page(width: {width}pt, height: auto, margin: (x: {pad}pt, top: {pad}pt, bottom: {pad}pt))\n\
#set text(size: {body_size}pt, font: \"IosevkaGorbie\", fill: ral-fg)\n\
#set table(stroke: ral-fg)\n\
#set line(stroke: ral-fg)\n\
#set rect(stroke: ral-fg)\n\
#set circle(stroke: ral-fg)\n\
#set ellipse(stroke: ral-fg)\n\
#set polygon(stroke: ral-fg)\n\
#set path(stroke: ral-fg)\n\
#show link: set text(fill: ral-blue)\n"
);
let source = format!("{preamble}{content}");
TYPST_STATE.with(|state| {
let mut state = state.borrow_mut();
render_typst(ui, &mut state, &source, preamble.len());
});
}
#[macro_export]
macro_rules! typst {
($ctx:expr, $fmt:expr $(, $args:expr)*) => {
{
let text = format!($fmt $(, $args)*);
$crate::widgets::typst_widget::typst_with_preamble($ctx, &text);
}
};
}
fn typst_rgb(c: egui::Color32) -> String {
format!("rgb(\"#{:02X}{:02X}{:02X}\")", c.r(), c.g(), c.b())
}
const RAL_CAT_NAMES: &[&str] = &[
"yellow", "orange", "pink", "red", "violet", "blue",
"sky", "water", "lime", "mint", "green", "teal",
];
static RAL_TYPST_STATIC: LazyLock<String> = LazyLock::new(|| {
use crate::card_ctx::{GRID_COL_WIDTH, GRID_GUTTER, GRID_COLUMNS, GRID_ROW_MODULE};
let mut s = String::with_capacity(16384);
let _ = writeln!(s, "#let grid-col = {GRID_COL_WIDTH}pt");
let _ = writeln!(s, "#let grid-gutter = {GRID_GUTTER}pt");
let _ = writeln!(s, "#let grid-columns = {GRID_COLUMNS}");
let _ = writeln!(s, "#let grid-row = {GRID_ROW_MODULE}pt");
let _ = writeln!(s, "#let grid-span(n) = n * {GRID_COL_WIDTH}pt + (n - 1) * {GRID_GUTTER}pt");
let _ = writeln!(s, "#let ral-table = (");
for &(code, _, color) in RAL_COLORS {
let _ = writeln!(
s,
" \"{code}\": rgb(\"#{:02X}{:02X}{:02X}\"),",
color.r(),
color.g(),
color.b()
);
}
let _ = writeln!(s, ")");
let _ = writeln!(s, "#let ral(num) = ral-table.at(str(num))");
let _ = writeln!(s, "#let ral-accent = ral(2009)");
for (&code, &name) in RAL_CATEGORICAL.iter().zip(RAL_CAT_NAMES) {
let _ = writeln!(s, "#let ral-{name} = ral({code})");
}
s
});
pub fn ral_preamble(ui: &egui::Ui) -> String {
let fg = ui.visuals().text_color();
let bg = ui.visuals().panel_fill;
let mut s = RAL_TYPST_STATIC.clone();
let _ = writeln!(s, "#let ral-fg = {}", typst_rgb(fg));
let _ = writeln!(s, "#let ral-bg = {}", typst_rgb(bg));
s
}
#[derive(Clone, Default)]
struct TypstSelection {
anchor: Option<egui::Pos2>,
cursor: Option<egui::Pos2>,
glyph_override: Option<Range<usize>>,
}
impl TypstSelection {
fn range(&self, chars: &[painter::PositionedChar]) -> Option<Range<usize>> {
if let Some(ref r) = self.glyph_override {
return Some(r.clone());
}
let anchor = self.anchor?;
let cursor = self.cursor?;
let a = nearest_glyph(chars, anchor)?;
let b = nearest_glyph(chars, cursor)?;
let lo = a.min(b);
let hi = a.max(b);
Some(lo..hi + 1)
}
}
fn nearest_glyph(chars: &[painter::PositionedChar], pos: egui::Pos2) -> Option<usize> {
chars.iter().enumerate().min_by(|(_, a), (_, b)| {
let da = a.rect.center().distance_sq(pos);
let db = b.rect.center().distance_sq(pos);
da.partial_cmp(&db).unwrap()
}).map(|(i, _)| i)
}
fn double_click_range(
source: &typst::syntax::Source,
chars: &[painter::PositionedChar],
pos: egui::Pos2,
) -> Option<Range<usize>> {
use typst::syntax::SyntaxKind;
let idx = nearest_glyph(chars, pos)?;
let (span, _) = chars[idx].span;
let node = source.find(span)?;
let mut current = &node;
loop {
let dominated_by = |k: SyntaxKind| matches!(
k,
SyntaxKind::Strong | SyntaxKind::Emph | SyntaxKind::Equation
| SyntaxKind::Heading | SyntaxKind::ListItem | SyntaxKind::EnumItem
| SyntaxKind::TermItem | SyntaxKind::FuncCall
| SyntaxKind::Markup
);
if dominated_by(current.kind()) {
let nr = current.range();
let lo = chars.iter().position(|ch| {
let (s, off) = ch.span;
source.range(s).map_or(false, |r| r.start + (off as usize) >= nr.start)
})?;
let hi = chars.iter().rposition(|ch| {
let (s, off) = ch.span;
source.range(s).map_or(false, |r| r.start + (off as usize) < nr.end)
})?;
return Some(lo..hi + 1);
}
match current.parent() {
Some(p) => current = p,
None => break,
}
}
let is_word = |i: usize| -> bool {
let ch = chars[i].text.chars().next().unwrap_or(' ');
ch.is_alphanumeric() || ch == '_' || ch == '-'
};
let mut lo = idx;
while lo > 0 && is_word(lo - 1) { lo -= 1; }
let mut hi = idx;
while hi + 1 < chars.len() && is_word(hi + 1) { hi += 1; }
Some(lo..hi + 1)
}
#[derive(Clone)]
struct SelectionResult {
sel_set: Vec<bool>,
copy_range: Option<Range<usize>>,
}
fn compute_selection(
source: &typst::syntax::Source,
chars: &[painter::PositionedChar],
glyph_range: &Option<Range<usize>>,
) -> SelectionResult {
let Some(ref range) = glyph_range else {
return SelectionResult { sel_set: Vec::new(), copy_range: None };
};
let mut min_byte = usize::MAX;
let mut max_byte = 0usize;
for i in range.clone() {
if i >= chars.len() { continue; }
let (span, offset) = chars[i].span;
if let Some(node_range) = source.range(span) {
let glyph_start = node_range.start + offset as usize;
let glyph_end = (glyph_start + chars[i].text.len()).min(node_range.end);
min_byte = min_byte.min(glyph_start);
max_byte = max_byte.max(glyph_end);
}
}
expand_copy_from_ast(source, chars, min_byte, max_byte, &mut min_byte, &mut max_byte);
let mut sel_set = vec![false; chars.len()];
if min_byte < max_byte {
for (i, ch) in chars.iter().enumerate() {
let (span, offset) = ch.span;
if let Some(node_range) = source.range(span) {
let glyph_start = node_range.start + offset as usize;
let glyph_end = (glyph_start + ch.text.len()).min(node_range.end);
if glyph_start < max_byte && glyph_end > min_byte {
sel_set[i] = true;
}
}
}
}
SelectionResult {
sel_set,
copy_range: if min_byte < max_byte { Some(min_byte..max_byte) } else { None },
}
}
fn expand_copy_from_ast(
source: &typst::syntax::Source,
chars: &[painter::PositionedChar],
in_min: usize,
in_max: usize,
min_byte: &mut usize,
max_byte: &mut usize,
) {
if in_min >= in_max { return; }
*min_byte = in_min;
*max_byte = in_max;
let find_span_near = |target: usize| -> Option<Span> {
chars.iter()
.filter_map(|ch| {
let (span, offset) = ch.span;
let nr = source.range(span)?;
let pos = nr.start + offset as usize;
Some((span, pos.abs_diff(target)))
})
.filter(|(_, dist)| *dist < 1000) .min_by_key(|(_, dist)| *dist)
.map(|(span, _)| span)
};
let Some(lo_span) = find_span_near(in_min) else { return };
let Some(hi_span) = find_span_near(in_max.saturating_sub(1)) else { return };
let collapse_endpoint = |source: &typst::syntax::Source,
chars: &[painter::PositionedChar],
span: Span,
min_byte: &mut usize,
max_byte: &mut usize| {
let Some(node) = source.find(span) else { return };
let mut current = &node;
loop {
let Some(parent) = current.parent() else { break };
let pr = parent.range();
let all_covered = chars.iter()
.filter_map(|ch| {
let (span, offset) = ch.span;
let nr = source.range(span)?;
Some(nr.start + offset as usize)
})
.filter(|&pos| pos >= pr.start && pos < pr.end)
.all(|pos| pos >= *min_byte && pos < *max_byte);
if all_covered {
*min_byte = (*min_byte).min(pr.start);
*max_byte = (*max_byte).max(pr.end);
current = parent;
} else {
break;
}
}
};
collapse_endpoint(source, chars, lo_span, min_byte, max_byte);
collapse_endpoint(source, chars, hi_span, min_byte, max_byte);
while *max_byte > *min_byte
&& source.text().as_bytes()[*max_byte - 1].is_ascii_whitespace()
{
*max_byte -= 1;
}
if *min_byte > 0 && source.text().as_bytes()[*min_byte - 1] == b'#' {
*min_byte -= 1;
}
}
fn render_typst_errors(
ui: &mut egui::Ui,
source: &str,
preamble_len: usize,
diags: &[super::typst_render::world::TypstDiag],
) {
let pad = crate::card_ctx::GRID_EDGE_PAD;
let frame = egui::Frame::NONE.inner_margin(egui::Margin::symmetric(pad as i8, pad as i8));
frame.show(ui, |ui| {
render_typst_errors_inner(ui, source, preamble_len, diags);
});
}
fn render_typst_errors_inner(
ui: &mut egui::Ui,
source: &str,
preamble_len: usize,
diags: &[super::typst_render::world::TypstDiag],
) {
use typst::diag::Severity;
let user_source = &source[preamble_len..];
let line_starts: Vec<usize> = std::iter::once(0)
.chain(user_source.match_indices('\n').map(|(i, _)| i + 1))
.collect();
let byte_to_line = |byte: usize| -> usize {
line_starts.partition_point(|&start| start <= byte).saturating_sub(1)
};
let line_text = |line: usize| -> &str {
let start = line_starts[line];
let end = if line + 1 < line_starts.len() {
line_starts[line + 1].saturating_sub(1)
} else {
user_source.len()
};
&user_source[start..end]
};
let mono = egui::FontId::monospace(12.0);
let error_color = egui::Color32::from_rgb(0xFF, 0x44, 0x44);
let warning_color = egui::Color32::from_rgb(0xFF, 0xCC, 0x22);
let hint_color = egui::Color32::from_rgb(0x55, 0xBB, 0xFF);
let line_num_color = ui.visuals().weak_text_color();
let source_color = ui.visuals().text_color();
let fmt = |color: egui::Color32| egui::text::TextFormat {
font_id: mono.clone(),
color,
..Default::default()
};
let label_no_wrap = |ui: &mut egui::Ui, mut job: egui::text::LayoutJob| {
job.wrap = egui::text::TextWrapping {
max_rows: 1,
break_anywhere: false,
overflow_character: Some('…'),
..Default::default()
};
ui.label(job);
};
let prev_spacing = ui.spacing().item_spacing.y;
ui.spacing_mut().item_spacing.y = 0.0;
let mut seen_lines = std::collections::HashSet::new();
let diags: Vec<_> = diags.iter().filter(|d| {
let line = d.span_range.as_ref()
.filter(|r| r.start >= preamble_len)
.map(|r| byte_to_line(r.start - preamble_len));
match line {
Some(l) => seen_lines.insert(l),
None => true,
}
}).collect();
for diag in diags {
let color = match diag.severity {
Severity::Error => error_color,
Severity::Warning => warning_color,
};
let prefix = match diag.severity {
Severity::Error => "error",
Severity::Warning => "warning",
};
ui.add_space(4.0);
let mut job = egui::text::LayoutJob::default();
job.append(&format!("{prefix}: "), 0.0, fmt(color));
job.append(&diag.message, 0.0, fmt(source_color));
ui.label(job);
if let Some(range) = &diag.span_range {
if range.start >= preamble_len {
let user_start = range.start - preamble_len;
let user_end = (range.end - preamble_len).min(user_source.len());
let err_line = byte_to_line(user_start);
let line_num = err_line + 1;
let gutter_width = format!("{line_num}").len().max(3);
let text = line_text(err_line);
let mut bar_job = egui::text::LayoutJob::default();
bar_job.append(&format!("{:>gutter_width$} ┃", ""), 0.0, fmt(line_num_color));
label_no_wrap(ui, bar_job);
let mut line_job = egui::text::LayoutJob::default();
line_job.append(&format!("{line_num:>gutter_width$} ┃ "), 0.0, fmt(line_num_color));
line_job.append(text, 0.0, fmt(source_color));
label_no_wrap(ui, line_job);
let line_start = line_starts[err_line];
let col_start = user_start - line_start;
let col_end = (user_end - line_start).max(col_start + 1);
let underline: String = " ".repeat(col_start)
+ &"─".repeat(col_end - col_start);
let mut ul_job = egui::text::LayoutJob::default();
ul_job.append(&format!("{:>gutter_width$} ┃ ", ""), 0.0, fmt(line_num_color));
ul_job.append(&underline, 0.0, fmt(color));
label_no_wrap(ui, ul_job);
}
}
for hint in &diag.hints {
let mut hint_job = egui::text::LayoutJob::default();
hint_job.append(" hint: ", 0.0, fmt(hint_color));
hint_job.append(hint, 0.0, fmt(source_color));
ui.label(hint_job);
}
}
ui.spacing_mut().item_spacing.y = prev_spacing;
}
fn render_typst(ui: &mut egui::Ui, state: &mut TypstState, source: &str, preamble_len: usize) {
state.world.set_source(source.to_string());
let doc = match state.world.compile() {
Ok(doc) => doc,
Err(diags) => {
render_typst_errors(ui, source, preamble_len, &diags);
return;
}
};
let text_color = ui.visuals().text_color();
let pixels_per_point = ui.ctx().pixels_per_point();
for page in doc.pages.iter() {
let (shapes, size, text_layout) = painter::render_frame_to_shapes(
&page.frame,
text_color,
pixels_per_point.to_bits(),
);
let (rect, response) =
ui.allocate_exact_size(size, egui::Sense::click_and_drag());
if !ui.is_rect_visible(rect) {
continue;
}
let offset = rect.min.to_vec2();
let chars = &text_layout.chars;
let has_text = !chars.is_empty();
let sel_id = response.id;
let mut sel = ui
.data_mut(|d| d.get_temp::<TypstSelection>(sel_id))
.unwrap_or_default();
let glyphs_rect = if has_text {
let mut r = egui::Rect::NOTHING;
for ch in chars {
r = r.union(ch.rect);
}
r.expand(12.0)
} else {
egui::Rect::NOTHING
};
if has_text {
if response.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
}
if response.double_clicked() {
if let Some(pos) = response.interact_pointer_pos() {
let frame_pos = pos - offset;
if let Some(range) = double_click_range(state.world.main_source(), chars, frame_pos) {
sel.glyph_override = Some(range);
}
}
} else if response.drag_started() {
sel.glyph_override = None;
if let Some(pos) = response.interact_pointer_pos() {
let frame_pos = pos - offset;
sel.anchor = Some(frame_pos);
sel.cursor = Some(frame_pos);
}
} else if response.dragged() {
if let Some(pos) = ui.ctx().pointer_latest_pos() {
let frame_pos = pos - offset;
let clamped = glyphs_rect.clamp(frame_pos);
sel.cursor = Some(clamped);
}
ui.ctx().input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO);
}
if response.clicked() && !response.double_clicked() && !response.dragged() {
sel = TypstSelection::default();
}
}
let glyph_range = sel.range(chars);
let source = state.world.main_source();
let sel_cache_id = sel_id.with("sel_cache");
let sel_key = (sel.anchor, sel.cursor, sel.glyph_override.clone());
let cached: Option<(
(Option<egui::Pos2>, Option<egui::Pos2>, Option<Range<usize>>),
SelectionResult,
)> = ui.ctx().data_mut(|d| d.get_temp(sel_cache_id));
let sel_result = if let Some((cached_key, cached_result)) = cached {
if cached_key == sel_key {
cached_result
} else {
let result = compute_selection(source, chars, &glyph_range);
ui.ctx().data_mut(|d| d.insert_temp(sel_cache_id, (sel_key, result.clone())));
result
}
} else {
let result = compute_selection(source, chars, &glyph_range);
if glyph_range.is_some() {
ui.ctx().data_mut(|d| d.insert_temp(sel_cache_id, (sel_key, result.clone())));
}
result
};
let selected = &sel_result.sel_set;
{
let highlight_color = ui.visuals().selection.bg_fill;
for (i, ch) in chars.iter().enumerate() {
if *selected.get(i).unwrap_or(&false) {
ui.painter().rect_filled(
ch.rect.translate(offset), 0.0, highlight_color,
);
}
}
}
for mut shape in shapes {
shape.translate(offset);
ui.painter().add(shape);
}
if !text_layout.links.is_empty() {
if let Some(hover_pos) = response.hover_pos() {
let frame_pos = hover_pos - offset;
for link in &text_layout.links {
if link.rect.contains(frame_pos) {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
if response.clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(&link.url));
}
break;
}
}
}
}
if selected.iter().any(|&s| s) {
response.request_focus();
let wants_copy = ui.input(|i| {
i.events
.iter()
.any(|e| matches!(e, egui::Event::Copy))
|| (i.modifiers.command && i.key_pressed(egui::Key::C))
});
if wants_copy {
let text = if let Some(ref r) = sel_result.copy_range {
source.text()[r.clone()].to_string()
} else {
selected.iter().enumerate()
.filter(|(_, &s)| s)
.filter_map(|(i, _)| chars.get(i).map(|c| c.text.as_str()))
.collect()
};
ui.ctx().copy_text(text);
}
}
ui.data_mut(|d| d.insert_temp(sel_id, sel));
}
}