#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[path = "shared/capture.rs"]
mod capture;
use std::time::Instant;
use iced::alignment::{Horizontal, Vertical};
use iced::widget::operation::{focus, focus_next, focus_previous, is_focused};
use iced::widget::{button, column, container, row, stack, text, text_input};
use iced::{Alignment, Color, Element, Length, Shadow, Subscription, Task, Theme, Vector};
use std::ops::Range;
use scrive_core::{
default_indent_size, is_completion_word_char, CompletionController, CompletionCx, CompletionItem,
CompletionKind, CompletionState, CompletionTrigger, Completions, Diagnostic, Document, FindQuery,
Hover, HoverCx, HoverInfo, InsertText, Point, Selection, SelectionId, SelectionSet, Severity,
SignatureCx, SignatureHelp, SignatureInfo, Snippet, SnippetSession, SyntaxDef, TabOutcome,
TokenTheme, LOOKBACK_LINES,
};
use scrive_iced::{Action, Editor};
const FIND_INPUT: &str = "scrive-find-input";
const REPLACE_INPUT: &str = "scrive-replace-input";
const EDITOR: &str = "editor";
static LARGE_DOC: std::sync::OnceLock<String> = std::sync::OnceLock::new();
const RELINT_MAX_BYTES: u32 = 2 * 1_048_576;
const RELINT_DEBOUNCE_MS: u64 = 250;
#[cfg(test)]
thread_local! {
static RELINT_RUNS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
thread_local! {
static POLL_RERUNS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
fn gen_large(mb: usize) -> String {
let target = mb * 1_048_576;
let mut rng = 7u64;
let mut next = move || {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
rng
};
let mut s = String::with_capacity(target + 512);
let mut i = 0usize;
while s.len() < target {
s.push_str(&format!("// block {i}: canned readings for id 0x{:02X}\n", next() & 0xFF));
s.push_str(&format!("fn read_{i}(id: u8) -> u8 {{\n match id {{\n"));
for _ in 0..3 {
s.push_str(&format!(" 0x{:02X} => {{ return {}; }}\n", next() & 0xFF, next() % 200));
}
s.push_str(" _ => { return 0; }\n }\n}\n\n");
i += 1;
}
s
}
fn long_sample() -> String {
r#"//! A fixed-capacity window over the most recent sensor readings, with a
//! rolling average — a small, self-contained example.
use std::collections::VecDeque;
/// The most recent readings, oldest first, capped at `capacity`.
pub struct Samples {
values: VecDeque<f64>,
capacity: usize,
}
impl Samples {
/// Create an empty window that keeps at most `capacity` readings.
pub fn new(capacity: usize) -> Self {
Samples {
values: VecDeque::with_capacity(capacity),
capacity,
}
}
/// Record one reading, evicting the oldest once the window is full.
pub fn push(&mut self, reading: f64) {
if self.values.len() == self.capacity {
self.values.pop_front();
}
self.values.push_back(reading);
}
/// The mean of the retained readings, or `None` when the window is empty.
pub fn average(&self) -> Option<f64> {
match self.values.len() {
0 => None,
n => {
let total: f64 = self.values.iter().sum();
Some(total / n as f64)
}
}
}
}
fn main() {
let mut window = Samples::new(4);
for reading in [21.5, 22.0, 23.25, 24.0, 25.5] {
window.push(reading);
println!("average = {:?}", window.average());
}
}
"#
.to_string()
}
const PARALLEL_MIN_BYTES: u32 = 2 * 1_048_576;
const SEGMENT_MAX_ROWS: u32 = 32_768;
const SPECULATION_BACKOFF: u32 = 128;
const POLL_VERIFY_BUDGET: usize = 4;
const POLL_RERUN_BUDGET: usize = 1;
struct Job {
idx: usize,
snapshot: std::sync::Arc<scrive_core::Snapshot>,
rows: Range<u32>,
start: scrive_core::SegmentStart,
spans_for: Range<u32>,
against: Option<scrive_core::SegmentTokens>,
rev: scrive_core::Revision,
}
struct Done {
idx: usize,
seg: scrive_core::SegmentTokens,
rev: scrive_core::Revision,
}
struct HighlightPool {
engine: scrive_core::HighlightEngine,
fresh: scrive_core::SegmentBoundary,
queue: std::sync::Arc<(std::sync::Mutex<std::collections::VecDeque<Job>>, std::sync::Condvar)>,
cur_rev: std::sync::Arc<std::sync::atomic::AtomicU64>,
done_rx: std::sync::mpsc::Receiver<Done>,
_workers: Vec<std::thread::JoinHandle<()>>,
snapshot: std::sync::Arc<scrive_core::Snapshot>,
rev: scrive_core::Revision,
seg_rows: Vec<Range<u32>>,
results: Vec<Option<scrive_core::SegmentTokens>>,
next_verify: usize,
prev_end: Option<scrive_core::SegmentBoundary>,
window: Range<u32>,
active: bool,
}
impl HighlightPool {
fn new(doc: &Document, viewport: Range<u32>) -> Option<Self> {
let engine = doc.highlight_engine()?;
let count = std::thread::available_parallelism()
.map(|n| n.get().saturating_sub(1).max(1))
.unwrap_or(1);
let (done_tx, done_rx) = std::sync::mpsc::channel::<Done>();
let queue: std::sync::Arc<(std::sync::Mutex<std::collections::VecDeque<Job>>, std::sync::Condvar)> =
std::sync::Arc::new((std::sync::Mutex::new(std::collections::VecDeque::new()), std::sync::Condvar::new()));
let cur_rev = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(doc.revision().0));
let workers = (0..count)
.map(|_| {
let engine = engine.clone();
let queue = queue.clone();
let cur_rev = cur_rev.clone();
let done_tx = done_tx.clone();
std::thread::spawn(move || loop {
let job = {
let (lock, cvar) = &*queue;
let mut q = lock.lock().unwrap();
while q.is_empty() {
q = cvar.wait(q).unwrap();
}
q.pop_front().unwrap()
};
if job.rev.0 != cur_rev.load(std::sync::atomic::Ordering::Relaxed) {
continue;
}
let seg = scrive_core::tokenize_segment(
&engine, &job.snapshot, job.rows, job.start, Some(job.spans_for),
job.against.as_ref(),
);
let _ = done_tx.send(Done { idx: job.idx, seg, rev: job.rev });
})
})
.collect();
let fresh = engine.fresh_boundary();
let mut pool = Self {
engine,
fresh,
queue,
cur_rev,
done_rx,
_workers: workers,
snapshot: std::sync::Arc::new(doc.snapshot()),
rev: doc.revision(),
seg_rows: Vec::new(),
results: Vec::new(),
next_verify: 0,
prev_end: None,
window: 0..0,
active: false,
};
pool.start(doc, viewport);
Some(pool)
}
fn start(&mut self, doc: &Document, viewport: Range<u32>) {
self.rev = doc.revision();
self.cur_rev.store(self.rev.0, std::sync::atomic::Ordering::Relaxed);
self.queue.0.lock().unwrap().clear();
self.snapshot = std::sync::Arc::new(doc.snapshot());
let n = self.snapshot.line_count();
self.set_window(viewport, n);
let workers = self._workers.len() as u32;
let segs = (n.div_ceil(SEGMENT_MAX_ROWS)).max(workers).max(1);
let seg_len = n.div_ceil(segs).max(1);
self.seg_rows = (0..n)
.step_by(seg_len as usize)
.map(|s| s..(s + seg_len).min(n))
.collect();
self.results = (0..self.seg_rows.len()).map(|_| None).collect();
self.next_verify = 0;
self.prev_end = None;
self.active = !self.seg_rows.is_empty();
let (lock, cvar) = &*self.queue;
let mut q = lock.lock().unwrap();
for (idx, rows) in self.seg_rows.iter().enumerate() {
q.push_back(Job {
idx,
snapshot: self.snapshot.clone(),
rows: rows.clone(),
start: scrive_core::SegmentStart::Fresh,
spans_for: self.window.clone(),
against: None,
rev: self.rev,
});
}
cvar.notify_all();
}
fn set_window(&mut self, viewport: Range<u32>, n: u32) {
self.window = scrive_core::padded_highlight_window(viewport, n);
}
fn restart(&mut self, doc: &mut Document, viewport: Range<u32>) {
self.start(doc, viewport.clone());
self.speculate(doc, viewport);
}
fn reaim(&mut self, doc: &mut Document, viewport: Range<u32>) {
let n = doc.buffer().line_count();
self.set_window(viewport.clone(), n);
self.speculate(doc, viewport);
}
fn speculate(&self, doc: &mut Document, viewport: Range<u32>) {
if !self.active || self.rev != doc.revision() {
return;
}
let n = self.snapshot.line_count();
let _ = viewport;
let rows = self.window.start.saturating_sub(SPECULATION_BACKOFF)..self.window.end.min(n);
if rows.start >= rows.end {
return;
}
let seg = scrive_core::tokenize_segment(
&self.engine,
&self.snapshot,
rows,
scrive_core::SegmentStart::Fresh,
Some(self.window.clone()),
None,
);
doc.absorb_highlight(self.rev, seg, false);
}
fn poll(&mut self, doc: &mut Document) {
while let Ok(done) = self.done_rx.try_recv() {
if done.rev == self.rev {
self.results[done.idx] = Some(done.seg);
} }
let mut verified = 0usize;
let mut reruns = 0usize;
while self.next_verify < self.results.len() {
if verified >= POLL_VERIFY_BUDGET {
break; }
let Some(seg_ref) = self.results[self.next_verify].as_ref() else { break };
let true_start_fresh =
self.next_verify == 0 || self.prev_end.as_ref() == Some(&self.fresh);
let needs_rerun = seg_ref.started_fresh() && !true_start_fresh;
if needs_rerun && reruns >= POLL_RERUN_BUDGET {
break; }
let seg = self.results[self.next_verify].take().expect("peeked Some");
if needs_rerun {
let start = scrive_core::SegmentStart::After(
self.prev_end.clone().expect("prev segment is verified"),
);
let fixed = scrive_core::tokenize_segment(
&self.engine,
&self.snapshot,
self.seg_rows[self.next_verify].clone(),
start,
None, Some(&seg),
);
let end = fixed.end_boundary().clone();
doc.absorb_highlight(self.rev, fixed, true);
self.prev_end = Some(end);
reruns += 1;
#[cfg(test)]
POLL_RERUNS.with(|c| c.set(c.get() + 1));
} else {
let end = seg.end_boundary().clone();
doc.absorb_highlight(self.rev, seg, true);
self.prev_end = Some(end);
}
self.next_verify += 1;
verified += 1;
}
if self.next_verify >= self.results.len() && self.active {
self.active = false;
}
}
}
pub struct App {
doc: Document,
find_open: bool,
find_query: String,
find_case: bool,
find_whole_word: bool,
find_regex: bool,
replace_open: bool,
replace_text: String,
find_focused: bool,
replace_focused: bool,
replace_preserve_case: bool,
start: Instant,
viewport: Range<u32>,
completion: CompletionController,
provider: StubCompletions,
snippet: Option<SnippetSession>,
sig_provider: StubSignatures,
signature: Option<SignatureInfo>,
hover_provider: StubHover,
hover: Option<HoverInfo>,
hl_pool: Option<HighlightPool>,
relint_dirty: bool,
last_relint_ms: u64,
}
struct StubHover;
impl Hover for StubHover {
fn hover(&mut self, cx: &HoverCx) -> Option<HoverInfo> {
let rev: String = cx.lookback.chars().rev().take_while(|c| is_completion_word_char(*c)).collect();
let word: String = rev.chars().rev().collect();
let markdown = match word.as_str() {
"fn" => "**fn** `name(args) -> ret` — defines a function.",
"let" => "**let** — bind a value to a name; add `mut` to allow reassignment.",
"mut" => "**mut** — make a binding or reference mutable.",
"const" => "**const** — a compile-time constant.",
"pub" => "**pub** — export an item from its module.",
"use" => "**use** — bring a path into scope.",
"mod" => "**mod** — declare a module.",
"struct" => "**struct** `Name { fields }` — a named record type.",
"enum" => "**enum** `Name { variants }` — a type that is one of several variants.",
"impl" => "**impl** — associate methods or a trait with a type.",
"trait" => "**trait** — a set of methods a type can implement.",
"match" => "**match** `x { pat => expr, … }` — branch on a value's shape.",
"for" => "**for** `x in iter { … }` — iterate over an iterator.",
"while" => "**while** `cond { … }` — loop while the condition holds.",
"loop" => "**loop** — repeat the body forever, until `break`.",
"if" => "**if** `cond { … } else { … }` — a conditional.",
"else" => "**else** — the branch taken when the `if` condition is false.",
"return" => "**return** — return a value from the enclosing function.",
"self" => "**self** — the receiver of a method.",
"Self" => "**Self** — the type the enclosing `impl` block is for.",
"as" => "**as** — a primitive cast, e.g. `n as f64`.",
"Option" => "**Option<T>** — either `Some(T)` or `None`.",
"Some" => "**Some(T)** — an `Option` that holds a value.",
"None" => "**None** — an `Option` that holds nothing.",
"Result" => "**Result<T, E>** — either `Ok(T)` or `Err(E)`.",
"Vec" => "**Vec<T>** — a growable, heap-allocated array.",
"VecDeque" => "**VecDeque<T>** — a double-ended queue.",
"String" => "**String** — an owned, growable UTF-8 string.",
"u8" => "**u8** — an unsigned 8-bit integer.",
"u32" => "**u32** — an unsigned 32-bit integer.",
"usize" => "**usize** — a pointer-sized unsigned integer.",
"f64" => "**f64** — a 64-bit floating-point number.",
"bool" => "**bool** — either `true` or `false`.",
"str" => "**str** — a borrowed string slice.",
_ => return None,
};
Some(HoverInfo { markdown: markdown.to_string(), range: cx.word.clone() })
}
}
struct StubSignatures;
impl SignatureHelp for StubSignatures {
#[allow(clippy::single_range_in_vec_init)] fn signature(&mut self, cx: &SignatureCx) -> Option<SignatureInfo> {
let (name, comma) = enclosing_call(&cx.lookback)?;
let (label, params): (&str, Vec<Range<u32>>) = match name.as_str() {
"new" => ("new(capacity: usize) -> Samples", vec![4..19]),
"with_capacity" => ("with_capacity(capacity: usize) -> VecDeque<T>", vec![14..29]),
"push" => ("push(&mut self, reading: f64)", vec![5..14, 16..28]),
"push_back" => ("push_back(&mut self, value: T)", vec![10..19, 21..29]),
"average" => ("average(&self) -> Option<f64>", vec![8..13]),
_ => return None,
};
let active = comma.min(params.len().saturating_sub(1) as u32);
let doc = match name.as_str() {
"new" => Some("Create an empty window that keeps at most `capacity` readings.".to_string()),
"push" => Some("Record one reading, evicting the oldest once the window is full.".to_string()),
"average" => Some("The mean of the retained readings, or `None` when empty.".to_string()),
_ => None,
};
Some(SignatureInfo { label: label.to_string(), params, active, doc })
}
}
fn enclosing_call(lookback: &str) -> Option<(String, u32)> {
let chars: Vec<char> = lookback.chars().collect();
let mut depth = 0i32;
let mut commas = 0u32;
let mut i = chars.len();
while i > 0 {
i -= 1;
match chars[i] {
')' | ']' => depth += 1,
'(' | '[' if depth > 0 => depth -= 1,
'(' => {
let mut j = i;
while j > 0 && is_completion_word_char(chars[j - 1]) {
j -= 1;
}
let name: String = chars[j..i].iter().collect();
return (!name.is_empty()).then_some((name, commas));
}
',' if depth == 0 => commas += 1,
_ => {}
}
}
None
}
struct StubCompletions {
items: Vec<CompletionItem>,
}
impl StubCompletions {
fn new() -> Self {
let kw = |l: &str| CompletionItem::plain(l, CompletionKind::Keyword);
let ty = |l: &str| CompletionItem::plain(l, CompletionKind::Type);
Self {
items: vec![
kw("let"),
kw("mut"),
kw("pub"),
kw("const"),
kw("use"),
kw("mod"),
CompletionItem::new("fn", CompletionKind::Construct, InsertText::Snippet("fn ${1:name}(${2:args}) -> ${3:()} {\n\t$0\n}".into()))
.with_detail("name(args) -> ret")
.with_doc("Define a function."),
CompletionItem::new("struct", CompletionKind::Construct, InsertText::Snippet("struct ${1:Name} {\n\t$0\n}".into()))
.with_detail("Name { fields }")
.with_doc("Define a record type."),
CompletionItem::new("enum", CompletionKind::Construct, InsertText::Snippet("enum ${1:Name} {\n\t$0\n}".into()))
.with_detail("Name { variants }")
.with_doc("Define a variant type."),
CompletionItem::new("impl", CompletionKind::Construct, InsertText::Snippet("impl ${1:Type} {\n\t$0\n}".into()))
.with_detail("Type { … }")
.with_doc("Associate methods with a type."),
CompletionItem::new("trait", CompletionKind::Construct, InsertText::Snippet("trait ${1:Name} {\n\t$0\n}".into()))
.with_detail("Name { … }")
.with_doc("Define a trait."),
CompletionItem::new("match", CompletionKind::Construct, InsertText::Snippet("match ${1:expr} {\n\t${2:pattern} => $0,\n}".into()))
.with_detail("expr { arms }")
.with_doc("Branch on a value's shape."),
kw("if"),
kw("else"),
kw("for"),
kw("while"),
kw("loop"),
kw("break"),
kw("continue"),
kw("return"),
kw("as"),
kw("where"),
ty("u8"),
ty("u16"),
ty("u32"),
ty("u64"),
ty("usize"),
ty("i32"),
ty("f64"),
ty("bool"),
ty("char"),
ty("str"),
ty("String"),
ty("Vec"),
ty("Option"),
ty("Result"),
],
}
}
}
impl Completions for StubCompletions {
fn complete(&mut self, _cx: &CompletionCx) -> Vec<CompletionItem> {
self.items.clone()
}
}
#[derive(Clone, Copy)]
enum CompletionEvent {
Typed(char),
Deleting,
CaretOrClose,
}
impl Default for App {
fn default() -> Self {
let sample;
let source: &str = match LARGE_DOC.get() {
Some(s) => s,
None => {
sample = long_sample();
&sample
}
};
let mut doc = Document::new(source).expect("sample fits the u32 offset space");
doc.set_line_comment(Some("//"));
doc.set_syntax(
SyntaxDef::from_sublime_syntax(include_str!("assets/rust.sublime-syntax"))
.expect("bundled Rust grammar parses"),
TokenTheme::from_tm_theme(include_str!("assets/scrive-dark.tmTheme"))
.expect("bundled theme parses"),
);
let mut app = Self {
doc,
find_open: false,
find_query: String::new(),
find_case: false,
find_whole_word: false,
find_regex: false,
replace_open: false,
replace_text: String::new(),
find_focused: false,
replace_focused: false,
replace_preserve_case: false,
start: Instant::now(),
viewport: 0..0,
completion: CompletionController::new(),
provider: StubCompletions::new(),
snippet: None,
sig_provider: StubSignatures,
signature: None,
hover_provider: StubHover,
hover: None,
hl_pool: None,
relint_dirty: false,
last_relint_ms: 0,
};
app.relint(); app
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Msg {
Editor(Action),
OpenFind,
OpenReplace,
CycleFocus { back: bool },
Focused { replace: bool, on: bool },
PointerDown,
CloseFind,
FindQuery(String),
ToggleCase,
ToggleWholeWord,
ToggleRegex,
ToggleFindInSelection,
FindNext,
FindPrev,
FindSelectAll,
ToggleReplace,
ReplaceText(String),
ReplaceOne,
ReplaceAll,
TogglePreserveCase,
HighlightSweep,
MaybeRelint,
}
impl App {
fn close_find(&mut self) {
self.find_open = false;
self.replace_open = false;
self.find_focused = false;
self.replace_focused = false;
self.find_query.clear();
self.doc.set_find_query(None, self.now_ms());
}
fn sync_rings() -> Task<Msg> {
Task::batch([
is_focused(FIND_INPUT).map(|on| Msg::Focused { replace: false, on }),
is_focused(REPLACE_INPUT).map(|on| Msg::Focused { replace: true, on }),
])
}
fn resync_focus() -> Task<Msg> {
Task::batch([
is_focused(FIND_INPUT).then(|f| if f { focus(FIND_INPUT) } else { Task::none() }),
is_focused(REPLACE_INPUT).then(|f| if f { focus(REPLACE_INPUT) } else { Task::none() }),
])
}
fn push_find_query(&mut self) {
let query = (!self.find_query.is_empty()).then(|| {
let mut q = FindQuery::new(self.find_query.clone());
q.case_sensitive = self.find_case;
q.whole_word = self.find_whole_word;
q.regex = self.find_regex;
q
});
let now = self.now_ms();
self.doc.set_find_query(query, now); }
pub fn update(&mut self, msg: Msg) -> Task<Msg> {
match msg {
Msg::Editor(Action::ViewportChanged(rows)) => {
self.viewport = rows.clone();
self.doc.set_highlight_window(rows.clone());
if self.doc.buffer().len() >= PARALLEL_MIN_BYTES {
let App { hl_pool, doc, .. } = self;
match hl_pool {
Some(pool) => pool.reaim(doc, rows),
None => {
if let Some(pool) = HighlightPool::new(doc, rows.clone()) {
pool.speculate(doc, rows);
*hl_pool = Some(pool);
}
}
}
} else {
self.doc.tokenize_highlight(rows.end);
}
self.hover = None; Task::none()
}
Msg::Editor(Action::PopupUp) => {
self.completion.move_selection(false);
Task::none()
}
Msg::Editor(Action::PopupDown) => {
self.completion.move_selection(true);
Task::none()
}
Msg::Editor(Action::PopupDismiss) => {
self.completion.escape();
Task::none()
}
Msg::Editor(Action::PopupAccept) => {
self.accept_completion();
Task::none()
}
Msg::Editor(Action::PopupClickAccept(idx)) => {
self.completion.set_selected(idx);
self.accept_completion();
Task::none()
}
Msg::Editor(Action::SnippetTab) => {
self.snippet_tab(true);
Task::none()
}
Msg::Editor(Action::SnippetTabPrev) => {
self.snippet_tab(false);
Task::none()
}
Msg::Editor(Action::SnippetCancel) => {
if let Some(mut s) = self.snippet.take() {
s.cancel(self.doc.decorations_mut());
}
Task::none()
}
Msg::Editor(Action::SignatureClose) => {
self.signature = None;
Task::none()
}
Msg::Editor(Action::HoverQuery(offset)) => {
let diags: Vec<(Range<u32>, String)> = self
.doc
.diagnostics_in(offset..offset + 1)
.map(|(r, sev, msg)| (r, format!("**{}:** {msg}", severity_label(sev))))
.collect();
let cx = self.build_hover_cx(offset);
let word = (cx.word.start != cx.word.end).then(|| self.hover_provider.hover(&cx)).flatten();
self.hover = if diags.is_empty() {
word
} else {
let range = diags[0].0.clone();
let mut md: Vec<String> = diags.into_iter().map(|(_, m)| m).collect();
if let Some(w) = word {
md.push(String::new());
md.push(w.markdown);
}
Some(HoverInfo { markdown: md.join("\n"), range })
};
Task::none()
}
Msg::Editor(Action::HoverDismiss) => {
self.hover = None;
Task::none()
}
Msg::Editor(Action::ToggleFold { opener }) => {
self.doc.toggle_fold_opener(opener);
Task::none()
}
Msg::Editor(Action::FoldAtCarets { unfold }) => {
self.doc.fold_at_carets(unfold);
Task::none()
}
Msg::Editor(Action::Collapse) if self.find_open => {
self.close_find();
Task::none()
}
Msg::Editor(action) => {
self.apply(action);
Task::none()
}
Msg::OpenFind => {
self.find_open = true;
let sel = self.doc.selections().newest();
let seed = (!sel.is_empty())
.then(|| self.doc.buffer().slice(sel.start()..sel.end()).into_owned())
.filter(|t| !t.contains('\n'));
if let Some(text) = seed {
self.find_query = text;
self.push_find_query();
}
self.find_focused = true; self.replace_focused = false;
focus(FIND_INPUT) }
Msg::OpenReplace => {
self.replace_open = true;
self.update(Msg::OpenFind)
}
Msg::CycleFocus { back } => {
let moved = if back { focus_previous() } else { focus_next() };
moved.chain(Self::sync_rings())
}
Msg::PointerDown if self.find_open => {
Task::batch([Self::resync_focus(), Self::sync_rings()])
}
Msg::Focused { replace, on } => {
if replace {
self.replace_focused = on;
} else {
self.find_focused = on;
}
Task::none()
}
Msg::CloseFind if self.find_open => {
self.close_find(); focus(EDITOR) }
Msg::FindQuery(q) => {
self.find_query = q;
self.push_find_query();
Task::none()
}
Msg::ToggleCase => {
self.find_case = !self.find_case;
self.push_find_query(); Task::none()
}
Msg::ToggleWholeWord => {
self.find_whole_word = !self.find_whole_word;
self.push_find_query();
Task::none()
}
Msg::ToggleRegex => {
self.find_regex = !self.find_regex;
self.push_find_query();
Task::none()
}
Msg::ToggleFindInSelection if self.find_open => {
let scope = if self.doc.find_scope().is_some() {
None
} else {
let sel = self.doc.selections().newest();
(!sel.is_empty()).then(|| sel.start()..sel.end())
};
let now = self.now_ms();
self.doc.set_find_scope(scope, now);
Task::none()
}
Msg::FindNext if self.find_open => {
let now = self.now_ms();
self.doc.find_next(now);
Task::none()
}
Msg::FindPrev if self.find_open => {
let now = self.now_ms();
self.doc.find_prev(now);
Task::none()
}
Msg::FindSelectAll if self.find_open => {
if self.doc.select_find_matches() {
return focus(EDITOR);
}
Task::none()
}
Msg::ToggleReplace => {
self.replace_open = !self.replace_open;
self.replace_focused = self.replace_open;
self.find_focused = !self.replace_open;
focus(if self.replace_open { REPLACE_INPUT } else { FIND_INPUT })
}
Msg::ReplaceText(t) => {
self.replace_text = t;
Task::none() }
Msg::ReplaceOne if self.find_open => {
let now = self.now_ms();
let before = self.doc.revision();
self.doc.replace_next(&self.replace_text, self.replace_preserve_case, now);
if self.doc.revision() != before {
self.after_edit(CompletionEvent::CaretOrClose);
}
Task::none()
}
Msg::ReplaceAll if self.find_open => {
if self.doc.replace_all(&self.replace_text, self.replace_preserve_case) > 0 {
self.after_edit(CompletionEvent::CaretOrClose);
}
Task::none()
}
Msg::TogglePreserveCase => {
self.replace_preserve_case = !self.replace_preserve_case;
Task::none() }
Msg::HighlightSweep => {
let App { doc, hl_pool, viewport, .. } = self;
let large = doc.buffer().len() >= PARALLEL_MIN_BYTES;
match hl_pool {
Some(pool) if large => {
if pool.rev != doc.revision() {
pool.restart(doc, viewport.clone());
} else {
pool.poll(doc);
}
if !pool.active {
let n = doc.buffer().line_count();
doc.tokenize_highlight(n);
}
}
other => {
if let Some(pool) = other {
pool.active = false;
}
let n = doc.buffer().line_count();
doc.tokenize_highlight(n);
}
}
Task::none()
}
Msg::MaybeRelint => {
self.maybe_relint(self.now_ms());
Task::none()
}
Msg::CloseFind
| Msg::FindNext
| Msg::FindPrev
| Msg::FindSelectAll
| Msg::ReplaceOne
| Msg::ReplaceAll
| Msg::ToggleFindInSelection
| Msg::PointerDown => Task::none(),
}
}
fn now_ms(&self) -> u64 {
self.start.elapsed().as_millis() as u64
}
fn maybe_relint(&mut self, now: u64) -> bool {
if !self.relint_dirty || now.saturating_sub(self.last_relint_ms) < RELINT_DEBOUNCE_MS {
return false;
}
self.relint();
self.relint_dirty = false;
self.last_relint_ms = now;
true
}
fn apply(&mut self, action: Action) {
let comp_event = match &action {
Action::Type(c) => CompletionEvent::Typed(*c),
Action::Backspace | Action::DeleteWordBack | Action::Delete | Action::DeleteWordForward => {
CompletionEvent::Deleting
}
_ => CompletionEvent::CaretOrClose,
};
match action {
Action::Type(ch) => self.doc.type_char(ch),
Action::Backspace => self.doc.backspace(),
Action::Delete => self.doc.delete_forward(),
Action::DeleteWordBack => self.doc.delete_word_back(),
Action::DeleteWordForward => self.doc.delete_word_forward(),
Action::Enter => self.doc.enter(),
Action::Tab => self.doc.tab(),
Action::Outdent => self.doc.outdent(),
Action::ToggleComment => self.doc.toggle_line_comment(),
Action::DeleteLine => self.doc.delete_line(),
Action::InsertLine { down } => self.doc.insert_line(down),
Action::Cut => self.doc.cut(),
Action::Paste { text, entire_line } => self.doc.paste(&text, entire_line),
Action::Move { motion, extend } => self.doc.move_carets(motion, extend),
Action::PlaceCaret(offset) => {
use scrive_core::{Selection, SelectionId, SelectionSet};
let mut set = SelectionSet::new(0);
set.set_single(Selection::caret(SelectionId(0), offset));
self.doc.set_selections(set);
}
Action::DragSelect { granularity, origin, head } => {
self.doc.drag_select(granularity, origin, head);
}
Action::AddCaret(offset) => self.doc.add_caret(offset),
Action::AddNextOccurrence => self.doc.add_next_occurrence(),
Action::SelectAllOccurrences => self.doc.select_all_occurrences(),
Action::AddCaretVertical { down } => self.doc.add_caret_vertical(down),
Action::JumpToBracket => self.doc.jump_to_bracket(),
Action::NextDiagnostic { forward } => {
self.doc.next_diagnostic(forward);
}
Action::ExpandSelection => self.doc.expand_selection(),
Action::ShrinkSelection => self.doc.shrink_selection(),
Action::Collapse => self.doc.collapse_selections(),
Action::SelectAll => self.doc.select_all(),
Action::Undo => {
self.doc.undo();
}
Action::Redo => {
self.doc.redo();
}
Action::ColumnSelect(dir) => self.doc.column_select(dir),
Action::ColumnDrag { anchor, active } => self.doc.column_drag(anchor, active),
Action::MoveLine { down } => self.doc.move_line(down),
Action::CopyLine { down } => self.doc.copy_line(down),
Action::ViewportChanged(_)
| Action::PopupUp
| Action::PopupDown
| Action::PopupAccept
| Action::PopupClickAccept(_)
| Action::PopupDismiss
| Action::SnippetTab
| Action::SnippetTabPrev
| Action::SnippetCancel
| Action::SignatureClose
| Action::HoverQuery(_)
| Action::HoverDismiss
| Action::ToggleFold { .. }
| Action::FoldAtCarets { .. } => {} }
self.after_edit(comp_event);
}
fn scoped(&self) -> bool {
self.doc.find_scope().is_some()
}
fn after_edit(&mut self, comp_event: CompletionEvent) {
self.doc.tokenize_highlight(self.viewport.end);
let now = self.now_ms();
self.doc.maybe_rescan_find(now);
self.drive_completion(comp_event);
self.drive_signature(comp_event);
self.reconcile_snippet();
self.hover = None;
self.relint_dirty = true;
}
fn relint(&mut self) {
if self.doc.buffer().len() > RELINT_MAX_BYTES {
return; }
#[cfg(test)]
RELINT_RUNS.with(|c| c.set(c.get() + 1));
let mut diags = Vec::new();
{
let buffer = self.doc.buffer();
for row in 0..buffer.line_count() {
let line = buffer.line(row);
let line_start = buffer.point_to_offset(Point::new(row, 0));
for (needle, sev, msg) in [
("FIXME", Severity::Error, "unresolved FIXME (demo lint)"),
("TODO", Severity::Warning, "unresolved TODO (demo lint)"),
] {
let mut from = 0usize;
while let Some(i) = line[from..].find(needle) {
let start = line_start + (from + i) as u32;
diags.push(Diagnostic::new(start..start + needle.len() as u32, sev, msg));
from = from + i + needle.len();
}
}
}
}
let rev = self.doc.revision();
let _ = self.doc.set_diagnostics(rev, diags);
}
fn word_around(&self, offset: u32) -> Range<u32> {
let p = self.doc.buffer().offset_to_point(offset);
let line = self.doc.buffer().line(p.row);
let line_start = offset - p.col;
let col = p.col as usize;
let start = line[..col]
.char_indices()
.rev()
.take_while(|(_, c)| is_completion_word_char(*c))
.last()
.map_or(col, |(i, _)| i);
let mut end = col;
for c in line[col..].chars().take_while(|c| is_completion_word_char(*c)) {
end += c.len_utf8();
}
(line_start + start as u32)..(line_start + end as u32)
}
fn build_hover_cx(&self, offset: u32) -> HoverCx {
let word = self.word_around(offset);
let position = self.doc.buffer().offset_to_point(offset);
let start_row = position.row.saturating_sub(LOOKBACK_LINES - 1);
let lb_start = self.doc.buffer().point_to_offset(Point::new(start_row, 0));
HoverCx {
doc: self.doc.buffer().doc_id(),
revision: self.doc.revision().0,
position,
word: word.clone(),
lookback: self.doc.buffer().slice(lb_start..word.end).into_owned(),
}
}
fn drive_signature(&mut self, event: CompletionEvent) {
let query = matches!(event, CompletionEvent::Typed('(')) || self.signature.is_some();
if query {
let cx = self.build_sig_cx();
self.signature = self.sig_provider.signature(&cx);
}
}
fn build_sig_cx(&self) -> SignatureCx {
let head = self.doc.selections().newest().head();
let position = self.doc.buffer().offset_to_point(head);
let start_row = position.row.saturating_sub(LOOKBACK_LINES - 1);
let lb_start = self.doc.buffer().point_to_offset(Point::new(start_row, 0));
SignatureCx {
doc: self.doc.buffer().doc_id(),
revision: self.doc.revision().0,
position,
lookback: self.doc.buffer().slice(lb_start..head).into_owned(),
}
}
fn drive_completion(&mut self, event: CompletionEvent) {
match event {
CompletionEvent::Typed(c) => {
let trigger = if is_completion_word_char(c) {
CompletionTrigger::Typed(c)
} else if matches!(c, '(' | ',' | '=' | ':' | '.' | ' ') {
CompletionTrigger::TriggerChar(c)
} else {
self.completion.on_boundary();
return;
};
let cx = self.build_cx(trigger);
let word = self.completion_word_text();
self.completion.on_input(&cx, &word, &mut self.provider);
}
CompletionEvent::Deleting => {
if self.completion.is_open() {
let word = self.completion_word_text();
match word.chars().last() {
Some(c) => {
let cx = self.build_cx(CompletionTrigger::Typed(c));
self.completion.on_input(&cx, &word, &mut self.provider);
}
None => self.completion.close(),
}
}
}
CompletionEvent::CaretOrClose => self.completion.close(),
}
}
fn accept_completion(&mut self) {
let Some(item) = self.completion.accept() else { return };
let replace = item.replace.clone().unwrap_or_else(|| self.completion_word());
self.set_selection_range(replace.clone());
let expanded = match &item.insert {
InsertText::Plain(s) => {
self.doc.insert_text(s);
None
}
InsertText::Snippet(body) => match Snippet::parse(body) {
Ok(snip) => {
let indent = self.line_indent(replace.start);
let e = snip.for_insertion(&indent, default_indent_size() as usize);
self.doc.insert_text(&e.text);
Some(e)
}
Err(_) => {
self.doc.insert_text(body);
None
}
},
};
if let Some(mut s) = self.snippet.take() {
s.cancel(self.doc.decorations_mut());
}
if let Some(e) = expanded {
match SnippetSession::start(&e, replace.start, self.doc.decorations_mut()) {
Some((session, first)) => {
self.set_selection_range(first);
self.snippet = Some(session);
}
None => {
let fin = e.stops.last().map_or(e.text.len() as u32, |s| s.range.start);
self.set_caret(replace.start + fin);
}
}
}
self.doc.tokenize_highlight(self.viewport.end);
self.doc.maybe_rescan_find(self.now_ms());
if item.retrigger && self.snippet.is_none() {
let cx = self.build_cx(CompletionTrigger::Manual);
let word = self.completion_word_text();
self.completion.on_input(&cx, &word, &mut self.provider);
}
}
fn snippet_tab(&mut self, forward: bool) {
let Some(mut session) = self.snippet.take() else { return };
match session.tab(forward, self.doc.decorations_mut()) {
TabOutcome::Move(range) => {
self.set_selection_range(range);
self.snippet = Some(session);
}
TabOutcome::Finish(offset) => self.set_caret(offset),
TabOutcome::Stay => self.snippet = Some(session),
}
}
fn reconcile_snippet(&mut self) {
if self.snippet.is_none() {
return;
}
let head = self.doc.selections().newest().head();
let escaped = self.snippet.as_ref().unwrap().edit_escapes(&(head..head), self.doc.decorations());
if escaped {
let mut s = self.snippet.take().unwrap();
s.cancel(self.doc.decorations_mut());
}
}
fn set_caret(&mut self, offset: u32) {
let mut set = SelectionSet::new(0);
set.set_single(Selection::caret(SelectionId(0), offset));
self.doc.set_selections(set);
}
fn set_selection_range(&mut self, range: Range<u32>) {
let mut set = SelectionSet::new(0);
set.set_single(Selection::from_anchor(SelectionId(0), range.start, range.end));
self.doc.set_selections(set);
}
fn completion_word(&self) -> Range<u32> {
let head = self.doc.selections().newest().head();
let p = self.doc.buffer().offset_to_point(head);
let line_start = head - p.col;
let prefix = &self.doc.buffer().line(p.row)[..p.col as usize];
let word_start = prefix
.char_indices()
.rev()
.take_while(|(_, c)| is_completion_word_char(*c))
.last()
.map_or(prefix.len(), |(i, _)| i);
(line_start + word_start as u32)..head
}
fn completion_word_text(&self) -> String {
let w = self.completion_word();
self.doc.buffer().slice(w.start..w.end).into_owned()
}
fn line_indent(&self, offset: u32) -> String {
let row = self.doc.buffer().offset_to_point(offset).row;
self.doc.buffer().line(row).chars().take_while(|c| *c == ' ' || *c == '\t').collect()
}
fn build_cx(&self, trigger: CompletionTrigger) -> CompletionCx {
let head = self.doc.selections().newest().head();
let position = self.doc.buffer().offset_to_point(head);
let refilter_only =
self.completion.is_open() && matches!(trigger, CompletionTrigger::Typed(_));
let lookback = if refilter_only {
String::new()
} else {
let start_row = position.row.saturating_sub(LOOKBACK_LINES - 1);
let lb_start = self.doc.buffer().point_to_offset(Point::new(start_row, 0));
self.doc.buffer().slice(lb_start..head).into_owned()
};
CompletionCx {
doc: self.doc.buffer().doc_id(),
revision: self.doc.revision().0,
position,
word: self.completion_word(),
lookback,
trigger,
}
}
pub fn view(&self) -> Element<'_, Msg> {
let popup = match self.completion.state() {
CompletionState::Open(list) => Some(list),
_ => None,
};
let editor = Editor::new(&self.doc, Msg::Editor)
.popup(popup)
.snippet_active(self.snippet.is_some())
.signature(self.signature.as_ref())
.hover(self.hover.as_ref())
.id(EDITOR);
if self.find_open {
let overlay = container(self.find_bar())
.width(Length::Fill)
.align_x(Horizontal::Right)
.padding(iced::Padding::new(8.0).right(8.0 + scrive_iced::SCROLLBAR_WIDTH));
stack([editor.into(), overlay.into()]).into()
} else {
editor.into()
}
}
fn find_bar(&self) -> Element<'_, Msg> {
let count = self.doc.find_match_count();
let invalid = self.doc.find_pattern_error().is_some();
let label = if invalid {
"Invalid regex".to_string()
} else {
match self.doc.active_find_match() {
Some(i) => format!("{} of {}", i + 1, count),
None if count > 0 => format!("{count} matches"),
None if self.find_query.is_empty() => String::new(),
None => "No results".to_string(),
}
};
let label_color = if invalid || label == "No results" {
Color::from_rgb8(0xF4, 0x87, 0x71)
} else {
Color::from_rgb8(0xCC, 0xCC, 0xCC)
};
const INPUT_W: f32 = 264.0;
const COUNT_W: f32 = 78.0;
const SPACING: f32 = 4.0;
const ROW_H: f32 = 26.0;
let input_style = |_theme: &Theme, _status: text_input::Status| text_input::Style {
background: Color::TRANSPARENT.into(),
border: iced::border::rounded(0.0),
icon: Color::from_rgb8(0xCC, 0xCC, 0xCC),
placeholder: Color::from_rgb8(0xA6, 0xA6, 0xA6),
value: Color::from_rgb8(0xCC, 0xCC, 0xCC),
selection: Color::from_rgb8(0x26, 0x4F, 0x78),
};
const BTN_W: f32 = 22.0;
const IN_BTN: f32 = 20.0;
const IN_GAP: f32 = 3.0; const IN_MARGIN: f32 = 3.0; let sized_btn = |glyph: char, on: bool, msg, w: f32, h: f32| {
button(
text(glyph.to_string())
.font(scrive_iced::CODICON)
.size(15)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Horizontal::Center)
.align_y(Vertical::Center),
)
.width(Length::Fixed(w))
.height(Length::Fixed(h))
.padding(0)
.on_press(msg)
.style(move |_theme, status| {
let hover = matches!(status, button::Status::Hovered | button::Status::Pressed);
button::Style {
background: (on || hover).then(|| Color::from_rgba8(90, 93, 94, 0.314).into()),
text_color: Color::from_rgb8(0xCC, 0xCC, 0xCC),
border: if on {
iced::border::rounded(5.0).width(1.0).color(Color::from_rgb8(0x00, 0x7F, 0xD4))
} else {
iced::border::rounded(5.0)
},
shadow: Shadow::default(),
snap: true,
}
})
};
let icon_btn = |glyph: char, on: bool, msg| sized_btn(glyph, on, msg, BTN_W, BTN_W);
let btn = |glyph: char, msg| sized_btn(glyph, false, msg, BTN_W, BTN_W);
let in_btn = |glyph: char, on: bool, msg| sized_btn(glyph, on, msg, IN_BTN, IN_BTN);
let box_of =
|field, buttons, focused| box_of(field, buttons, focused, INPUT_W, ROW_H, IN_GAP, IN_MARGIN);
let find_box = box_of(
text_input("Find", &self.find_query)
.id(FIND_INPUT)
.on_input(Msg::FindQuery)
.on_submit(Msg::FindNext)
.padding(iced::Padding::new(4.0).left(2.0))
.size(13)
.width(Length::Fill)
.style(input_style)
.into(),
vec![
in_btn(scrive_iced::icon::CASE_SENSITIVE, self.find_case, Msg::ToggleCase).into(),
in_btn(scrive_iced::icon::WHOLE_WORD, self.find_whole_word, Msg::ToggleWholeWord)
.into(),
in_btn(scrive_iced::icon::REGEX, self.find_regex, Msg::ToggleRegex).into(),
],
self.find_focused,
);
let find_row = row![
find_box,
text(label)
.size(12)
.color(label_color)
.width(Length::Fixed(COUNT_W))
.align_x(Horizontal::Left),
btn(scrive_iced::icon::ARROW_UP, Msg::FindPrev), btn(scrive_iced::icon::ARROW_DOWN, Msg::FindNext), icon_btn(scrive_iced::icon::SELECTION, self.scoped(), Msg::ToggleFindInSelection),
btn(scrive_iced::icon::CLOSE, Msg::CloseFind), ]
.spacing(SPACING)
.height(Length::Fixed(ROW_H))
.align_y(Alignment::Center);
let rows = if self.replace_open {
let replace_box = box_of(
text_input("Replace", &self.replace_text)
.id(REPLACE_INPUT)
.on_input(Msg::ReplaceText)
.on_submit(Msg::ReplaceOne)
.padding(iced::Padding::new(4.0).left(2.0))
.size(13)
.width(Length::Fill)
.style(input_style)
.into(),
vec![in_btn(
scrive_iced::icon::PRESERVE_CASE,
self.replace_preserve_case,
Msg::TogglePreserveCase,
)
.into()],
self.replace_focused,
);
let replace_row = row![
replace_box,
btn(scrive_iced::icon::REPLACE, Msg::ReplaceOne), btn(scrive_iced::icon::REPLACE_ALL, Msg::ReplaceAll), ]
.spacing(SPACING)
.height(Length::Fixed(ROW_H))
.align_y(Alignment::Center);
column![find_row, replace_row].spacing(SPACING)
} else {
column![find_row]
};
container(
row![
sized_btn(
if self.replace_open {
scrive_iced::icon::CHEVRON_DOWN
} else {
scrive_iced::icon::CHEVRON_RIGHT
},
false,
Msg::ToggleReplace,
BTN_W,
if self.replace_open { ROW_H * 2.0 + SPACING } else { ROW_H },
),
rows,
]
.spacing(SPACING)
.align_y(Alignment::Center),
)
.padding([6, 8])
.style(|_theme: &Theme| container::Style {
background: Some(Color::from_rgb8(0x25, 0x25, 0x26).into()),
border: iced::border::rounded(8.0).color(Color::from_rgb8(0x45, 0x45, 0x45)).width(1.0),
shadow: Shadow {
color: Color::from_rgba8(0, 0, 0, 0.36),
offset: Vector::new(0.0, 2.0),
blur_radius: 8.0,
},
..container::Style::default()
})
.into()
}
fn subscription(&self) -> Subscription<Msg> {
let sweeping = self.doc.highlight_frontier().is_some()
|| self.hl_pool.as_ref().is_some_and(|p| p.active);
let sweep = if sweeping {
iced::window::frames().map(|_| Msg::HighlightSweep)
} else {
Subscription::none()
};
let relint = if self.relint_dirty {
iced::window::frames().map(|_| Msg::MaybeRelint)
} else {
Subscription::none()
};
let keys = iced::event::listen_with(|event, status, _window| match event {
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key, modifiers, ..
}) => find_chord(&key, modifiers, status),
iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)) => {
Some(Msg::PointerDown)
}
_ => None,
});
Subscription::batch([keys, sweep, relint])
}
}
fn severity_label(sev: Severity) -> &'static str {
match sev {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
Severity::Hint => "hint",
}
}
pub fn scrive_dark() -> Theme {
use iced::theme::Palette;
use iced::Color;
Theme::custom(
"Scrive Dark".to_string(),
Palette {
background: Color::from_rgb8(0x1c, 0x1e, 0x24), text: Color::from_rgb8(0xdf, 0xe1, 0xe6), primary: Color::from_rgb8(0xec, 0x6a, 0x88), success: Color::from_rgb8(0xa3, 0xc7, 0x6d), warning: Color::from_rgb8(0xe0, 0xb6, 0x58), danger: Color::from_rgb8(0xe0, 0x57, 0x5b), },
)
}
fn box_of<'a>(
field: Element<'a, Msg>,
buttons: Vec<Element<'a, Msg>>,
focused: bool,
w: f32,
h: f32,
gap: f32,
margin: f32,
) -> Element<'a, Msg> {
container(
row![field, row(buttons).spacing(gap).align_y(Alignment::Center)]
.spacing(gap)
.align_y(Alignment::Center),
)
.width(Length::Fixed(w))
.height(Length::Fixed(h))
.padding(iced::Padding::from([0.0, margin]))
.style(move |_theme: &Theme| container::Style {
background: Some(Color::from_rgb8(0x3C, 0x3C, 0x3C).into()),
border: iced::border::rounded(4.0).width(1.0).color(if focused {
Color::from_rgb8(0x00, 0x7F, 0xD4) } else {
Color::TRANSPARENT
}),
..container::Style::default()
})
.into()
}
fn find_chord(
key: &iced::keyboard::Key,
modifiers: iced::keyboard::Modifiers,
status: iced::event::Status,
) -> Option<Msg> {
use iced::keyboard::{key::Named, Key};
let ctrl = modifiers.command() || modifiers.control();
match key {
Key::Character(c) if ctrl && c.as_str() == "f" => Some(Msg::OpenFind),
Key::Character(c) if ctrl && c.as_str() == "h" => Some(Msg::OpenReplace),
Key::Named(Named::Escape) => Some(Msg::CloseFind),
Key::Named(Named::Enter) if modifiers.alt() => Some(Msg::FindSelectAll),
Key::Named(Named::Tab) if status == iced::event::Status::Ignored => {
Some(Msg::CycleFocus { back: modifiers.shift() })
}
_ => None,
}
}
fn theme(_state: &App) -> Theme {
scrive_dark()
}
fn main() -> iced::Result {
let args: Vec<String> = std::env::args().collect();
if let Some(i) = args.iter().position(|a| a == "--large") {
let mb: usize = args.get(i + 1).and_then(|s| s.parse().ok()).unwrap_or(10);
let _ = LARGE_DOC.set(gen_large(mb));
}
if let Some(i) = args.iter().position(|a| a == "--capture") {
let path = args.get(i + 1).map_or("scratch.png", String::as_str);
let mut app = App::default();
app.doc.tokenize_highlight(app.doc.buffer().line_count());
let (w, h) = capture::render_to_png(app.view(), 900, 560, &scrive_dark(), &[], path);
eprintln!("captured {w}x{h} -> {path}");
return Ok(());
}
if let Some(i) = args.iter().position(|a| a == "--capture-folds") {
let path = args.get(i + 1).map_or("scratch-folds.png", String::as_str);
let mut app = App::default();
app.doc.tokenize_highlight(app.doc.buffer().line_count());
for (open, ..) in app.doc.collapsible_pairs() {
app.doc.toggle_fold_opener(open);
}
let (w, h) = capture::render_to_png(app.view(), 900, 560, &scrive_dark(), &[], path);
eprintln!("captured {w}x{h} -> {path}");
return Ok(());
}
if let Some(i) = args.iter().position(|a| a == "--capture-find") {
let path = args.get(i + 1).map_or("scratch-find.png", String::as_str);
let mut app = App::default();
app.doc.tokenize_highlight(app.doc.buffer().line_count());
let _ = app.update(Msg::OpenReplace); let _ = app.update(Msg::FindQuery("self".into()));
let _ = app.update(Msg::ReplaceText("this".into()));
let _ = app.update(Msg::ToggleCase);
let _ = app.update(Msg::ToggleWholeWord);
let _ = app.update(Msg::TogglePreserveCase); app.apply(Action::DragSelect {
granularity: scrive_core::Granularity::Char,
origin: 0,
head: 600,
});
let _ = app.update(Msg::ToggleFindInSelection);
let _ = app.update(Msg::FindNext); let (w, h) = capture::render_to_png(app.view(), 900, 560, &scrive_dark(), &[], path);
eprintln!("captured {w}x{h} -> {path}");
return Ok(());
}
let app = iced::application(App::default, App::update, App::view)
.title("scrive — scratch")
.theme(theme)
.subscription(App::subscription);
scrive_iced::required_fonts()
.iter()
.fold(app, |app, font| app.font(*font))
.run()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replace_all_through_the_bar_is_one_undo_step() {
let mut app = App::default();
let original = app.doc.text().into_owned();
let _ = app.update(Msg::OpenFind);
let _ = app.update(Msg::FindQuery("self".into()));
assert!(app.doc.find_match_count() > 0, "the sample must contain the needle");
let _ = app.update(Msg::ReplaceText("this".into()));
let _ = app.update(Msg::ReplaceAll);
assert!(!app.doc.text().contains("self"), "every match replaced");
assert!(app.doc.text().contains("this"));
app.apply(Action::Undo);
assert_eq!(app.doc.text(), original.as_str(), "replace-all must undo as ONE step");
}
#[test]
fn the_replace_button_selects_before_it_replaces() {
let mut app = App::default();
let original = app.doc.text().into_owned();
let _ = app.update(Msg::OpenFind);
let _ = app.update(Msg::FindQuery("self".into()));
let _ = app.update(Msg::ReplaceText("this".into()));
let _ = app.update(Msg::ReplaceOne);
assert_eq!(app.doc.text(), original.as_str(), "the first press only navigates");
let _ = app.update(Msg::ReplaceOne);
assert_ne!(app.doc.text(), original.as_str(), "the second press replaces");
}
#[test]
fn closing_collapses_the_replace_row() {
let mut app = App::default();
let _ = app.update(Msg::OpenFind);
let _ = app.update(Msg::ToggleReplace); assert!(app.replace_open);
let _ = app.update(Msg::FindQuery("self".into()));
let _ = app.update(Msg::CloseFind);
assert!(app.find_query.is_empty(), "close drops the query");
assert!(app.doc.find_query().is_none(), "…and its matches");
assert!(!app.replace_open, "…and collapses the replace row");
let _ = app.update(Msg::OpenFind);
assert!(!app.replace_open, "Ctrl+F is find-only");
let _ = app.update(Msg::CloseFind);
let _ = app.update(Msg::OpenReplace);
assert!(app.replace_open, "Ctrl+H reopens with replace");
}
#[test]
fn the_case_toggle_rescans() {
let mut app = App::default();
let _ = app.update(Msg::OpenFind);
let _ = app.update(Msg::FindQuery("self".into()));
let insensitive = app.doc.find_match_count();
let _ = app.update(Msg::ToggleCase);
assert!(app.find_case);
let sensitive = app.doc.find_match_count();
assert!(
sensitive < insensitive,
"case-sensitive must drop the `Self` matches ({sensitive} vs {insensitive})"
);
assert!(app.doc.find_query().is_some_and(|q| q.case_sensitive));
let _ = app.update(Msg::ToggleCase);
assert_eq!(app.doc.find_match_count(), insensitive);
}
#[test]
fn the_whole_word_and_regex_toggles_rescan() {
let mut app = App::default();
let _ = app.update(Msg::OpenFind);
let _ = app.update(Msg::FindQuery("self".into()));
let plain = app.doc.find_match_count();
let _ = app.update(Msg::ToggleWholeWord);
assert!(app.doc.find_query().is_some_and(|q| q.whole_word));
assert!(app.doc.find_match_count() <= plain);
let _ = app.update(Msg::ToggleWholeWord);
assert_eq!(app.doc.find_match_count(), plain, "…and toggling back restores it");
let _ = app.update(Msg::FindQuery("s.lf".into()));
assert_eq!(app.doc.find_match_count(), 0, "literal `s.lf` is not in the sample");
let _ = app.update(Msg::ToggleRegex);
assert!(app.doc.find_match_count() > 0, "as a regex it matches `self`");
}
#[test]
#[allow(clippy::field_reassign_with_default)] fn the_preserve_case_toggle_recases_replacements() {
let mut app = App::default();
app.doc = Document::new("FOO Foo foo").unwrap();
let _ = app.update(Msg::OpenReplace);
let _ = app.update(Msg::FindQuery("foo".into())); let _ = app.update(Msg::ReplaceText("bar".into()));
assert!(!app.replace_preserve_case);
let _ = app.update(Msg::TogglePreserveCase);
assert!(app.replace_preserve_case, "the toggle latches app state");
let _ = app.update(Msg::ReplaceAll);
assert_eq!(app.doc.text(), "BAR Bar bar", "each match keeps its casing");
}
#[test]
fn a_half_typed_regex_reports_invalid_rather_than_no_results() {
let mut app = App::default();
let _ = app.update(Msg::OpenFind);
let _ = app.update(Msg::ToggleRegex);
let _ = app.update(Msg::FindQuery("(".into()));
assert_eq!(app.doc.find_match_count(), 0);
assert!(app.doc.find_pattern_error().is_some(), "the bar has a reason to show");
app.apply(Action::Type('x'));
let _ = app.update(Msg::FindQuery("(self)".into()));
assert!(app.doc.find_pattern_error().is_none());
assert!(app.doc.find_match_count() > 0);
}
#[test]
fn find_in_selection_scopes_to_the_selection() {
let mut app = App::default();
let _ = app.update(Msg::OpenFind);
let _ = app.update(Msg::FindQuery("self".into()));
let all = app.doc.find_match_count();
assert!(all > 1);
app.apply(Action::PlaceCaret(0));
let half = app.doc.buffer().len() / 2;
app.apply(Action::DragSelect {
granularity: scrive_core::Granularity::Char,
origin: 0,
head: half,
});
let _ = app.update(Msg::ToggleFindInSelection);
assert!(app.scoped(), "the button latches off the document's scope");
let scoped = app.doc.find_match_count();
assert!(scoped < all, "the scope must drop the out-of-range matches");
assert!(
app.doc.find_matches_in(0..u32::MAX).all(|(m, _)| m.end <= half),
"no match may sit outside the scope"
);
let _ = app.update(Msg::ToggleFindInSelection);
assert!(!app.scoped());
assert_eq!(app.doc.find_match_count(), all);
}
#[test]
fn bar_clicks_route_focus_to_the_clicked_field() {
use iced::advanced::widget::Operation;
use iced::advanced::{clipboard, renderer};
use iced::keyboard::{key::Named, Key, Modifiers};
use iced::{mouse, Event, Font, Pixels, Point, Size, Theme};
use iced_runtime::task;
use iced_runtime::user_interface::{Cache, UserInterface};
use std::slice::from_ref;
use std::task::{Context, Poll, Waker};
const W: f32 = 900.0;
const H: f32 = 560.0;
let mut renderer = iced_renderer::fallback::Renderer::Secondary(
iced_tiny_skia::Renderer::new(Font::default(), Pixels(14.0)),
);
let theme = scrive_dark();
let mut app = App::default();
let mut cache = Cache::new();
let mut pending: Vec<Msg> = vec![Msg::OpenReplace];
let mut frame = |app: &mut App,
cache: &mut Cache,
pending: &mut Vec<Msg>,
events: &[Event],
cursor: mouse::Cursor| {
let mut streams: Vec<_> = pending
.drain(..)
.filter_map(|m| task::into_stream(app.update(m)))
.collect();
let mut ui: UserInterface<'_, Msg, Theme, iced::Renderer> = UserInterface::build(
app.view(),
Size::new(W, H),
std::mem::take(cache),
&mut renderer,
);
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
for mut stream in streams.drain(..) {
loop {
match stream.as_mut().poll_next(&mut cx) {
Poll::Ready(Some(iced_runtime::Action::Widget(op))) => {
let mut current = Some(op);
while let Some(mut op) = current.take() {
ui.operate(&renderer, op.as_mut());
if let iced::advanced::widget::operation::Outcome::Chain(next) =
op.finish()
{
current = Some(next);
}
}
}
Poll::Ready(Some(iced_runtime::Action::Output(m))) => pending.push(m),
Poll::Ready(Some(_)) => {}
Poll::Ready(None) | Poll::Pending => break,
}
}
}
let mut published: Vec<Msg> = Vec::new();
let (_, statuses) =
ui.update(events, cursor, &mut renderer, &mut clipboard::Null, &mut published);
ui.draw(&mut renderer, &theme, &renderer::Style::default(), cursor);
*cache = ui.into_cache();
for m in published {
let _ = app.update(m);
}
for (ev, status) in events.iter().zip(statuses) {
let msg = match ev {
Event::Keyboard(iced::keyboard::Event::KeyPressed {
key, modifiers, ..
}) => find_chord(key, *modifiers, status),
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
Some(Msg::PointerDown)
}
_ => None,
};
pending.extend(msg);
}
};
let away = mouse::Cursor::Available(Point::new(W / 2.0, H - 8.0));
let click = Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left));
let tab = Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: Key::Named(Named::Tab),
modified_key: Key::Named(Named::Tab),
physical_key: iced::keyboard::key::Physical::Unidentified(
iced::keyboard::key::NativeCode::Unidentified,
),
location: iced::keyboard::Location::Standard,
modifiers: Modifiers::default(),
text: None,
repeat: false,
});
frame(&mut app, &mut cache, &mut pending, &[], away);
frame(&mut app, &mut cache, &mut pending, &[], away);
frame(&mut app, &mut cache, &mut pending, std::slice::from_ref(&click), away);
frame(&mut app, &mut cache, &mut pending, &[], away); let before = app.doc.text().into_owned();
frame(&mut app, &mut cache, &mut pending, std::slice::from_ref(&tab), away);
assert_ne!(
app.doc.text(),
before.as_str(),
"precondition: after clicking the editor, Tab must indent"
);
app.apply(Action::Undo);
let on_find = mouse::Cursor::Available(Point::new(440.0, 26.0));
frame(&mut app, &mut cache, &mut pending, from_ref(&click), on_find);
frame(&mut app, &mut cache, &mut pending, &[], on_find); let before = app.doc.text().into_owned();
frame(&mut app, &mut cache, &mut pending, from_ref(&tab), on_find);
assert_eq!(
app.doc.text(),
before.as_str(),
"the editor kept focus after the field was clicked and stole Tab to indent"
);
let on_replace = mouse::Cursor::Available(Point::new(440.0, 55.0)); let type_z = Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: Key::Character("Z".into()),
modified_key: Key::Character("Z".into()),
physical_key: iced::keyboard::key::Physical::Unidentified(
iced::keyboard::key::NativeCode::Unidentified,
),
location: iced::keyboard::Location::Standard,
modifiers: Modifiers::default(),
text: Some("Z".into()),
repeat: false,
});
frame(&mut app, &mut cache, &mut pending, from_ref(&click), on_replace);
frame(&mut app, &mut cache, &mut pending, &[], on_replace); frame(&mut app, &mut cache, &mut pending, from_ref(&click), on_find);
frame(&mut app, &mut cache, &mut pending, &[], on_find); let (fq, rt) = (app.find_query.clone(), app.replace_text.clone());
frame(&mut app, &mut cache, &mut pending, from_ref(&type_z), on_find);
assert_ne!(app.find_query, fq, "a char typed after clicking FIND must land in the find field");
assert_eq!(app.replace_text, rt, "…and not in the replace field");
for _ in 0..3 {
frame(&mut app, &mut cache, &mut pending, &[], on_find);
}
assert!(app.find_focused && !app.replace_focused, "find ring on after clicking find");
frame(&mut app, &mut cache, &mut pending, from_ref(&click), on_replace);
for _ in 0..3 {
frame(&mut app, &mut cache, &mut pending, &[], on_replace);
}
assert!(app.replace_focused && !app.find_focused, "replace ring follows the click to replace");
}
#[test]
fn tab_cycles_focus_only_when_nothing_else_took_the_key() {
use iced::event::Status;
use iced::keyboard::{key::Named, Key, Modifiers};
const TAB: Key = Key::Named(Named::Tab);
assert_eq!(find_chord(&TAB, Modifiers::default(), Status::Captured), None);
assert_eq!(find_chord(&TAB, Modifiers::SHIFT, Status::Captured), None);
assert_eq!(
find_chord(&TAB, Modifiers::default(), Status::Ignored),
Some(Msg::CycleFocus { back: false })
);
assert_eq!(
find_chord(&TAB, Modifiers::SHIFT, Status::Ignored),
Some(Msg::CycleFocus { back: true })
);
let ign = Status::Ignored;
assert_eq!(
find_chord(&Key::Character("f".into()), Modifiers::CTRL, ign),
Some(Msg::OpenFind)
);
assert_eq!(
find_chord(&Key::Character("h".into()), Modifiers::CTRL, ign),
Some(Msg::OpenReplace)
);
assert_eq!(find_chord(&Key::Named(Named::Escape), Modifiers::default(), ign), Some(Msg::CloseFind));
assert_eq!(
find_chord(&Key::Named(Named::Enter), Modifiers::ALT, ign),
Some(Msg::FindSelectAll)
);
assert_eq!(find_chord(&Key::Named(Named::Enter), Modifiers::default(), ign), None);
assert_eq!(find_chord(&Key::Character("f".into()), Modifiers::default(), ign), None);
}
#[test]
fn ctrl_h_opens_find_with_the_replace_row_out() {
let mut app = App::default();
let _ = app.update(Msg::OpenReplace);
assert!(app.find_open);
assert!(app.replace_open);
}
#[test]
fn a_navigate_only_replace_press_does_not_arm_the_relint() {
let mut app = App::default();
let _ = app.update(Msg::OpenFind);
let _ = app.update(Msg::FindQuery("self".into()));
let _ = app.update(Msg::ReplaceText("this".into()));
app.relint_dirty = false;
let _ = app.update(Msg::ReplaceOne); assert!(!app.relint_dirty, "a navigate-only press must not arm a re-lint");
let _ = app.update(Msg::ReplaceOne); assert!(app.relint_dirty, "…but a real replace must");
}
#[test]
fn relint_debounces_off_the_keystroke_path() {
let mut app = App::default();
let base = RELINT_RUNS.with(std::cell::Cell::get);
let t0 = 100_000u64;
app.last_relint_ms = t0;
app.relint_dirty = false;
const N: usize = 25;
for _ in 0..N {
app.apply(Action::Type('x'));
assert!(!app.maybe_relint(t0 + 10), "a tick inside the window must not scan");
}
assert_eq!(RELINT_RUNS.with(std::cell::Cell::get) - base, 0, "the burst ran ZERO whole-doc scans");
assert!(app.relint_dirty, "a trailing scan is still pending");
assert!(app.maybe_relint(t0 + RELINT_DEBOUNCE_MS + 1), "the trailing tick scans");
assert_eq!(RELINT_RUNS.with(std::cell::Cell::get) - base, 1, "exactly one scan for the whole burst");
assert!(!app.relint_dirty, "flag cleared after the scan");
assert!(!app.maybe_relint(t0 + 10 * RELINT_DEBOUNCE_MS), "idle: no re-scan");
assert_eq!(RELINT_RUNS.with(std::cell::Cell::get) - base, 1, "still one");
}
#[test]
fn relint_trailing_scan_makes_diagnostics_current() {
let mut app = App::default();
let t0 = 100_000u64;
app.last_relint_ms = t0;
app.relint_dirty = false;
let start = app.doc.buffer().len();
app.apply(Action::PlaceCaret(start));
for ch in "FIXME".chars() {
app.apply(Action::Type(ch));
}
let typed = start..start + 5;
assert!(!app.maybe_relint(t0 + 10), "still inside the debounce window");
let flagged_before = app
.doc
.diagnostics_in(typed.clone())
.any(|(r, sev, _)| r == typed && matches!(sev, Severity::Error));
assert!(!flagged_before, "the freshly-typed FIXME is not flagged mid-burst");
assert!(app.maybe_relint(t0 + RELINT_DEBOUNCE_MS + 1), "the trailing tick scans");
let flagged_after = app
.doc
.diagnostics_in(typed.clone())
.any(|(r, sev, _)| r == typed && matches!(sev, Severity::Error));
assert!(flagged_after, "after the debounced scan the FIXME is flagged");
}
fn grammar_doc(source: &str) -> Document {
let mut doc = Document::new(source).expect("fits the u32 offset space");
doc.set_syntax(
SyntaxDef::from_sublime_syntax(include_str!("assets/rust.sublime-syntax"))
.expect("bundled Rust grammar parses"),
TokenTheme::from_tm_theme(include_str!("assets/scrive-dark.tmTheme"))
.expect("bundled theme parses"),
);
doc
}
#[test]
fn poll_verifies_at_most_budget_per_frame() {
const N: usize = 12;
let mut lines = [""; N];
lines[2] = "\"";
lines[4] = "\"";
let source = lines.join("\n");
let mut doc = grammar_doc(&source);
assert_eq!(doc.buffer().line_count(), N as u32, "one row per segment");
let mut pool = HighlightPool::new(&doc, 0..N as u32).expect("grammar → engine");
pool.rev = scrive_core::Revision(u64::MAX);
pool.cur_rev.store(u64::MAX, std::sync::atomic::Ordering::Relaxed);
pool.queue.0.lock().unwrap().clear();
while pool.done_rx.try_recv().is_ok() {}
pool.snapshot = std::sync::Arc::new(doc.snapshot());
pool.seg_rows = (0..N as u32).map(|r| r..r + 1).collect();
pool.window = pool.snapshot.line_count()..pool.snapshot.line_count();
pool.results = pool
.seg_rows
.iter()
.map(|rows| {
Some(scrive_core::tokenize_segment(
&pool.engine,
&pool.snapshot,
rows.clone(),
scrive_core::SegmentStart::Fresh,
None,
None,
))
})
.collect();
pool.next_verify = 0;
pool.prev_end = None;
pool.active = true;
assert!(
pool.results.iter().all(|s| s.as_ref().unwrap().started_fresh()),
"all seeded segments are Fresh guesses",
);
assert!(
pool.results[2].as_ref().unwrap().end_boundary() != &pool.fresh,
"an unterminated `\"` leaves the string context open (non-fresh end)",
);
let mut frames = 0usize;
loop {
let before = pool.next_verify;
let reruns_before = POLL_RERUNS.with(std::cell::Cell::get);
pool.poll(&mut doc);
let delta = pool.next_verify - before;
let reruns = POLL_RERUNS.with(std::cell::Cell::get) - reruns_before;
frames += 1;
assert!(delta <= POLL_VERIFY_BUDGET, "frame absorbed {delta} > verify budget");
assert!(reruns <= POLL_RERUN_BUDGET as u64, "frame ran {reruns} > rerun budget");
if pool.next_verify < pool.results.len() {
assert!(pool.active, "a partial frame stays active so the subscription re-fires");
assert!(delta >= 1, "a partial frame must make progress (no live-lock)");
}
if !pool.active {
break;
}
assert!(frames <= N + 4, "must converge, not spin");
}
assert_eq!(pool.next_verify, N, "every segment verified");
assert!(!pool.active, "active clears only once the document is fully verified");
assert!(frames > 1, "verification was paced across frames, not drained in one");
}
}