#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[path = "shared/capture.rs"]
mod capture;
use std::ops::Range;
use std::time::Instant;
use iced::{Element, Subscription, Task, Theme};
use scrive_core::{
is_completion_word_char, CompletionCx, CompletionItem, CompletionKind, Completions, Diagnostic,
Granularity, Hover, HoverCx, HoverInfo, InsertText, Point, Severity, SignatureCx, SignatureHelp,
SignatureInfo, SyntaxDef,
};
use scrive_iced::{Action, CodeEditor, Event};
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) };
}
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()
}
pub struct App {
editor: CodeEditor,
start: Instant,
relint_dirty: bool,
last_relint_ms: u64,
}
#[derive(Debug, Clone)]
pub enum Message {
Editor(Event),
Relint,
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
impl App {
pub fn new() -> Self {
let sample;
let source: &str = match LARGE_DOC.get() {
Some(s) => s,
None => {
sample = long_sample();
&sample
}
};
let grammar = SyntaxDef::from_sublime_syntax(include_str!("assets/rust.sublime-syntax"))
.expect("bundled Rust grammar parses");
let editor = CodeEditor::new(source)
.language(grammar)
.line_comment(Some("//"))
.bracket_lexing(vec![b'"'], None)
.completions(StubCompletions::new())
.hover(StubHover)
.signature(StubSignatures);
let mut app = Self { editor, start: Instant::now(), relint_dirty: false, last_relint_ms: 0 };
app.relint(); app
}
pub fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Editor(event) => {
let task = self.editor.update(event).map(Message::Editor);
if self.editor.take_dirty() {
self.relint_dirty = true;
}
task
}
Message::Relint => {
self.maybe_relint(self.now_ms());
Task::none()
}
}
}
pub fn view(&self) -> Element<'_, Message> {
self.editor.view().map(Message::Editor)
}
pub fn subscription(&self) -> Subscription<Message> {
let editor = self.editor.subscription().map(Message::Editor);
let relint = if self.relint_dirty {
iced::window::frames().map(|_| Message::Relint)
} else {
Subscription::none()
};
Subscription::batch([editor, relint])
}
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 relint(&mut self) {
if self.editor.document().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.editor.document().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.editor.document().revision();
let _ = self.editor.set_diagnostics(rev, diags);
}
}
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()
}
}
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 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 app = App::new();
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::new();
app.editor.fold_all();
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::new();
let ev = |e| Message::Editor(e);
let _ = app.update(ev(Event::OpenReplace)); let _ = app.update(ev(Event::FindQuery("self".into())));
let _ = app.update(ev(Event::ReplaceText("this".into())));
let _ = app.update(ev(Event::ToggleCase));
let _ = app.update(ev(Event::ToggleWholeWord));
let _ = app.update(ev(Event::TogglePreserveCase)); let _ = app.update(ev(Event::Editor(Action::DragSelect {
granularity: Granularity::Char,
origin: 0,
head: 600,
})));
let _ = app.update(ev(Event::ToggleFindInSelection));
let _ = app.update(ev(Event::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::new, 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 relint_debounces_off_the_keystroke_path() {
let mut app = App::new();
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 {
let _ = app.update(Message::Editor(Event::Editor(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::new();
let t0 = 100_000u64;
app.last_relint_ms = t0;
app.relint_dirty = false;
let start = app.editor.document().buffer().len();
let _ = app.update(Message::Editor(Event::Editor(Action::PlaceCaret(start))));
for ch in "FIXME".chars() {
let _ = app.update(Message::Editor(Event::Editor(Action::Type(ch))));
}
let typed = start..start + 5;
assert!(!app.maybe_relint(t0 + 10), "still inside the debounce window");
let flagged_before = app
.editor
.document()
.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
.editor
.document()
.diagnostics_in(typed.clone())
.any(|(r, sev, _)| r == typed && matches!(sev, Severity::Error));
assert!(flagged_after, "after the debounced scan the FIXME is flagged");
}
}