diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs index 2581e0ce290e64592bbaaf6b085dae4e17322664..4693e2476dac0db7d6932e164f156700959dc37e 100644 --- a/crates/snowbound/src/web.rs +++ b/crates/snowbound/src/web.rs @@ -85,6 +85,9 @@ extern "C" { pub fn fetch_dictionary(name: &str); #[wasm_bindgen(js_name = storeFiles)] fn store_files(changes: js_sys::Array); + /// Keeps what `index.html`'s loading shell paints on the next visit. + #[wasm_bindgen(js_name = keepLook)] + fn keep_look(look: &str); } /// Where the browser keeps its notebooks, which a first visit makes one in. @@ -201,6 +204,8 @@ thread_local! { /// When changed files are next written out. static STORE_DUE: Cell> = const { Cell::new(None) }; static LANGUAGE: RefCell = const { RefCell::new(String::new()) }; + /// The look last handed to `keepLook`. + static LOOK: RefCell = const { RefCell::new(String::new()) }; } /// Whether the browser runs on a Mac, where Command takes the editing chords. @@ -916,9 +921,12 @@ pub async fn start( notebook::fs::mount(&root); restore(folder.get(1).into()); } - let state = open(fonts) + let mut state = open(fonts) .await .map_err(|error| JsValue::from_str(&error.to_string()))?; + // The first frame is the loading shell's layout, a toolbar over the page; `loaded` eases + // the rest in. + state.full_page = true; // Text no run tags is in the browser's language, as typed in it. if let Some(spelling) = &state.spelling { spelling.untagged(canvas::language::lcid(&input_language())); @@ -933,6 +941,16 @@ pub async fn start( Ok(()) } +/// The loading shell is gone: the tab row, page list and sidebar ease in as Full Page View +/// leaves. +#[wasm_bindgen] +pub fn loaded() { + send(UserEvent::Then(Box::new(|state| { + state.full_page = false; + Ok(()) + }))); +} + /// Puts back the files IndexedDB kept: `[path, bytes or null for a folder, modified]`. fn restore(files: js_sys::Array) { for entry in files.iter() { @@ -1356,6 +1374,7 @@ fn turn(state: &mut State) -> Result<(), Box> { } if host(|host| std::mem::take(&mut host.redraw)) { state.frame()?; + remember_look(state); } let space = state.session.as_ref().map(|session| session.space); if TYPED.take() || LOOKED.replace(space) != space { @@ -1384,6 +1403,20 @@ fn turn(state: &mut State) -> Result<(), Box> { Ok(()) } +/// Keeps the open section's hue and the page's colour, in the appearance they show in, for +/// the loading shell to frame its page in. +fn remember_look(state: &State) { + let hue = draw::hue(crate::section_color(state.section_color())); + let [red, green, blue] = draw::srgb_bytes(state.paper().color); + let dark = matches!(host(|host| host.theme), Theme::Dark); + let look = + format!(r##"{{"hue":{hue:.0},"paper":"#{red:02x}{green:02x}{blue:02x}","dark":{dark}}}"##); + if LOOK.with_borrow(|kept| *kept != look) { + keep_look(&look); + LOOK.set(look); + } +} + /// Fetches the fallback faces `text` calls for that are not yet asked for. fn fetch_fallbacks(text: &str) { for character in text.chars().map(u32::from) { diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js index 89aab5d2da80ccf66e7c689abb7403eb5377596e..f081872e9f0fceb44befc7f896d88f911fe0e0cf 100644 --- a/crates/snowbound/web/glue.js +++ b/crates/snowbound/web/glue.js @@ -329,6 +329,10 @@ export function setCursor(name) { if (canvas.style.cursor !== name) canvas.style.cursor = name; } +export function keepLook(look) { + localStorage.setItem("snowbound-look", look); +} + export function setTitle(title) { document.title = title; } diff --git a/crates/snowbound/web/index.html b/crates/snowbound/web/index.html index 3b1672ec9b0b57e2a108111c1bf2ca99788e6037..f2e9d1945099d19ca3a6d8c499b80bd4ecfe77ab 100644 --- a/crates/snowbound/web/index.html +++ b/crates/snowbound/web/index.html @@ -4,9 +4,33 @@ Snowbound + @@ -45,10 +109,47 @@ -
Loading Snowbound…
+ +
+ +

Loading Snowbound

+
diff --git a/tools/release_web.py b/tools/release_web.py index 73d08b6def2b734f89b52ca132722fb958a15cac..f086a989e3bd8276d0d117f82f05a22aafd4adee 100644 --- a/tools/release_web.py +++ b/tools/release_web.py @@ -8,8 +8,10 @@ static folder and publishes it to the share's web/ folder. import argparse import gzip import hashlib +import itertools import json import os +import re from pathlib import Path import shutil import subprocess @@ -22,6 +24,8 @@ ROOT = Path(__file__).resolve().parents[1] TARGET = ROOT / 'target/wasm' WEB = ROOT / 'crates/snowbound/web' FONTS = ROOT / 'crates/canvas/assets/fonts' +# Where the loading shell's icons are looked up by name, in order. +ICONS = [ROOT / 'crates/snowbound/assets/icons', ROOT / 'crates/canvas/assets/tags', ROOT / 'crates/ui/assets'] PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web') # copyparty lists the folder itself, so the app is at its index.html. URL = 'https://file.paperclover.net/shr/snowbound/web/index.html' @@ -91,18 +95,40 @@ def build(out): '--out-dir', out, module]) bound = out / 'snowbound_web_bg.wasm' run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound]) - shutil.copy(WEB / 'index.html', out) dictionaries(out / 'dictionaries') (out / 'fonts').mkdir() for font in sorted(FONTS.glob('*')): if font.suffix in ('.ttf', '.txt'): shutil.copy(font, out / 'fonts') fallbacks(out / 'fonts') + page(out) for path in sorted(out.rglob('*')): if path.is_file(): print(f'{path.stat().st_size:>12,} {path.relative_to(out)}') +def page(out): + """Writes index.html with its loading shell's icons inlined, and the sizes of the files + it fetches before the app starts, for its progress bar.""" + count = itertools.count() + + def inline(match): + name = match[2] + svg = next(folder / f'{name}.svg' for folder in ICONS if (folder / f'{name}.svg').exists()).read_text() + # Each copy's gradients keep ids of their own in the one document. + prefix = f'i{next(count)}-' + svg = re.sub(r'id="([^"]+)"', rf'id="{prefix}\1"', svg) + svg = re.sub(r'url\(#([^)]+)\)', rf'url(#{prefix}\1)', svg) + return f'{svg.strip()}' + html = re.sub(r']*) icon="([^"]+)">', inline, (WEB / 'index.html').read_text()) + sizes = {str(path.relative_to(out)): path.stat().st_size + for path in [out / 'snowbound_web_bg.wasm', *sorted((out / 'fonts').glob('*.ttf'))]} + marker = 'const SIZES = {};' + if marker not in html: + sys.exit(f'index.html lacks `{marker}`') + (out / 'index.html').write_text(html.replace(marker, f'const SIZES = {json.dumps(sizes)};')) + + def dictionaries(folder): """The Hunspell dictionaries in DICTIONARIES as UTF-8, gzipped for the glue to inflate, each beside its readme, the texts of the licences they come under in licenses/, and their