use std::ops::Range;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use anyhow::Result;
use unicode_segmentation::UnicodeSegmentation;
use vello::kurbo::{Affine, BezPath, Point, Rect, Stroke};
use vello::peniko::Fill;
use vello::util::{RenderContext, RenderSurface};
use vello::wgpu;
use vello::wgpu::CurrentSurfaceTexture;
use vello::{AaConfig, RenderParams, Renderer, RendererOptions, Scene};
use winit::application::ApplicationHandler;
use winit::dpi::{PhysicalPosition, PhysicalSize};
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy};
use winit::keyboard::{Key, KeyCode, ModifiersState, NamedKey, PhysicalKey};
use winit::window::{CursorIcon, Theme, Window, WindowId};
use winit::event_loop::ControlFlow;
use crate::buffer::Buffer;
use crate::chrome::{BarRect, FindButtonRects, StatusInfo, draw_find_bar, draw_status_bar};
use crate::config::{self, Config, ResolvedConfig};
use crate::consts::{
CARET_WIDTH, FIND_ROW_H, FONT_SIZE, LINE_HEIGHT, OUTLINE_WIDTH, PADDING, STATUS_BAR_H,
WHEEL_LINE_STEP,
};
use crate::core::{
AutocompleteSuggestion, AutocompleteTrigger, Editor, FieldFocus, FindMode, WatchKind,
};
use crate::doc_layout::{
DocLayout, GithubRenderData, HeightCache, LayoutParams, LineCache, PreeditView, RenderCache,
ScreenRect, TableCache,
};
use crate::editor::{Direction, EditorTheme};
use crate::git::{detect_github_context, parse_github_repo_string};
use crate::github::{GitHubClient, ValidationResult};
use crate::image_cache::ImageCache;
use crate::image_load::{RepaintSignal, load_local_images_blocking, spawn_image_loads};
use crate::inline::{GitHubContext, GitHubRef};
#[cfg(feature = "math")]
use crate::math;
#[cfg(feature = "mermaid")]
use crate::mermaid;
use crate::outline::{current_heading_index, draw_outline};
use crate::overlay::{
HoverTarget, draw_autocomplete, draw_hover_popover, find_hover_target, hover_target_at_offset,
};
use crate::raster::rasterize_scene_to_png;
use crate::text_engine::{TextEngine, peniko_color, peniko_color_alpha};
use crate::text_input;
use crate::validation::{GitHubValidationCache, IssueOrPr, MentionableUser, ValidatedRefData};
fn chrome_metrics(scale: f32, height_dev: f32, find_h: f32) -> (f32, f32) {
let content_top = 0.0;
let editor_h = (height_dev - STATUS_BAR_H * scale - find_h).max(1.0);
(content_top, editor_h)
}
fn outline_width(editor: &Editor, scale: f32) -> f32 {
if editor.outline_open() {
OUTLINE_WIDTH * scale
} else {
0.0
}
}
fn find_bar_height(editor: &Editor, scale: f32) -> f32 {
match editor.find_state() {
None => 0.0,
Some(find) => {
let rows = if find.mode == FindMode::Replace {
2.0
} else {
1.0
};
rows * FIND_ROW_H * scale + 8.0 * scale
}
}
}
fn window_title(editor: &Editor) -> String {
let name = editor
.file_path()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "untitled".to_string());
if editor.is_dirty() {
format!("● {name}")
} else {
name
}
}
fn heading_at(buffer: &Buffer, line: usize) -> Option<u8> {
let headings = buffer.headings();
current_heading_index(headings, line).map(|i| headings[i].level)
}
fn build_status_info(editor: &Editor, doc: &DocLayout, editor_h: f32) -> StatusInfo {
let buffer = &editor.state.buffer;
let cursor = editor.cursor_position();
let line = buffer.byte_to_line(cursor);
let line_start = buffer.line_to_byte(line);
let col = buffer.slice_cow(line_start..cursor).graphemes(true).count() + 1;
let (first, last) = doc.visible_range(editor_h);
StatusInfo {
context: editor.state.build_nested_context(cursor),
heading_level: heading_at(buffer, line),
cursor_line: line + 1,
cursor_col: col,
total_lines: buffer.line_count(),
first_visible: first,
last_visible: last.saturating_sub(1),
}
}
const DEMO_DOC: &str = r##"# writ — a live Markdown diff viewer
**writ** renders Markdown as you type, and shows a live *inline diff* against
git `HEAD`: added lines glow green, deletions appear as ghost rows, and
word-level changes highlight in place. Point it at a file an agent is editing and
watch the changes land in real time.
## Features
- [x] GPU-rendered Markdown (winit + Vello + Parley)
- [x] Live inline `git HEAD` diff with word-level changes
- [x] GitHub `#123` / `@user` refs — validated, with hover cards + autocomplete
- [ ] GFM tables
- [ ] Multi-cursor
## A code block
```rust
fn main() {
let editor = Editor::new("# hello");
println!("{}", editor.text());
}
```
> Ctrl-click any link or `#ref` to open it in your browser.
Try editing this file — every keystroke re-renders and re-diffs against HEAD.
"##;
const DEMO_BASE: &str = r##"# writ — a Markdown viewer
**writ** renders Markdown as you type. Point it at a file and read it nicely.
## Features
- GPU-rendered Markdown
- This line is removed in the working copy, so it renders as a deleted ghost row.
## A code block
```rust
fn main() {
let editor = Editor::new("# hello");
println!("{}", editor.text());
}
```
Try editing this file.
"##;
fn demo_editor() -> Editor {
let mut editor = Editor::new(DEMO_DOC);
editor.set_head_base(DEMO_BASE);
editor
}
type ReloadData = (String, Option<String>);
type AcSlot = Arc<Mutex<Option<FetchedSuggestions>>>;
#[derive(Debug, Clone)]
pub(crate) enum WritEvent {
GithubUpdated,
ImageLoaded,
FileChanged,
GitBaseChanged,
FileReloaded,
}
#[derive(Clone)]
struct RedrawOnImageLoad(EventLoopProxy<WritEvent>);
impl RepaintSignal for RedrawOnImageLoad {
fn notify(&self) {
let _ = self.0.send_event(WritEvent::ImageLoaded);
}
}
enum FetchedSuggestions {
Issues {
prefix: String,
issues: Vec<IssueOrPr>,
},
Users {
prefix: String,
users: Vec<MentionableUser>,
},
}
struct ActiveSurface {
surface: RenderSurface<'static>,
window: Arc<Window>,
scale: f32,
find_bar_h: f32,
}
impl ActiveSurface {
fn viewport(&self) -> (f32, f32, f32) {
let (_, editor_h) = chrome_metrics(
self.scale,
self.surface.config.height as f32,
self.find_bar_h,
);
(self.surface.config.width as f32, editor_h, self.scale)
}
}
fn sync_find_chrome(doc_engine: &mut DocEngine, state: &mut ActiveSurface) {
state.find_bar_h = find_bar_height(&doc_engine.editor, state.scale);
let (w, editor_h, scale) = state.viewport();
doc_engine.rebuild_preserving_scroll(w, scale, editor_h);
}
struct Preedit {
text: String,
cursor: Option<(usize, usize)>,
}
struct DocEngine {
text_engine: TextEngine,
line_cache: LineCache,
render_cache: RenderCache,
height_cache: HeightCache,
table_cache: TableCache,
theme: EditorTheme,
font_size: f32,
editor: Editor,
doc: Option<DocLayout>,
images: ImageCache,
preedit: Option<Preedit>,
}
struct App {
context: RenderContext,
renderer: Option<Renderer>,
state: Option<ActiveSurface>,
scene: Scene,
doc_engine: DocEngine,
modifiers: ModifiersState,
mouse_pos: (f32, f32),
mouse_down: bool,
cursor_icon: CursorIcon,
last_click: Option<(std::time::Instant, (f32, f32))>,
click_count: usize,
drag_scroll_dy: f32,
last_drag_tick: Option<std::time::Instant>,
drag_pending: bool,
pending_rebuild: bool,
reload_slot: Arc<Mutex<Option<ReloadData>>>,
clipboard: Option<arboard::Clipboard>,
title: String,
hovered: Option<HoverTarget>,
ac_row_rects: Vec<ScreenRect>,
outline_row_rects: Vec<ScreenRect>,
outline_hover: Option<usize>,
fold_chevron_rects: Vec<(usize, ScreenRect)>,
gutter_hover: Option<usize>,
find_btn_rects: Option<FindButtonRects>,
ac_gen: Arc<AtomicU64>,
ac_slot: AcSlot,
proxy: EventLoopProxy<WritEvent>,
runtime: tokio::runtime::Handle,
}
impl App {
fn new(
mut editor: Editor,
config: ResolvedConfig,
proxy: EventLoopProxy<WritEvent>,
runtime: tokio::runtime::Handle,
) -> Self {
let title = window_title(&editor);
let mut text_engine = TextEngine::new();
if let Some(family) = config.font_family {
text_engine.set_font_family(Some(family.clone()));
if !text_engine.is_monospace() {
eprintln!("[writ] config: font '{family}' is not monospace; using the default");
text_engine.set_font_family(None);
}
}
if let Some(rx) = editor.take_file_watch_rx() {
let proxy = proxy.clone();
std::thread::spawn(move || {
while let Ok(kind) = rx.recv() {
let event = match kind {
WatchKind::File => WritEvent::FileChanged,
WatchKind::GitBase => WritEvent::GitBaseChanged,
};
if proxy.send_event(event).is_err() {
break;
}
}
});
}
Self {
context: RenderContext::new(),
renderer: None,
state: None,
scene: Scene::new(),
doc_engine: DocEngine {
text_engine,
line_cache: LineCache::new(),
render_cache: RenderCache::new(),
height_cache: HeightCache::new(),
table_cache: TableCache::new(),
theme: config.theme,
font_size: config.font_size,
editor,
doc: None,
images: ImageCache::new(),
preedit: None,
},
modifiers: ModifiersState::empty(),
mouse_pos: (0.0, 0.0),
mouse_down: false,
cursor_icon: CursorIcon::Default,
last_click: None,
click_count: 0,
drag_scroll_dy: 0.0,
last_drag_tick: None,
drag_pending: false,
pending_rebuild: false,
reload_slot: Arc::new(Mutex::new(None)),
clipboard: arboard::Clipboard::new().ok(),
title,
hovered: None,
ac_row_rects: Vec::new(),
outline_row_rects: Vec::new(),
outline_hover: None,
fold_chevron_rects: Vec::new(),
gutter_hover: None,
find_btn_rects: None,
ac_gen: Arc::new(AtomicU64::new(0)),
ac_slot: Arc::new(Mutex::new(None)),
proxy,
runtime,
}
}
fn spawn_validations(&self) {
let visible = self.state.as_ref().and_then(|s| {
let (_, vh, _) = s.viewport();
self.doc_engine.doc.as_ref().map(|d| {
let (a, b) = d.visible_range(vh);
a..b
})
});
spawn_ref_validations(&self.doc_engine.editor, visible, &self.runtime, &self.proxy);
self.sync_images();
}
fn sync_images(&self) {
sync_image_loads(&self.doc_engine, &self.runtime, &self.proxy);
}
}
fn sync_image_loads(
doc_engine: &DocEngine,
runtime: &tokio::runtime::Handle,
proxy: &EventLoopProxy<WritEvent>,
) {
let Some(doc) = doc_engine.doc.as_ref() else {
return;
};
#[cfg(feature = "mermaid")]
{
let sources = doc.mermaid_sources();
if !sources.is_empty() {
let proxy = proxy.clone();
mermaid::spawn_mermaid_renders(sources, &doc_engine.images, move || {
let _ = proxy.send_event(WritEvent::ImageLoaded);
});
}
}
#[cfg(feature = "math")]
{
let jobs = doc.math_sources();
if !jobs.is_empty() {
let proxy = proxy.clone();
math::spawn_math_renders(jobs, &doc_engine.images, move || {
let _ = proxy.send_event(WritEvent::ImageLoaded);
});
}
}
let urls = doc.image_urls();
if urls.is_empty() {
return;
}
let dir = doc_engine
.editor
.file_path()
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
spawn_image_loads(
dir,
urls,
&doc_engine.images,
runtime,
RedrawOnImageLoad(proxy.clone()),
);
}
fn after_edit(
doc_engine: &mut DocEngine,
runtime: &tokio::runtime::Handle,
proxy: &EventLoopProxy<WritEvent>,
w: f32,
scale: f32,
editor_h: f32,
) {
doc_engine.refresh(w, scale, editor_h);
let visible = doc_engine.doc.as_ref().map(|d| {
let (a, b) = d.visible_range(editor_h);
a..b
});
spawn_ref_validations(&doc_engine.editor, visible, runtime, proxy);
sync_image_loads(doc_engine, runtime, proxy);
}
#[allow(clippy::too_many_arguments)]
fn apply_edit_effects(
doc_engine: &mut DocEngine,
hovered: &mut Option<HoverTarget>,
runtime: &tokio::runtime::Handle,
proxy: &EventLoopProxy<WritEvent>,
ac: Option<(&Arc<AtomicU64>, &AcSlot)>,
window: &Window,
w: f32,
scale: f32,
editor_h: f32,
) {
if doc_engine.editor.find_state().is_some() {
doc_engine.editor.find_rescan();
}
*hovered = None;
after_edit(doc_engine, runtime, proxy, w, scale, editor_h);
if let Some((ac_gen, ac_slot)) = ac {
sync_autocomplete(&mut doc_engine.editor, runtime, proxy, ac_gen, ac_slot);
}
window.request_redraw();
}
fn spawn_ref_validations(
editor: &Editor,
visible: Option<Range<usize>>,
runtime: &tokio::runtime::Handle,
proxy: &EventLoopProxy<WritEvent>,
) {
let Some(client) = editor.github_client() else {
return;
};
let cache = editor.github_validation_cache();
let refs = match visible {
Some(r) => editor.detected_refs_in_lines(r),
None => editor.detected_refs(),
};
for reference in refs {
if cache.contains(&reference) {
continue; }
cache.mark_pending(reference.clone());
let client = client.clone();
let cache = cache.clone();
let proxy = proxy.clone();
runtime.spawn(async move {
validate_ref_into_cache(&client, &cache, reference).await;
let _ = proxy.send_event(WritEvent::GithubUpdated);
});
}
}
async fn validate_ref_into_cache(
client: &GitHubClient,
cache: &GitHubValidationCache,
reference: GitHubRef,
) {
match client.validate_ref(&reference).await {
ValidationResult::ValidWithData(d) => cache.set_valid(reference, Some(d)),
ValidationResult::ValidNoData => cache.set_valid(reference, None),
ValidationResult::Invalid => cache.set_invalid(reference),
}
}
const PAGE_LINES: usize = 20;
const DETECT_OVERSCAN_LINES: usize = 200;
const MULTI_CLICK_MS: u128 = 400;
const MULTI_CLICK_DIST: f32 = 6.0;
const DRAG_SCROLL_ZONE: f32 = 24.0;
const DRAG_SCROLL_EDGE_SPEED: f32 = 140.0;
const DRAG_SCROLL_GAIN: f32 = 10.0;
const DRAG_SCROLL_MAX_SPEED: f32 = 800.0;
fn drag_edge_velocity(y: f32, editor_h: f32, scale: f32) -> f32 {
let zone = DRAG_SCROLL_ZONE * scale;
let edge = DRAG_SCROLL_EDGE_SPEED * scale;
let cap = DRAG_SCROLL_MAX_SPEED * scale;
let (depth, dir) = if y < zone {
(zone - y, -1.0)
} else if y > editor_h - zone {
(y - (editor_h - zone), 1.0)
} else {
return 0.0;
};
let speed = edge * (depth / zone).min(1.0) + DRAG_SCROLL_GAIN * (depth - zone).max(0.0);
dir * speed.min(cap)
}
fn drag_extend_step(
doc_engine: &mut DocEngine,
mouse: (f32, f32),
dy: f32,
w: f32,
scale: f32,
editor_h: f32,
) {
if dy != 0.0
&& let Some(doc) = doc_engine.doc.as_mut()
{
doc.scroll_by(dy, editor_h);
}
let hy = mouse.1.clamp(0.0, editor_h);
if let Some(off) = doc_engine
.doc
.as_ref()
.and_then(|d| d.hit_test(mouse.0, hy))
{
doc_engine.editor.drag(off);
}
doc_engine.refresh(w, scale, editor_h);
}
fn apply_key(
editor: &mut Editor,
modifiers: ModifiersState,
event: &winit::event::KeyEvent,
) -> bool {
let shift = modifiers.shift_key();
let alt = modifiers.alt_key();
let ctrl = modifiers.control_key() || modifiers.super_key();
match &event.logical_key {
Key::Named(NamedKey::Enter) => {
if shift && alt {
editor.shift_alt_enter();
} else if shift {
editor.shift_enter();
} else {
editor.enter();
}
true
}
Key::Named(NamedKey::Backspace) => {
editor.backspace();
true
}
Key::Named(NamedKey::Delete) => {
editor.delete_forward();
true
}
Key::Named(NamedKey::Tab) => {
if !shift && editor.cursor_in_code_block() {
editor.insert_str(" ");
} else if shift {
editor.shift_tab();
} else {
editor.tab();
}
true
}
Key::Named(NamedKey::Space) => {
editor.try_insert_space();
true
}
Key::Named(NamedKey::ArrowLeft) => {
editor.move_in_direction(Direction::Left, shift);
true
}
Key::Named(NamedKey::ArrowRight) => {
editor.move_in_direction(Direction::Right, shift);
true
}
Key::Named(NamedKey::ArrowUp) => {
editor.move_in_direction(Direction::Up, shift);
true
}
Key::Named(NamedKey::ArrowDown) => {
editor.move_in_direction(Direction::Down, shift);
true
}
Key::Named(NamedKey::Home) => {
let dir = if ctrl {
Direction::DocStart
} else {
Direction::LineStart
};
editor.move_in_direction(dir, shift);
true
}
Key::Named(NamedKey::End) => {
let dir = if ctrl {
Direction::DocEnd
} else {
Direction::LineEnd
};
editor.move_in_direction(dir, shift);
true
}
Key::Named(NamedKey::PageUp) => {
for _ in 0..PAGE_LINES {
editor.move_in_direction(Direction::Up, shift);
}
true
}
Key::Named(NamedKey::PageDown) => {
for _ in 0..PAGE_LINES {
editor.move_in_direction(Direction::Down, shift);
}
true
}
_ => {
if ctrl {
if let Key::Character(c) = &event.logical_key {
let c = c.as_str();
if c.eq_ignore_ascii_case("s") {
if let Err(e) = editor.save() {
eprintln!("[writ] save failed: {e}");
}
return true; }
if c.eq_ignore_ascii_case("z") {
if shift {
editor.redo();
} else {
editor.undo();
}
return true;
}
if c.eq_ignore_ascii_case("y") {
editor.redo();
return true;
}
if c.eq_ignore_ascii_case("a") {
editor.select_all();
return true;
}
if c.eq_ignore_ascii_case("r") {
editor.revalidate_github_refs();
return true;
}
}
return false;
}
if let Some(text) = &event.text
&& !text.is_empty()
&& !text.chars().any(|c| c.is_control())
{
editor.insert_str(text);
match text.as_str() {
">" => editor.maybe_complete_blockquote_marker(),
"`" | "~" => editor.maybe_complete_code_fence(),
_ => {}
}
return true;
}
false
}
}
}
impl DocEngine {
fn detection_range(&self, editor_h: f32) -> Range<usize> {
let n = self.editor.state.buffer.line_count();
let cursor_line = self.editor.line_of(self.editor.cursor_position());
match &self.doc {
Some(d) => {
let (first, last) = d.visible_range(editor_h);
let lo = first.saturating_sub(DETECT_OVERSCAN_LINES).min(cursor_line);
let hi = (last + DETECT_OVERSCAN_LINES).max(cursor_line + 1).min(n);
lo..hi
}
None => 0..n,
}
}
fn relayout_at_anchor(&mut self, device_width: f32, scale: f32, editor_h: f32) -> DocLayout {
let (anchor_line, anchor_off) = self
.doc
.as_ref()
.map(|d| d.scroll_anchor())
.unwrap_or((0, 0.0));
let mut new_doc = self.rebuild(device_width, scale, anchor_line, editor_h);
new_doc.scroll_y = new_doc.anchor_scroll_y(anchor_line, anchor_off);
new_doc.clamp_scroll(editor_h);
new_doc
}
fn refresh(&mut self, device_width: f32, scale: f32, editor_h: f32) {
self.editor.reveal_cursor();
self.editor
.refresh_detection(self.detection_range(editor_h));
let mut new_doc = self.relayout_at_anchor(device_width, scale, editor_h);
new_doc.scroll_to(self.editor.cursor_position(), editor_h);
self.doc = Some(new_doc);
if self
.doc
.as_ref()
.is_some_and(|d| d.needs_remeasure(editor_h))
{
self.doc = Some(self.relayout_at_anchor(device_width, scale, editor_h));
}
}
fn rebuild_preserving_scroll(&mut self, device_width: f32, scale: f32, editor_h: f32) {
self.editor
.refresh_detection(self.detection_range(editor_h));
self.doc = Some(self.relayout_at_anchor(device_width, scale, editor_h));
}
fn rebuild(
&mut self,
device_width: f32,
scale: f32,
anchor_line: usize,
viewport_h: f32,
) -> DocLayout {
let cursor_offset = self.editor.cursor_position();
let version = self.editor.state.buffer.version();
let diff = self.editor.diff_state().cloned();
let snapshot = self.editor.state.buffer.render_snapshot();
let github = GithubRenderData {
refs_by_line: self.editor.github_refs_by_line(),
urls_by_line: self.editor.naked_urls_by_line(),
cache: self.editor.github_validation_cache(),
context: self.editor.github_context(),
};
let outline_w = outline_width(&self.editor, scale);
let params = LayoutParams {
content_x0: 0.0,
content_w: (device_width - outline_w).max(1.0),
scale,
pad_x: PADDING,
pad_top: PADDING,
pad_bottom: PADDING * 2.0,
base_font_size: self.font_size,
line_height: LINE_HEIGHT,
fg: peniko_color(self.theme.foreground),
};
let preedit = self.preedit.as_ref().map(|p| PreeditView {
text: &p.text,
cursor: p.cursor,
});
let folds = self.editor.hidden_line_ranges();
let selection = self.editor.selection_range().unwrap_or_else(|| {
let c = self.editor.cursor_position();
c..c
});
DocLayout::build(
&mut self.text_engine,
&mut self.line_cache,
&mut self.render_cache,
&mut self.height_cache,
&mut self.table_cache,
version,
&snapshot,
&self.theme,
diff.as_ref(),
Some(&github),
&self.images,
cursor_offset,
¶ms,
preedit.as_ref(),
anchor_line,
viewport_h,
&folds,
&selection,
self.editor.math_spans_by_line(),
)
}
}
fn sync_autocomplete(
editor: &mut Editor,
runtime: &tokio::runtime::Handle,
proxy: &EventLoopProxy<WritEvent>,
fetch_gen: &Arc<AtomicU64>,
slot: &AcSlot,
) {
if !editor.update_autocomplete_from_cursor() {
return;
}
let Some((trigger, prefix)) = editor.begin_autocomplete_fetch() else {
return;
};
let Some(client) = editor.github_client().cloned() else {
return;
};
let Some(context) = editor.github_context().cloned() else {
return;
};
let my_gen = fetch_gen.fetch_add(1, Ordering::SeqCst) + 1;
let fetch_gen = fetch_gen.clone();
let proxy = proxy.clone();
let slot = slot.clone();
runtime.spawn(async move {
tokio::time::sleep(Duration::from_millis(150)).await;
if fetch_gen.load(Ordering::SeqCst) != my_gen {
return; }
let fetched = fetch_suggestions(&client, &context, trigger, prefix).await;
*slot.lock().unwrap() = Some(fetched);
let _ = proxy.send_event(WritEvent::GithubUpdated);
});
}
async fn fetch_suggestions(
client: &GitHubClient,
context: &GitHubContext,
trigger: AutocompleteTrigger,
prefix: String,
) -> FetchedSuggestions {
match trigger {
AutocompleteTrigger::Issue => {
let issues = client
.issues_matching_prefix(&context.owner, &context.repo, &prefix, 8)
.await;
FetchedSuggestions::Issues { prefix, issues }
}
AutocompleteTrigger::User => {
let users = client
.users_matching_prefix(&context.owner, &context.repo, &prefix, 8)
.await;
FetchedSuggestions::Users { prefix, users }
}
}
}
fn apply_fetched(editor: &mut Editor, fetched: FetchedSuggestions) {
match fetched {
FetchedSuggestions::Issues { prefix, issues } => {
let context = editor.github_context().cloned();
let cache = editor.github_validation_cache().clone();
let mut suggestions = Vec::with_capacity(issues.len());
for issue in issues {
if let Some(ctx) = &context {
cache.set_valid(
GitHubRef::Issue {
owner: ctx.owner.clone(),
repo: ctx.repo.clone(),
number: issue.number,
},
Some(ValidatedRefData::Issue(issue.clone())),
);
}
suggestions.push(AutocompleteSuggestion::IssueOrPr {
number: issue.number,
symbol: issue.symbol().to_string(),
status: issue.status(),
title: issue.title,
});
}
editor.apply_autocomplete_suggestions(AutocompleteTrigger::Issue, &prefix, suggestions);
}
FetchedSuggestions::Users { prefix, users } => {
let suggestions = users
.into_iter()
.map(|u| AutocompleteSuggestion::User {
login: u.login,
name: u.name,
})
.collect();
editor.apply_autocomplete_suggestions(AutocompleteTrigger::User, &prefix, suggestions);
}
}
}
fn rect_contains(rect: &ScreenRect, pos: (f32, f32)) -> bool {
let (px, py) = (pos.0 as f64, pos.1 as f64);
let &(x0, y0, x1, y1) = rect;
px >= x0 && px <= x1 && py >= y0 && py <= y1
}
fn ac_row_at(rects: &[ScreenRect], pos: (f32, f32)) -> Option<usize> {
rects.iter().position(|r| rect_contains(r, pos))
}
fn pointer_cursor(
doc_engine: &mut DocEngine,
state: &ActiveSurface,
modifiers: ModifiersState,
mouse_pos: (f32, f32),
fold_chevrons: &[(usize, ScreenRect)],
ac_rows: &[ScreenRect],
) -> CursorIcon {
if (modifiers.control_key() || modifiers.super_key())
&& doc_engine
.doc
.as_ref()
.and_then(|d| d.hit_test(mouse_pos.0, mouse_pos.1))
.is_some_and(|off| doc_engine.editor.link_at(off).is_some())
{
return CursorIcon::Pointer;
}
if fold_chevrons
.iter()
.any(|(_, r)| rect_contains(r, mouse_pos))
|| ac_rows.iter().any(|r| rect_contains(r, mouse_pos))
{
return CursorIcon::Default;
}
let width = state.surface.config.width as f32;
let height = state.surface.config.height as f32;
let outline_w = outline_width(&doc_engine.editor, state.scale);
let (_, editor_h) = chrome_metrics(state.scale, height, state.find_bar_h);
let body: ScreenRect = (0.0, 0.0, (width - outline_w) as f64, editor_h as f64);
if rect_contains(&body, mouse_pos) {
CursorIcon::Text
} else {
CursorIcon::Default
}
}
#[allow(clippy::too_many_arguments)]
fn update_pointer_cursor(
doc_engine: &mut DocEngine,
state: &ActiveSurface,
modifiers: ModifiersState,
mouse_pos: (f32, f32),
fold_chevrons: &[(usize, ScreenRect)],
ac_rows: &[ScreenRect],
current: &mut CursorIcon,
) {
let icon = pointer_cursor(
doc_engine,
state,
modifiers,
mouse_pos,
fold_chevrons,
ac_rows,
);
if icon != *current {
*current = icon;
state.window.set_cursor(icon);
}
}
#[allow(clippy::too_many_arguments)]
fn paint_document(
scene: &mut Scene,
engine: &mut TextEngine,
theme: &EditorTheme,
editor: &Editor,
doc: &DocLayout,
content_top: f32,
editor_h: f32,
find_h: f32,
width: f32,
height: f32,
scale: f32,
outline_hover: Option<usize>,
gutter_hover: Option<usize>,
) -> (
Option<FindButtonRects>,
Vec<ScreenRect>,
Vec<(usize, ScreenRect)>,
) {
let outline_w = outline_width(editor, scale);
let doc_w = (width - outline_w).max(0.0);
let clip = Rect::new(
0.0,
content_top as f64,
doc_w as f64,
(content_top + editor_h) as f64,
);
scene.push_clip_layer(Fill::NonZero, Affine::IDENTITY, &clip);
doc.draw_added_backgrounds(scene, editor_h);
doc.draw_blockquote_gutters(scene, editor_h);
doc.draw_horizontal_rules(scene, editor_h);
doc.draw_images(engine, scene, editor_h);
doc.draw_tables(engine, scene, editor_h);
if let Some(find) = editor.find_state()
&& !find.matches.is_empty()
{
let tint = peniko_color_alpha(theme.yellow, 0.16);
let (first, last) = doc.visible_range(editor_h);
for (i, m) in find.matches.iter().enumerate() {
if find.active == Some(i) {
continue;
}
if doc.line_of(m.end) < first || doc.line_of(m.start) >= last {
continue;
}
for (x0, y0, x1, y1) in doc.selection_rects(m.clone()) {
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
tint,
None,
&Rect::new(x0, y0, x1, y1),
);
}
}
}
if let Some(sel) = editor.selection_range() {
let color = peniko_color(theme.selection);
for (x0, y0, x1, y1) in doc.selection_rects(sel) {
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
color,
None,
&Rect::new(x0, y0, x1, y1),
);
}
}
if let Some(find) = editor.find_state()
&& let Some(active) = find.active
&& let Some(m) = find.matches.get(active)
{
let fill = peniko_color_alpha(theme.orange, 0.34);
let border = peniko_color_alpha(theme.orange, 0.95);
let stroke = Stroke::new(1.5 * scale as f64);
for (x0, y0, x1, y1) in doc.selection_rects(m.clone()) {
let r = Rect::new(x0, y0, x1, y1);
scene.fill(Fill::NonZero, Affine::IDENTITY, fill, None, &r);
scene.stroke(&stroke, Affine::IDENTITY, border, None, &r);
}
}
doc.draw(engine, scene, editor_h);
let cw = CARET_WIDTH * scale;
let caret = doc
.preedit_caret_rect(cw)
.or_else(|| doc.caret_rect(editor.cursor_position(), cw));
if let Some((x0, y0, x1, y1)) = caret {
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
peniko_color(theme.foreground),
None,
&Rect::new(x0, y0, x1.max(x0 + 1.0), y1),
);
}
let fold_rects = draw_fold_chevrons(scene, theme, editor, doc, editor_h, gutter_hover, scale);
scene.pop_layer();
let outline_rects = if outline_w > 0.0 {
let panel = BarRect {
x0: (width - outline_w) as f64,
y0: content_top as f64,
x1: width as f64,
y1: (content_top + editor_h) as f64,
};
let buffer = &editor.state.buffer;
let headings = buffer.headings();
let cursor_line = buffer.byte_to_line(editor.cursor_position());
let current = current_heading_index(headings, cursor_line);
let folded: Vec<bool> = headings
.iter()
.map(|h| editor.is_heading_folded(h.byte_offset))
.collect();
draw_outline(
engine,
scene,
theme,
headings,
current,
outline_hover,
&folded,
&panel,
scale,
)
} else {
Vec::new()
};
let bar_top = (content_top + editor_h) as f64;
let status_top = bar_top + find_h as f64;
let btn_rects = if find_h > 0.0
&& let Some(find) = editor.find_state()
{
draw_find_bar(
engine,
scene,
theme,
find,
&BarRect {
x0: 0.0,
y0: bar_top,
x1: width as f64,
y1: status_top,
},
scale,
)
} else {
None
};
let info = build_status_info(editor, doc, editor_h);
draw_status_bar(
engine,
scene,
theme,
&BarRect {
x0: 0.0,
y0: status_top,
x1: width as f64,
y1: height as f64,
},
&info,
scale,
);
(btn_rects, outline_rects, fold_rects)
}
fn fold_level_key(key: PhysicalKey) -> Option<u8> {
match key {
PhysicalKey::Code(KeyCode::Digit1) => Some(1),
PhysicalKey::Code(KeyCode::Digit2) => Some(2),
PhysicalKey::Code(KeyCode::Digit3) => Some(3),
PhysicalKey::Code(KeyCode::Digit4) => Some(4),
PhysicalKey::Code(KeyCode::Digit5) => Some(5),
PhysicalKey::Code(KeyCode::Digit6) => Some(6),
_ => None,
}
}
fn draw_fold_chevrons(
scene: &mut Scene,
theme: &EditorTheme,
editor: &Editor,
doc: &DocLayout,
editor_h: f32,
hover: Option<usize>,
scale: f32,
) -> Vec<(usize, ScreenRect)> {
let (first, last) = doc.visible_range(editor_h);
let hidden = editor.hidden_line_ranges();
let visible_foldable = |line: usize| -> bool {
line >= first && line < last && !hidden.iter().any(|r| r.contains(&line))
};
let anchors: Vec<(usize, usize)> = editor
.fold_anchors()
.into_iter()
.filter(|a| visible_foldable(a.line))
.map(|a| (a.line, a.byte_offset))
.collect();
let body_left = doc.body_left();
let s = 9.0 * scale; let mut rects: Vec<(usize, ScreenRect)> = Vec::new();
for (line, byte_offset) in anchors {
let Some(top) = doc.line_top_screen(line) else {
continue;
};
let folded = editor.is_heading_folded(byte_offset);
let row_h = doc.line_text_height(line);
let hit: ScreenRect = (
(body_left - 20.0 * scale) as f64,
top as f64,
(body_left - 2.0 * scale) as f64,
(top + row_h) as f64,
);
rects.push((byte_offset, hit));
if !folded && hover != Some(byte_offset) {
continue;
}
let cx = body_left - 13.0 * scale;
let cy = top + row_h / 2.0;
let color = if hover == Some(byte_offset) {
peniko_color(theme.foreground)
} else {
peniko_color(theme.comment)
};
let mut tri = BezPath::new();
if folded {
tri.move_to(Point::new((cx - s * 0.3) as f64, (cy - s * 0.5) as f64));
tri.line_to(Point::new((cx - s * 0.3) as f64, (cy + s * 0.5) as f64));
tri.line_to(Point::new((cx + s * 0.45) as f64, cy as f64));
} else {
tri.move_to(Point::new((cx - s * 0.5) as f64, (cy - s * 0.3) as f64));
tri.line_to(Point::new((cx + s * 0.5) as f64, (cy - s * 0.3) as f64));
tri.line_to(Point::new(cx as f64, (cy + s * 0.45) as f64));
}
tri.close_path();
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &tri);
}
rects
}
impl ApplicationHandler<WritEvent> for App {
fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: WritEvent) {
if matches!(event, WritEvent::FileChanged | WritEvent::GitBaseChanged) {
let Some(path) = self.doc_engine.editor.file_path().map(|p| p.to_path_buf()) else {
return;
};
let ignore_self_write = matches!(event, WritEvent::GitBaseChanged);
let last_mtime = self.doc_engine.editor.last_save_mtime();
let slot = self.reload_slot.clone();
let proxy = self.proxy.clone();
self.runtime.spawn_blocking(move || {
if let Some(data) = Editor::read_reload(&path, last_mtime, ignore_self_write) {
*slot.lock().unwrap() = Some(data);
let _ = proxy.send_event(WritEvent::FileReloaded);
}
});
return;
}
if matches!(event, WritEvent::FileReloaded) {
let data = self.reload_slot.lock().unwrap().take();
if let Some((content, base_text)) = data
&& let Some(state) = self.state.as_ref()
{
let (w, vh, scale) = state.viewport();
let window = state.window.clone();
self.doc_engine.editor.apply_reload(content, base_text);
apply_edit_effects(
&mut self.doc_engine,
&mut self.hovered,
&self.runtime,
&self.proxy,
None,
&window,
w,
scale,
vh,
);
}
return;
}
if matches!(event, WritEvent::GithubUpdated) {
let fetched = self.ac_slot.lock().unwrap().take();
if let Some(fetched) = fetched {
apply_fetched(&mut self.doc_engine.editor, fetched);
}
}
if let Some(state) = self.state.as_ref() {
self.pending_rebuild = true;
state.window.request_redraw();
}
}
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() {
return;
}
let attrs = Window::default_attributes()
.with_title(self.title.clone())
.with_theme(Some(Theme::Dark));
let window = Arc::new(
event_loop
.create_window(attrs)
.expect("failed to create window"),
);
window.set_ime_allowed(true);
let size = window.inner_size();
let surface = pollster::block_on(self.context.create_surface(
window.clone(),
size.width.max(1),
size.height.max(1),
wgpu::PresentMode::AutoVsync,
))
.expect("failed to create surface");
let dev = &self.context.devices[surface.dev_id];
self.renderer = Some(
Renderer::new(
&dev.device,
RendererOptions {
use_cpu: false,
antialiasing_support: vello::AaSupport::area_only(),
num_init_threads: None,
pipeline_cache: None,
},
)
.expect("failed to create Vello renderer"),
);
let scale = window.scale_factor() as f32;
let (_, editor_h) = chrome_metrics(scale, size.height as f32, 0.0);
let doc = self
.doc_engine
.rebuild(size.width as f32, scale, 0, editor_h);
self.doc_engine.doc = Some(doc);
self.state = Some(ActiveSurface {
surface,
window,
scale,
find_bar_h: 0.0,
});
self.spawn_validations();
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
self.context.resize_surface(
&mut state.surface,
size.width.max(1),
size.height.max(1),
);
let (w, editor_h, scale) = state.viewport();
self.doc_engine
.rebuild_preserving_scroll(w, scale, editor_h);
state.window.request_redraw();
}
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
state.scale = scale_factor as f32;
sync_find_chrome(&mut self.doc_engine, state);
state.window.request_redraw();
}
WindowEvent::MouseWheel { delta, .. } => {
let dy = match delta {
MouseScrollDelta::LineDelta(_, y) => -y * WHEEL_LINE_STEP * state.scale,
MouseScrollDelta::PixelDelta(p) => -p.y as f32,
};
let (_, editor_h) = chrome_metrics(
state.scale,
state.surface.config.height as f32,
state.find_bar_h,
);
let remeasure = if let Some(doc) = self.doc_engine.doc.as_mut() {
doc.scroll_by(dy, editor_h);
doc.needs_remeasure(editor_h)
} else {
false
};
self.hovered = None; if remeasure {
let w = state.surface.config.width as f32;
let scale = state.scale;
self.doc_engine
.rebuild_preserving_scroll(w, scale, editor_h);
sync_image_loads(&self.doc_engine, &self.runtime, &self.proxy);
}
spawn_ref_validations(
&self.doc_engine.editor,
self.doc_engine.doc.as_ref().map(|d| {
let (a, b) = d.visible_range(editor_h);
a..b
}),
&self.runtime,
&self.proxy,
);
if self.doc_engine.doc.is_some() {
state.window.request_redraw();
}
}
WindowEvent::ModifiersChanged(mods) => {
self.modifiers = mods.state();
update_pointer_cursor(
&mut self.doc_engine,
state,
self.modifiers,
self.mouse_pos,
&self.fold_chevron_rects,
&self.ac_row_rects,
&mut self.cursor_icon,
);
}
WindowEvent::Ime(winit::event::Ime::Enabled) => {
self.doc_engine.preedit = None;
}
WindowEvent::Ime(winit::event::Ime::Preedit(text, cursor)) => {
if self
.doc_engine
.editor
.find_state()
.is_some_and(|f| f.focused)
{
state.window.request_redraw();
return;
}
if text.is_empty() && self.doc_engine.preedit.is_none() {
return;
}
self.doc_engine.preedit = (!text.is_empty()).then_some(Preedit { text, cursor });
let (w, vh, _) = state.viewport();
self.doc_engine
.rebuild_preserving_scroll(w, state.scale, vh);
if let Some(doc) = self.doc_engine.doc.as_ref() {
let cw = CARET_WIDTH * state.scale;
let rect = doc
.preedit_caret_rect(cw)
.or_else(|| doc.caret_rect(self.doc_engine.editor.cursor_position(), cw));
if let Some((x0, y0, x1, y1)) = rect {
state.window.set_ime_cursor_area(
PhysicalPosition::new(x0, y0),
PhysicalSize::new((x1 - x0).max(1.0), (y1 - y0).max(1.0)),
);
}
}
state.window.request_redraw();
}
WindowEvent::Ime(winit::event::Ime::Commit(text)) => {
if self
.doc_engine
.editor
.find_state()
.is_some_and(|f| f.focused)
{
if !text.is_empty() {
let to_replace = self
.doc_engine
.editor
.find_state()
.is_some_and(|f| f.focus == FieldFocus::Replace);
if let Some(find) = self.doc_engine.editor.find_state_mut() {
if to_replace {
find.replace.insert(&text);
} else {
find.search.insert(&text);
}
}
if !to_replace {
let (_, vh, _) = state.viewport();
self.doc_engine.editor.find_rescan();
if let Some(doc) = self.doc_engine.doc.as_mut() {
doc.scroll_to(self.doc_engine.editor.cursor_position(), vh);
}
}
}
state.window.request_redraw();
return;
}
let had_preedit = self.doc_engine.preedit.take().is_some();
let (w, vh, _) = state.viewport();
if !text.is_empty() {
self.doc_engine.editor.insert_str(&text);
apply_edit_effects(
&mut self.doc_engine,
&mut self.hovered,
&self.runtime,
&self.proxy,
None,
&state.window,
w,
state.scale,
vh,
);
} else if had_preedit {
self.doc_engine
.rebuild_preserving_scroll(w, state.scale, vh);
state.window.request_redraw();
}
}
WindowEvent::Ime(winit::event::Ime::Disabled) => {
if self.doc_engine.preedit.take().is_some() {
let (w, vh, _) = state.viewport();
self.doc_engine
.rebuild_preserving_scroll(w, state.scale, vh);
state.window.request_redraw();
}
}
WindowEvent::CursorMoved { position, .. } => {
self.mouse_pos = (position.x as f32, position.y as f32);
let panel_left = state.surface.config.width as f32 - OUTLINE_WIDTH * state.scale;
let new_outline_hover =
if self.doc_engine.editor.outline_open() && self.mouse_pos.0 >= panel_left {
ac_row_at(&self.outline_row_rects, self.mouse_pos)
} else {
None
};
if new_outline_hover != self.outline_hover {
self.outline_hover = new_outline_hover;
state.window.request_redraw();
}
let new_gutter_hover = self
.fold_chevron_rects
.iter()
.find(|(_, r)| rect_contains(r, self.mouse_pos))
.map(|(off, _)| *off);
if new_gutter_hover != self.gutter_hover {
self.gutter_hover = new_gutter_hover;
state.window.request_redraw();
}
if self.mouse_down {
let (_, vh, _) = state.viewport();
self.drag_scroll_dy = drag_edge_velocity(self.mouse_pos.1, vh, state.scale);
self.drag_pending = true;
state.window.request_redraw();
} else if self.doc_engine.editor.autocomplete().is_some() {
if let Some(row) = ac_row_at(&self.ac_row_rects, self.mouse_pos) {
self.doc_engine.editor.autocomplete_select(row);
state.window.request_redraw();
}
} else {
let new = self.doc_engine.doc.as_ref().and_then(|d| {
find_hover_target(
&self.doc_engine.editor,
d,
self.mouse_pos.0,
self.mouse_pos.1,
)
});
let changed = self.hovered.as_ref().map(|h| &h.reference)
!= new.as_ref().map(|h| &h.reference);
self.hovered = new;
if changed {
state.window.request_redraw();
}
}
if !self.mouse_down {
update_pointer_cursor(
&mut self.doc_engine,
state,
self.modifiers,
self.mouse_pos,
&self.fold_chevron_rects,
&self.ac_row_rects,
&mut self.cursor_icon,
);
}
}
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
..
} => {
self.mouse_down = true;
let (w, vh, _) = state.viewport();
let in_find_bar = if self.doc_engine.editor.find_state().is_some() {
let bar_top = vh;
let bar_bottom = vh + state.find_bar_h;
let click_y = self.mouse_pos.1;
if click_y < bar_top {
if let Some(find) = self.doc_engine.editor.find_state_mut() {
find.focused = false;
}
false
} else if click_y < bar_bottom {
let field = if self
.doc_engine
.editor
.find_state()
.is_some_and(|f| f.mode == FindMode::Replace)
&& click_y >= bar_top + state.find_bar_h / 2.0
{
FieldFocus::Replace
} else {
FieldFocus::Search
};
if let Some(find) = self.doc_engine.editor.find_state_mut() {
find.focused = true;
find.focus = field;
}
true
} else {
false }
} else {
false
};
if let Some(rects) = self.find_btn_rects.as_ref() {
let hit_all = rect_contains(&rects.all, self.mouse_pos);
let hit_replace = rect_contains(&rects.replace, self.mouse_pos);
if hit_all || hit_replace {
if hit_all {
self.doc_engine.editor.find_replace_all();
} else {
self.doc_engine.editor.find_replace_current();
}
apply_edit_effects(
&mut self.doc_engine,
&mut self.hovered,
&self.runtime,
&self.proxy,
None,
&state.window,
w,
state.scale,
vh,
);
return;
}
}
if in_find_bar {
state.window.request_redraw();
return;
}
if self.doc_engine.editor.autocomplete().is_some()
&& let Some(row) = ac_row_at(&self.ac_row_rects, self.mouse_pos)
{
self.doc_engine.editor.autocomplete_select(row);
if self.doc_engine.editor.accept_autocomplete_suggestion() {
apply_edit_effects(
&mut self.doc_engine,
&mut self.hovered,
&self.runtime,
&self.proxy,
None,
&state.window,
w,
state.scale,
vh,
);
}
return;
}
if let Some(&(off, _)) = self
.fold_chevron_rects
.iter()
.find(|(_, r)| rect_contains(r, self.mouse_pos))
{
let ctrl = self.modifiers.control_key() || self.modifiers.super_key();
let shift = self.modifiers.shift_key();
self.doc_engine.editor.apply_fold_gesture(off, ctrl, shift);
self.doc_engine
.rebuild_preserving_scroll(w, state.scale, vh);
state.window.request_redraw();
return;
}
if self.doc_engine.editor.outline_open()
&& self.mouse_pos.0 >= w - OUTLINE_WIDTH * state.scale
{
if let Some(i) = ac_row_at(&self.outline_row_rects, self.mouse_pos) {
let off = self
.doc_engine
.editor
.state
.buffer
.headings()
.get(i)
.map(|h| h.byte_offset);
if let Some(off) = off {
self.doc_engine.editor.set_cursor(off);
if self.doc_engine.editor.reveal_cursor() {
self.doc_engine
.rebuild_preserving_scroll(w, state.scale, vh);
}
let mut remeasure = false;
if let Some(doc) = self.doc_engine.doc.as_mut() {
doc.scroll_line_to_top(doc.line_of(off), vh);
remeasure = doc.needs_remeasure(vh);
}
if remeasure {
self.doc_engine
.rebuild_preserving_scroll(w, state.scale, vh);
if let Some(doc) = self.doc_engine.doc.as_mut() {
doc.scroll_line_to_top(doc.line_of(off), vh);
}
}
sync_image_loads(&self.doc_engine, &self.runtime, &self.proxy);
}
}
state.window.request_redraw();
return;
}
if let Some(off) = self
.doc_engine
.doc
.as_ref()
.and_then(|d| d.hit_test(self.mouse_pos.0, self.mouse_pos.1))
{
if (self.modifiers.control_key() || self.modifiers.super_key())
&& let Some(url) = self.doc_engine.editor.link_at(off)
{
if let Err(e) = open::that_detached(&url) {
eprintln!("[writ] failed to open {url}: {e}");
}
return;
}
if let Some(line) = self.doc_engine.editor.checkbox_at(off) {
self.doc_engine.editor.toggle_checkbox(line);
apply_edit_effects(
&mut self.doc_engine,
&mut self.hovered,
&self.runtime,
&self.proxy,
None,
&state.window,
w,
state.scale,
vh,
);
return;
}
let image_line = self.doc_engine.doc.as_ref().and_then(|d| {
let line = self.doc_engine.editor.line_of(off);
d.is_image_line(line).then_some(line)
});
let now = std::time::Instant::now();
let repeat = self.last_click.is_some_and(|(t, p)| {
now.duration_since(t).as_millis() <= MULTI_CLICK_MS
&& (p.0 - self.mouse_pos.0).hypot(p.1 - self.mouse_pos.1)
<= MULTI_CLICK_DIST
});
self.click_count = if repeat {
(self.click_count % 3) + 1
} else {
1
};
self.last_click = Some((now, self.mouse_pos));
self.doc_engine
.editor
.click(off, self.modifiers.shift_key(), self.click_count);
self.doc_engine.refresh(w, state.scale, vh);
if let Some(line) = image_line
&& let Some(off2) = self
.doc_engine
.doc
.as_ref()
.and_then(|d| d.offset_in_line_at_x(line, self.mouse_pos.0))
{
self.doc_engine.editor.set_cursor(off2);
}
sync_image_loads(&self.doc_engine, &self.runtime, &self.proxy);
sync_autocomplete(
&mut self.doc_engine.editor,
&self.runtime,
&self.proxy,
&self.ac_gen,
&self.ac_slot,
);
state.window.request_redraw();
}
}
WindowEvent::MouseInput {
state: ElementState::Released,
button: MouseButton::Left,
..
} => {
self.mouse_down = false;
self.drag_scroll_dy = 0.0;
}
WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => {
let cmd = self.modifiers.control_key() || self.modifiers.super_key();
if cmd
&& matches!(&event.logical_key, Key::Character(c) if c.as_str().eq_ignore_ascii_case("w"))
{
event_loop.exit();
return;
}
let (w, vh, _) = state.viewport();
let open_find = cmd
&& matches!(&event.logical_key, Key::Character(c) if c.as_str().eq_ignore_ascii_case("f"));
let open_replace = cmd
&& matches!(&event.logical_key, Key::Character(c) if c.as_str().eq_ignore_ascii_case("h"));
if open_find || open_replace {
self.doc_engine.editor.open_find(open_replace);
sync_find_chrome(&mut self.doc_engine, state);
let (_, feh, _) = state.viewport();
if let Some(doc) = self.doc_engine.doc.as_mut() {
doc.scroll_to(self.doc_engine.editor.cursor_position(), feh);
}
state.window.request_redraw();
return;
}
let shift = self.modifiers.shift_key();
if cmd
&& shift
&& matches!(&event.logical_key, Key::Character(c) if c.as_str().eq_ignore_ascii_case("o"))
{
self.doc_engine.editor.toggle_outline();
let (ow, oeh, oscale) = state.viewport();
self.doc_engine.rebuild_preserving_scroll(ow, oscale, oeh);
state.window.request_redraw();
return;
}
let alt = self.modifiers.alt_key();
if cmd && (shift || alt) {
let bracket = match event.physical_key {
PhysicalKey::Code(KeyCode::BracketLeft) => Some(true),
PhysicalKey::Code(KeyCode::BracketRight) => Some(false),
_ => None,
};
let level = shift.then(|| fold_level_key(event.physical_key)).flatten();
let acted = if let Some(left) = bracket {
match (shift, left) {
(true, true) => self.doc_engine.editor.fold_at_cursor(),
(true, false) => self.doc_engine.editor.unfold_at_cursor(),
(false, true) => self.doc_engine.editor.fold_all_headings(),
(false, false) => self.doc_engine.editor.unfold_all(),
}
true
} else if let Some(level) = level {
self.doc_engine.editor.fold_to_level(level);
true
} else {
false
};
if acted {
let (fw, feh, fscale) = state.viewport();
self.doc_engine.rebuild_preserving_scroll(fw, fscale, feh);
if let Some(doc) = self.doc_engine.doc.as_mut() {
doc.scroll_to(self.doc_engine.editor.cursor_position(), feh);
}
state.window.request_redraw();
return;
}
}
if self.doc_engine.editor.find_state().is_some()
&& matches!(&event.logical_key, Key::Named(NamedKey::Escape))
{
self.doc_engine.editor.close_find();
sync_find_chrome(&mut self.doc_engine, state);
state.window.request_redraw();
return;
}
if self
.doc_engine
.editor
.find_state()
.is_some_and(|f| f.focused)
{
let alt = self.modifiers.alt_key();
let shift = self.modifiers.shift_key();
let is_z = matches!(&event.logical_key, Key::Character(c) if c.as_str().eq_ignore_ascii_case("z"));
let is_y = matches!(&event.logical_key, Key::Character(c) if c.as_str().eq_ignore_ascii_case("y"));
if cmd && (is_z || is_y) {
if is_y || (is_z && shift) {
self.doc_engine.editor.redo();
} else {
self.doc_engine.editor.undo();
}
self.doc_engine.editor.find_rescan();
apply_edit_effects(
&mut self.doc_engine,
&mut self.hovered,
&self.runtime,
&self.proxy,
None,
&state.window,
w,
state.scale,
vh,
);
return;
}
match &event.logical_key {
Key::Named(NamedKey::Enter) => {
let replace_mode = self
.doc_engine
.editor
.find_state()
.is_some_and(|f| f.mode == FindMode::Replace);
let focus_replace = self
.doc_engine
.editor
.find_state()
.is_some_and(|f| f.focus == FieldFocus::Replace);
if replace_mode && (cmd || (!shift && focus_replace)) {
if cmd {
self.doc_engine.editor.find_replace_all();
} else {
self.doc_engine.editor.find_replace_current();
}
apply_edit_effects(
&mut self.doc_engine,
&mut self.hovered,
&self.runtime,
&self.proxy,
None,
&state.window,
w,
state.scale,
vh,
);
} else {
let hit = if shift {
self.doc_engine.editor.find_prev()
} else {
self.doc_engine.editor.find_next()
};
if hit.is_some() {
if self.doc_engine.editor.reveal_cursor() {
self.doc_engine.rebuild_preserving_scroll(
w,
state.scale,
vh,
);
}
if let Some(doc) = self.doc_engine.doc.as_mut() {
doc.scroll_to(self.doc_engine.editor.cursor_position(), vh);
}
}
}
}
Key::Named(NamedKey::Tab) => self.doc_engine.editor.find_toggle_field(),
Key::Character(c) if alt && c.as_str().eq_ignore_ascii_case("r") => {
self.doc_engine.editor.find_toggle_regex();
if let Some(doc) = self.doc_engine.doc.as_mut() {
doc.scroll_to(self.doc_engine.editor.cursor_position(), vh);
}
}
Key::Character(c) if alt && c.as_str().eq_ignore_ascii_case("c") => {
self.doc_engine.editor.find_toggle_case();
if let Some(doc) = self.doc_engine.doc.as_mut() {
doc.scroll_to(self.doc_engine.editor.cursor_position(), vh);
}
}
_ => {
let ctrl_v = cmd
&& matches!(&event.logical_key, Key::Character(c) if c.as_str().eq_ignore_ascii_case("v"));
let to_replace = self
.doc_engine
.editor
.find_state()
.is_some_and(|f| f.focus == FieldFocus::Replace);
if ctrl_v {
let text = self
.clipboard
.as_mut()
.and_then(|cb| cb.get_text().ok())
.filter(|t| !t.is_empty());
if let Some(text) = text
&& let Some(find) = self.doc_engine.editor.find_state_mut()
{
if to_replace {
find.replace.insert(&text);
} else {
find.search.insert(&text);
}
}
} else if let Some(find) = self.doc_engine.editor.find_state_mut() {
let field = if to_replace {
&mut find.replace
} else {
&mut find.search
};
text_input::apply_key(field, &event, self.modifiers);
}
if !to_replace {
self.doc_engine.editor.find_rescan();
if let Some(doc) = self.doc_engine.doc.as_mut() {
doc.scroll_to(self.doc_engine.editor.cursor_position(), vh);
}
}
}
}
state.window.request_redraw();
return;
}
if cmd && let Key::Character(c) = &event.logical_key {
match c.as_str().to_ascii_lowercase().as_str() {
"c" | "x" if self.doc_engine.editor.selected_text().is_some() => {
if let Some(sel) = self.doc_engine.editor.selected_text()
&& let Some(cb) = self.clipboard.as_mut()
{
let _ = cb.set_text(sel);
}
if c.as_str().eq_ignore_ascii_case("x") {
self.doc_engine.editor.backspace(); apply_edit_effects(
&mut self.doc_engine,
&mut self.hovered,
&self.runtime,
&self.proxy,
None,
&state.window,
w,
state.scale,
vh,
);
}
return;
}
"v" => {
if let Some(cb) = self.clipboard.as_mut()
&& let Ok(text) = cb.get_text()
&& !text.is_empty()
{
self.doc_engine.editor.paste(&text);
apply_edit_effects(
&mut self.doc_engine,
&mut self.hovered,
&self.runtime,
&self.proxy,
Some((&self.ac_gen, &self.ac_slot)),
&state.window,
w,
state.scale,
vh,
);
}
return;
}
_ => {}
}
}
let ac_has = self
.doc_engine
.editor
.autocomplete()
.map(|ac| !ac.suggestions.is_empty());
if let Some(has) = ac_has {
match &event.logical_key {
Key::Named(NamedKey::Escape) => {
self.doc_engine.editor.close_autocomplete();
state.window.request_redraw();
return;
}
Key::Named(NamedKey::ArrowUp) if has => {
self.doc_engine.editor.autocomplete_move(false);
state.window.request_redraw();
return;
}
Key::Named(NamedKey::ArrowDown) if has => {
self.doc_engine.editor.autocomplete_move(true);
state.window.request_redraw();
return;
}
Key::Named(NamedKey::Enter | NamedKey::Tab)
if has && self.doc_engine.editor.accept_autocomplete_suggestion() =>
{
apply_edit_effects(
&mut self.doc_engine,
&mut self.hovered,
&self.runtime,
&self.proxy,
None,
&state.window,
w,
state.scale,
vh,
);
return;
}
_ => {}
}
}
if apply_key(&mut self.doc_engine.editor, self.modifiers, &event) {
apply_edit_effects(
&mut self.doc_engine,
&mut self.hovered,
&self.runtime,
&self.proxy,
Some((&self.ac_gen, &self.ac_slot)),
&state.window,
w,
state.scale,
vh,
);
}
}
WindowEvent::RedrawRequested => {
if self.drag_pending {
self.drag_pending = false;
let (w, vh, scale) = state.viewport();
drag_extend_step(&mut self.doc_engine, self.mouse_pos, 0.0, w, scale, vh);
}
self.scene.reset();
let desired = window_title(&self.doc_engine.editor);
if desired != self.title {
self.title = desired;
state.window.set_title(&self.title);
}
let width = state.surface.config.width as f32;
let height = state.surface.config.height as f32;
let (content_top, editor_h) = chrome_metrics(state.scale, height, state.find_bar_h);
if self.pending_rebuild {
self.doc_engine
.rebuild_preserving_scroll(width, state.scale, editor_h);
sync_image_loads(&self.doc_engine, &self.runtime, &self.proxy);
self.pending_rebuild = false;
}
if let Some(doc) = self.doc_engine.doc.as_ref() {
let (btn_rects, outline_rects, fold_rects) = paint_document(
&mut self.scene,
&mut self.doc_engine.text_engine,
&self.doc_engine.theme,
&self.doc_engine.editor,
doc,
content_top,
editor_h,
state.find_bar_h,
width,
height,
state.scale,
self.outline_hover,
self.gutter_hover,
);
self.find_btn_rects = btn_rects;
self.outline_row_rects = outline_rects;
self.fold_chevron_rects = fold_rects;
let ac_open = self
.doc_engine
.editor
.autocomplete()
.is_some_and(|ac| !ac.suggestions.is_empty());
if ac_open {
if let Some(caret) = doc.caret_rect(
self.doc_engine.editor.cursor_position(),
CARET_WIDTH * state.scale,
) {
let ac = self.doc_engine.editor.autocomplete().expect("open");
self.ac_row_rects = draw_autocomplete(
&mut self.doc_engine.text_engine,
&mut self.scene,
&self.doc_engine.theme,
ac,
caret,
width,
height,
state.scale,
);
}
} else {
self.ac_row_rects.clear();
if let Some(target) = self.hovered.as_ref() {
draw_hover_popover(
&mut self.doc_engine.text_engine,
&mut self.scene,
&self.doc_engine.theme,
&self.doc_engine.editor,
target,
width,
height,
state.scale,
);
}
}
}
let dev = &self.context.devices[state.surface.dev_id];
let params = RenderParams {
base_color: peniko_color(self.doc_engine.theme.background),
width: state.surface.config.width,
height: state.surface.config.height,
antialiasing_method: AaConfig::Area,
};
let surface_texture = match state.surface.surface.get_current_texture() {
CurrentSurfaceTexture::Success(t) | CurrentSurfaceTexture::Suboptimal(t) => t,
other => {
eprintln!("[writ] skip frame: {:?}", other);
return;
}
};
self.renderer
.as_mut()
.expect("renderer missing")
.render_to_texture(
&dev.device,
&dev.queue,
&self.scene,
&state.surface.target_view,
¶ms,
)
.expect("render_to_texture failed");
let mut encoder =
dev.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("surface_blit"),
});
state.surface.blitter.copy(
&dev.device,
&mut encoder,
&state.surface.target_view,
&surface_texture
.texture
.create_view(&wgpu::TextureViewDescriptor::default()),
);
dev.queue.submit([encoder.finish()]);
state.window.pre_present_notify();
surface_texture.present();
}
_ => {}
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if self.mouse_down && self.drag_scroll_dy != 0.0 {
let now = std::time::Instant::now();
let dt = self
.last_drag_tick
.map_or(0.0, |t| now.duration_since(t).as_secs_f32());
self.last_drag_tick = Some(now);
let dy = self.drag_scroll_dy * dt;
if let Some((w, editor_h, scale, window)) = self.state.as_ref().map(|s| {
let (w, editor_h, scale) = s.viewport();
(w, editor_h, scale, s.window.clone())
}) {
drag_extend_step(&mut self.doc_engine, self.mouse_pos, dy, w, scale, editor_h);
window.request_redraw();
}
event_loop.set_control_flow(ControlFlow::WaitUntil(
now + std::time::Duration::from_millis(16),
));
} else {
self.last_drag_tick = None;
event_loop.set_control_flow(ControlFlow::Wait);
}
}
}
pub fn run() -> Result<()> {
if std::env::var_os("WINIT_WAYLAND_CSD_THEME").is_none() {
unsafe { std::env::set_var("WINIT_WAYLAND_CSD_THEME", "dark") };
}
let _ = rustls::crypto::ring::default_provider().install_default();
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let _guard = runtime.enter();
if let Ok(path) = std::env::var("WRIT_SHELL_SNAPSHOT") {
let env_f32 = |k: &str, d: f32| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
let w = env_f32("WRIT_SHELL_W", 1000.0) as u32;
let h = env_f32("WRIT_SHELL_H", 400.0) as u32;
let scroll = env_f32("WRIT_SHELL_SCROLL", 0.0);
return snapshot(&path, w, h, scroll);
}
let (editor, app_config) = load_editor_from_cli();
let event_loop = EventLoop::<WritEvent>::with_user_event().build()?;
let proxy = event_loop.create_proxy();
let mut app = App::new(editor, app_config, proxy, runtime.handle().clone());
event_loop.run_app(&mut app)?;
Ok(())
}
fn load_editor_from_cli() -> (Editor, ResolvedConfig) {
use clap::Parser;
if std::env::args().len() <= 1 {
return (demo_editor(), ResolvedConfig::default());
}
let config = Config::parse();
let app_config = config::load(config.config.as_deref());
if config.demo {
return (demo_editor(), app_config);
}
let Some(path) = config.file.clone() else {
return (demo_editor(), app_config);
};
let mut editor = Editor::open(&path);
editor.set_autosave(config.autosave);
let context = config
.github_repo
.as_deref()
.and_then(parse_github_repo_string)
.or_else(|| detect_github_context(&path));
if let Some(ctx) = context {
editor.set_github_context(ctx);
}
if let Some(token) = config.github_token.clone() {
editor.set_github_client(GitHubClient::new(token));
}
editor.refresh_detection(0..usize::MAX);
if let Err(e) = editor.watch_file() {
eprintln!("[writ] file watch failed: {e}");
}
(editor, app_config)
}
fn validate_all_blocking(editor: &mut Editor) {
let Some(client) = editor.github_client().cloned() else {
return;
};
let cache = editor.github_validation_cache().clone();
let refs = editor.detected_refs();
tokio::runtime::Handle::current().block_on(async {
for r in refs {
validate_ref_into_cache(&client, &cache, r).await;
}
});
}
fn fetch_autocomplete_blocking(editor: &mut Editor) {
let Some((trigger, prefix)) = editor.begin_autocomplete_fetch() else {
return;
};
let Some(client) = editor.github_client().cloned() else {
return;
};
let Some(context) = editor.github_context().cloned() else {
return;
};
let fetched = tokio::runtime::Handle::current()
.block_on(fetch_suggestions(&client, &context, trigger, prefix));
apply_fetched(editor, fetched);
}
pub fn snapshot(path: &str, width: u32, height: u32, scroll_y: f32) -> Result<()> {
let mut editor = match std::env::var("WRIT_SHELL_FILE") {
Ok(p) => Editor::open(std::path::Path::new(&p)),
Err(_) => Editor::new(DEMO_DOC),
};
let env_usize = |k: &str| std::env::var(k).ok().and_then(|v| v.parse().ok());
editor.set_cursor(env_usize("WRIT_SHELL_CURSOR").unwrap_or(0));
if let (Some(a), Some(b)) = (env_usize("WRIT_SHELL_SEL_A"), env_usize("WRIT_SHELL_SEL_B")) {
editor.click(a, false, 1);
editor.drag(b);
}
if std::env::var("WRIT_SHELL_DIFF").is_ok() {
editor.set_head_base(DEMO_BASE);
}
if std::env::var("WRIT_SHELL_OUTLINE").is_ok() {
editor.set_outline_open(true);
}
if let Ok(token) = std::env::var("GITHUB_TOKEN")
&& std::env::var("WRIT_SHELL_GITHUB").is_ok()
{
if let Some(ctx) = std::env::var("WRIT_SHELL_GITHUB_REPO")
.ok()
.and_then(|s| parse_github_repo_string(&s))
{
editor.set_github_context(ctx);
}
editor.set_github_client(GitHubClient::new(token));
editor.refresh_detection(0..usize::MAX);
validate_all_blocking(&mut editor);
if std::env::var("WRIT_SHELL_AUTOCOMPLETE").is_ok() {
editor.update_autocomplete_from_cursor();
if editor.autocomplete().is_some() {
fetch_autocomplete_blocking(&mut editor);
}
}
}
let (content_top, editor_h) = chrome_metrics(1.0, height as f32, 0.0);
let mut de = DocEngine {
text_engine: TextEngine::new(),
line_cache: LineCache::new(),
render_cache: RenderCache::new(),
height_cache: HeightCache::new(),
table_cache: TableCache::new(),
theme: EditorTheme::dracula(),
font_size: FONT_SIZE,
editor,
doc: None,
images: ImageCache::new(),
preedit: None,
};
de.editor.refresh_detection(0..usize::MAX);
let mut doc = de.rebuild(width as f32, 1.0, 0, f32::INFINITY);
let img_dir = de
.editor
.file_path()
.and_then(|p| p.parent().map(|p| p.to_path_buf()));
load_local_images_blocking(img_dir.as_deref(), doc.image_urls(), &de.images);
#[cfg(feature = "mermaid")]
mermaid::render_mermaid_blocking(doc.mermaid_sources(), &de.images);
#[cfg(feature = "math")]
math::render_math_blocking(doc.math_sources(), &de.images);
doc = de.rebuild(width as f32, 1.0, 0, f32::INFINITY);
doc.scroll_by(scroll_y, editor_h);
let mut scene = Scene::new();
paint_document(
&mut scene,
&mut de.text_engine,
&de.theme,
&de.editor,
&doc,
content_top,
editor_h,
0.0,
width as f32,
height as f32,
1.0,
None,
None,
);
if let Some(off) = std::env::var("WRIT_SHELL_HOVER")
.ok()
.and_then(|v| v.parse::<usize>().ok())
&& let Some(t) = hover_target_at_offset(&de.editor, &doc, off)
{
draw_hover_popover(
&mut de.text_engine,
&mut scene,
&de.theme,
&de.editor,
&t,
width as f32,
height as f32,
1.0,
);
}
if let Some(ac) = de
.editor
.autocomplete()
.filter(|ac| !ac.suggestions.is_empty())
&& let Some(caret) = doc.caret_rect(de.editor.cursor_position(), 2.0)
{
draw_autocomplete(
&mut de.text_engine,
&mut scene,
&de.theme,
ac,
caret,
width as f32,
height as f32,
1.0,
);
}
rasterize_scene_to_png(
&scene,
width,
height,
peniko_color(de.theme.background),
path,
)?;
eprintln!("[writ] wrote snapshot: {path} ({width}x{height})");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn drag_edge_velocity_zones() {
let h = 400.0;
assert_eq!(drag_edge_velocity(200.0, h, 1.0), 0.0);
assert!(drag_edge_velocity(5.0, h, 1.0) < drag_edge_velocity(20.0, h, 1.0));
assert!(drag_edge_velocity(5.0, h, 1.0) < 0.0);
assert!(drag_edge_velocity(h - 2.0, h, 1.0) > 0.0);
assert!((drag_edge_velocity(0.0, h, 1.0) + DRAG_SCROLL_EDGE_SPEED).abs() < 1e-3);
assert!(drag_edge_velocity(-10.0, h, 1.0).abs() > DRAG_SCROLL_EDGE_SPEED);
assert!(drag_edge_velocity(-100_000.0, h, 1.0).abs() <= DRAG_SCROLL_MAX_SPEED + 1e-3);
assert!(drag_edge_velocity(h + 100_000.0, h, 1.0) <= DRAG_SCROLL_MAX_SPEED + 1e-3);
assert!(drag_edge_velocity(30.0, h, 2.0) < 0.0);
assert_eq!(drag_edge_velocity(30.0, h, 1.0), 0.0);
}
}