Add .textream file support, in the macOS app's own format
Open and Save as… in the editor's status row, backed by the native file dialog rather than a custom one. The file format is kept exactly as the macOS app writes it — a JSON array of page strings, nothing else — so a script saved on one platform opens cleanly on the other. There was no reason to invent a Windows-specific format for something this simple, and doing so would have cost the one genuinely useful property a shared format has. Multi-page scripts stay out of scope here, so opening a file with more than one page flattens them into this app's single continuous script, each former page separated by a blank line. Nothing is dropped — a script written on a Mac with several pages still opens in full, just without the page boundaries. The dialog itself lives in the webview, through @tauri-apps/plugin-dialog; the file format lives in Rust, in document.rs, so the format has its own unit tests that don't need a window to run. Opening a file confirms first when the editor already holds a script, since there is no undo for the replacement — the current script is only ever autosaved as part of settings, not tracked as a document with its own save state. The `.textream` extension is exposed from Rust through a dedicated command rather than duplicated as a string literal on both sides of the IPC boundary.
This commit is contained in:
parent
81ec9ce219
commit
271d83a430
11 changed files with 400 additions and 5 deletions
67
Cargo.lock
generated
67
Cargo.lock
generated
|
|
@ -3265,6 +3265,30 @@ dependencies = [
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfd"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||||
|
dependencies = [
|
||||||
|
"block2",
|
||||||
|
"dispatch2",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"gtk-sys",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
"raw-window-handle",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
|
|
@ -4111,6 +4135,48 @@ dependencies = [
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-dialog"
|
||||||
|
version = "2.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"raw-window-handle",
|
||||||
|
"rfd",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-plugin-fs",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-fs"
|
||||||
|
version = "2.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"log",
|
||||||
|
"objc2-foundation",
|
||||||
|
"percent-encoding",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"toml 1.1.4+spec-1.1.0",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-global-shortcut"
|
name = "tauri-plugin-global-shortcut"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
|
|
@ -4281,6 +4347,7 @@ dependencies = [
|
||||||
"sherpa-rs",
|
"sherpa-rs",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-global-shortcut",
|
"tauri-plugin-global-shortcut",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
|
|
||||||
24
README.md
24
README.md
|
|
@ -88,8 +88,10 @@ src-tauri/ Windows shell
|
||||||
├── audio.rs Microphone capture and the speech worker
|
├── audio.rs Microphone capture and the speech worker
|
||||||
├── speech.rs Streaming recogniser over the sherpa-onnx C API
|
├── speech.rs Streaming recogniser over the sherpa-onnx C API
|
||||||
├── model.rs Model registry and first-run download
|
├── model.rs Model registry and first-run download
|
||||||
|
├── document.rs .textream file format — read/write, macOS-compatible
|
||||||
├── backdrop.rs Mica or blur behind the frameless window
|
├── backdrop.rs Mica or blur behind the frameless window
|
||||||
├── settings.rs Persisted preferences
|
├── settings.rs Persisted preferences
|
||||||
|
├── shortcuts.rs Global shortcuts for hands-free control
|
||||||
├── session.rs Live session state and the webview-facing DTOs
|
├── session.rs Live session state and the webview-facing DTOs
|
||||||
└── lib.rs Commands, events, tray icon
|
└── lib.rs Commands, events, tray icon
|
||||||
|
|
||||||
|
|
@ -98,7 +100,7 @@ src/ Svelte UI
|
||||||
├── Chrome.svelte Title bar and resize grips for the frameless window
|
├── Chrome.svelte Title bar and resize grips for the frameless window
|
||||||
├── LiquidStart.svelte The voice-reactive transport control
|
├── LiquidStart.svelte The voice-reactive transport control
|
||||||
├── Overlay.svelte The prompter pill
|
├── Overlay.svelte The prompter pill
|
||||||
└── lib/ Typed command wrappers, spring integrator
|
└── lib/ Typed command wrappers, spring integrator, file dialogs
|
||||||
```
|
```
|
||||||
|
|
||||||
The engine deliberately knows nothing about Windows. That keeps it testable
|
The engine deliberately knows nothing about Windows. That keeps it testable
|
||||||
|
|
@ -164,6 +166,23 @@ job; the app already handles everything after it.
|
||||||
Adding a language is a few lines in [`model.rs`](src-tauri/src/model.rs) and a
|
Adding a language is a few lines in [`model.rs`](src-tauri/src/model.rs) and a
|
||||||
published model to point at.
|
published model to point at.
|
||||||
|
|
||||||
|
## Script files
|
||||||
|
|
||||||
|
`.textream` files are the same format the macOS app writes: a JSON array of
|
||||||
|
page strings, nothing else. A script saved on one platform opens cleanly on
|
||||||
|
the other — the format was kept exactly as-is rather than inventing a Windows
|
||||||
|
one, since there was no reason to diverge from something this simple.
|
||||||
|
|
||||||
|
Multi-page scripts are out of scope here (see the roadmap), so opening a file
|
||||||
|
with more than one page flattens them into the single script this app edits,
|
||||||
|
each former page separated by a blank line. Nothing is dropped — a script
|
||||||
|
written on a Mac with several pages still opens here in full, just without the
|
||||||
|
page boundaries.
|
||||||
|
|
||||||
|
The script itself is always autosaved as part of your settings, so `.textream`
|
||||||
|
files are for moving a script between machines or sharing it with someone
|
||||||
|
else, not the only copy of anything.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
Requires:
|
Requires:
|
||||||
|
|
@ -218,8 +237,9 @@ cargo test --workspace
|
||||||
- [x] Font, size, colour and opacity
|
- [x] Font, size, colour and opacity
|
||||||
- [x] Pause, hold, and mute from the transport dock
|
- [x] Pause, hold, and mute from the transport dock
|
||||||
- [x] Tap a word to jump; scroll to catch up
|
- [x] Tap a word to jump; scroll to catch up
|
||||||
- [ ] `.textream` files
|
- [x] `.textream` files — the same format the macOS app writes
|
||||||
- [x] Global shortcuts — start, hold and mute without leaving the camera
|
- [x] Global shortcuts — start, hold and mute without leaving the camera
|
||||||
|
- [x] First-run welcome, and error messages that say what to do
|
||||||
- [ ] Update check
|
- [ ] Update check
|
||||||
|
|
||||||
### Deliberately out of scope
|
### Deliberately out of scope
|
||||||
|
|
|
||||||
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -8,7 +8,8 @@
|
||||||
"name": "textream-windows",
|
"name": "textream-windows",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.1"
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||||
|
|
@ -642,6 +643,15 @@
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-dialog": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
"app:build": "tauri build"
|
"app:build": "tauri build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.1"
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ prompt-core.workspace = true
|
||||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@
|
||||||
"core:window:allow-set-always-on-top",
|
"core:window:allow-set-always-on-top",
|
||||||
"core:window:allow-set-ignore-cursor-events",
|
"core:window:allow-set-ignore-cursor-events",
|
||||||
"core:webview:default",
|
"core:webview:default",
|
||||||
"opener:default"
|
"opener:default",
|
||||||
|
"dialog:default",
|
||||||
|
"dialog:allow-confirm"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
122
src-tauri/src/document.rs
Normal file
122
src-tauri/src/document.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
//! `.textream` files.
|
||||||
|
//!
|
||||||
|
//! The macOS app stores a script as a plain JSON array of page strings —
|
||||||
|
//! `["page one", "page two"]`, nothing else. That format is deliberately kept
|
||||||
|
//! here rather than inventing a Windows-specific one: a script saved on one
|
||||||
|
//! platform opens cleanly on the other, and the format is simple enough that
|
||||||
|
//! there is no reason to diverge from it.
|
||||||
|
//!
|
||||||
|
//! Multi-page scripts are out of scope for this port (see the README), so
|
||||||
|
//! multiple pages are flattened into the one continuous script this app
|
||||||
|
//! edits, separated by a blank line. Nothing is dropped — a script written on
|
||||||
|
//! a Mac with several pages still opens here in full, just without the page
|
||||||
|
//! boundaries.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Extension `.textream` files use, without the leading dot.
|
||||||
|
pub const EXTENSION: &str = "textream";
|
||||||
|
|
||||||
|
/// Serialises a script into the `.textream` file format.
|
||||||
|
///
|
||||||
|
/// The whole script becomes the file's one page. Pretty-printed rather than
|
||||||
|
/// compact: these are small text files a person may reasonably open outside
|
||||||
|
/// the app, and pretty-printing costs nothing a script-sized file would
|
||||||
|
/// notice.
|
||||||
|
pub fn encode(script: &str) -> Result<String, String> {
|
||||||
|
serde_json::to_string_pretty(&[script]).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a `.textream` file's pages back into one script.
|
||||||
|
///
|
||||||
|
/// Rejects a file with no pages at all — that is not a script the editor can
|
||||||
|
/// do anything useful with — but a file with only blank pages is valid; it
|
||||||
|
/// simply produces an empty script.
|
||||||
|
pub fn decode(contents: &str) -> Result<String, String> {
|
||||||
|
let pages: Vec<String> = serde_json::from_str(contents)
|
||||||
|
.map_err(|_| "This file is not a valid .textream script.".to_string())?;
|
||||||
|
if pages.is_empty() {
|
||||||
|
return Err("This .textream file has no pages to read.".to_string());
|
||||||
|
}
|
||||||
|
Ok(pages.join("\n\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `script` to `path` as a `.textream` file.
|
||||||
|
pub fn save(path: &Path, script: &str) -> Result<(), String> {
|
||||||
|
let encoded = encode(script)?;
|
||||||
|
fs::write(path, encoded).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a `.textream` file at `path`, flattened to one script.
|
||||||
|
pub fn load(path: &Path) -> Result<String, String> {
|
||||||
|
let contents = fs::read_to_string(path).map_err(|error| error.to_string())?;
|
||||||
|
decode(&contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_script_round_trips_through_encode_and_decode() {
|
||||||
|
let script = "Welcome back. [smile]\n\nToday we ship.";
|
||||||
|
let encoded = encode(script).unwrap();
|
||||||
|
assert_eq!(decode(&encoded).unwrap(), script);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoding_produces_a_single_element_array() {
|
||||||
|
let encoded = encode("hello").unwrap();
|
||||||
|
let pages: Vec<String> = serde_json::from_str(&encoded).unwrap();
|
||||||
|
assert_eq!(pages, vec!["hello".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_pages_from_the_macos_app_are_flattened_in_order() {
|
||||||
|
let file = r#"["Page one.", "Page two.", "Page three."]"#;
|
||||||
|
assert_eq!(
|
||||||
|
decode(file).unwrap(),
|
||||||
|
"Page one.\n\nPage two.\n\nPage three."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_page_array_is_rejected() {
|
||||||
|
assert!(decode("[]").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_blank_page_decodes_to_an_empty_script() {
|
||||||
|
assert_eq!(decode(r#"[""]"#).unwrap(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_json_is_rejected_with_a_plain_message() {
|
||||||
|
let error = decode("not json at all").unwrap_err();
|
||||||
|
assert!(!error.contains("serde"), "leaked a parser's own vocabulary");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_string_instead_of_an_array_is_rejected() {
|
||||||
|
assert!(decode(r#""just a string"#).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_then_load_preserves_the_script_on_disk() {
|
||||||
|
let dir = std::env::temp_dir().join("textream-document-test");
|
||||||
|
let _ = fs::create_dir_all(&dir);
|
||||||
|
let path = dir.join("sample.textream");
|
||||||
|
|
||||||
|
save(&path, "alpha\n\nbeta").unwrap();
|
||||||
|
assert_eq!(load(&path).unwrap(), "alpha\n\nbeta");
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loading_a_missing_file_fails_without_panicking() {
|
||||||
|
let missing = Path::new("C:/definitely/not/here.textream");
|
||||||
|
assert!(load(missing).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
mod backdrop;
|
mod backdrop;
|
||||||
|
mod document;
|
||||||
mod model;
|
mod model;
|
||||||
mod overlay;
|
mod overlay;
|
||||||
mod session;
|
mod session;
|
||||||
|
|
@ -230,6 +231,32 @@ fn shortcut_bindings() -> Vec<(String, String)> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The file extension `.textream` files use, without the leading dot.
|
||||||
|
///
|
||||||
|
/// Read by the frontend when it builds the save/open dialog's filter, so the
|
||||||
|
/// extension is written in exactly one place rather than duplicated as a
|
||||||
|
/// string literal on both sides of the IPC boundary.
|
||||||
|
#[tauri::command]
|
||||||
|
fn script_file_extension() -> &'static str {
|
||||||
|
document::EXTENSION
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the script to a `.textream` file at `path`.
|
||||||
|
///
|
||||||
|
/// The dialog itself is chosen in the frontend, through `@tauri-apps/plugin-dialog`
|
||||||
|
/// — this command only owns the file format, matching the split the macOS app
|
||||||
|
/// makes between its `NSSavePanel` and `saveToURL`.
|
||||||
|
#[tauri::command]
|
||||||
|
fn save_script_file(path: String, text: String) -> Result<(), String> {
|
||||||
|
document::save(std::path::Path::new(&path), &text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a `.textream` file at `path`, flattened to one script.
|
||||||
|
#[tauri::command]
|
||||||
|
fn open_script_file(path: String) -> Result<String, String> {
|
||||||
|
document::load(std::path::Path::new(&path))
|
||||||
|
}
|
||||||
|
|
||||||
/// Which backdrop the compositor gave the main window.
|
/// Which backdrop the compositor gave the main window.
|
||||||
///
|
///
|
||||||
/// The UI needs this because a frameless transparent window with no effect
|
/// The UI needs this because a frameless transparent window with no effect
|
||||||
|
|
@ -389,6 +416,7 @@ fn build_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(shortcuts::plugin())
|
.plugin(shortcuts::plugin())
|
||||||
.manage(SessionState::new())
|
.manage(SessionState::new())
|
||||||
.manage(AudioState::default())
|
.manage(AudioState::default())
|
||||||
|
|
@ -432,6 +460,9 @@ pub fn run() {
|
||||||
jump_to_offset,
|
jump_to_offset,
|
||||||
window_backdrop,
|
window_backdrop,
|
||||||
shortcut_bindings,
|
shortcut_bindings,
|
||||||
|
script_file_extension,
|
||||||
|
save_script_file,
|
||||||
|
open_script_file,
|
||||||
speech_diagnostics,
|
speech_diagnostics,
|
||||||
is_first_run,
|
is_first_run,
|
||||||
load_settings,
|
load_settings,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import * as api from "./lib/api";
|
import * as api from "./lib/api";
|
||||||
|
import * as files from "./lib/files";
|
||||||
import type {
|
import type {
|
||||||
ColorPreset,
|
ColorPreset,
|
||||||
DownloadProgress,
|
DownloadProgress,
|
||||||
|
|
@ -247,6 +248,27 @@ Today we are shipping something I have wanted for a long time.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveScriptToFile() {
|
||||||
|
try {
|
||||||
|
const saved = await files.saveScript(settings.script);
|
||||||
|
if (saved) say("Script saved.");
|
||||||
|
} catch (error) {
|
||||||
|
say(`Could not save the script: ${error}`, "warn");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openScriptFromFile() {
|
||||||
|
try {
|
||||||
|
const loaded = await files.openScript(settings.script);
|
||||||
|
if (loaded === null) return;
|
||||||
|
settings.script = loaded;
|
||||||
|
persist();
|
||||||
|
say("Script loaded.");
|
||||||
|
} catch (error) {
|
||||||
|
say(`Could not open that file: ${error}`, "warn");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleRun() {
|
async function toggleRun() {
|
||||||
// start() and stop() both open or release a microphone and a model, which
|
// start() and stop() both open or release a microphone and a model, which
|
||||||
// take real time to settle. Without this guard, a second click before the
|
// take real time to settle. Without this guard, a second click before the
|
||||||
|
|
@ -428,6 +450,10 @@ Today we are shipping something I have wanted for a long time.
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<span class="status" class:warn={statusKind === "warn"}>{status}</span>
|
<span class="status" class:warn={statusKind === "warn"}>{status}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="file-actions">
|
||||||
|
<button onclick={openScriptFromFile}>Open…</button>
|
||||||
|
<button onclick={saveScriptToFile}>Save as…</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if diagnostics}
|
{#if diagnostics}
|
||||||
|
|
@ -888,6 +914,25 @@ Today we are shipping something I have wanted for a long time.
|
||||||
color: #ff9e0a;
|
color: #ff9e0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.file-actions button {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.file-actions button:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
.heard {
|
.heard {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
|
|
||||||
|
|
@ -232,3 +232,27 @@ export interface SpeechDiagnostics {
|
||||||
|
|
||||||
export const speechDiagnostics = () =>
|
export const speechDiagnostics = () =>
|
||||||
invoke<SpeechDiagnostics>("speech_diagnostics");
|
invoke<SpeechDiagnostics>("speech_diagnostics");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `.textream` extension, without the leading dot.
|
||||||
|
*
|
||||||
|
* Read from Rust rather than hardcoded here, so the string exists in exactly
|
||||||
|
* one place instead of duplicated across the IPC boundary.
|
||||||
|
*/
|
||||||
|
export const scriptFileExtension = () =>
|
||||||
|
invoke<string>("script_file_extension");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes `text` as a `.textream` file at `path`.
|
||||||
|
*
|
||||||
|
* `path` comes from the native save dialog (see `lib/files.ts`), not from
|
||||||
|
* this module — the dialog is a webview concern, the file format is a Rust
|
||||||
|
* one, and keeping that split means the format can be unit-tested without a
|
||||||
|
* window.
|
||||||
|
*/
|
||||||
|
export const saveScriptFile = (path: string, text: string) =>
|
||||||
|
invoke<void>("save_script_file", { path, text });
|
||||||
|
|
||||||
|
/** Reads a `.textream` file at `path`, flattened to one script. */
|
||||||
|
export const openScriptFile = (path: string) =>
|
||||||
|
invoke<string>("open_script_file", { path });
|
||||||
|
|
|
||||||
72
src/lib/files.ts
Normal file
72
src/lib/files.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
/**
|
||||||
|
* `.textream` file dialogs.
|
||||||
|
*
|
||||||
|
* The native picker lives here, in the webview — it is the one piece of this
|
||||||
|
* feature that is a UI concern rather than a file-format one. Everything about
|
||||||
|
* what a `.textream` file actually contains stays in Rust (`document.rs`),
|
||||||
|
* reachable through `saveScriptFile`/`openScriptFile` in `./api`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { save, open, confirm } from "@tauri-apps/plugin-dialog";
|
||||||
|
import * as api from "./api";
|
||||||
|
|
||||||
|
let cachedExtension: string | null = null;
|
||||||
|
|
||||||
|
async function extension(): Promise<string> {
|
||||||
|
cachedExtension ??= await api.scriptFileExtension();
|
||||||
|
return cachedExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filter() {
|
||||||
|
const ext = await extension();
|
||||||
|
return [{ name: "Textream Script", extensions: [ext] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts for a destination and writes the script there.
|
||||||
|
*
|
||||||
|
* @returns `true` if the file was written, `false` if the user cancelled.
|
||||||
|
*/
|
||||||
|
export async function saveScript(script: string): Promise<boolean> {
|
||||||
|
const ext = await extension();
|
||||||
|
const path = await save({
|
||||||
|
title: "Save script",
|
||||||
|
defaultPath: `Untitled.${ext}`,
|
||||||
|
filters: await filter(),
|
||||||
|
});
|
||||||
|
if (!path) return false;
|
||||||
|
await api.saveScriptFile(path, script);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts for a `.textream` file and returns its script, flattened to one
|
||||||
|
* continuous page.
|
||||||
|
*
|
||||||
|
* Asks for confirmation first when `currentScript` is non-empty, since opening
|
||||||
|
* replaces whatever is in the editor and there is no undo for that here — the
|
||||||
|
* script is autosaved continuously, so the one being replaced is not
|
||||||
|
* recoverable once this returns.
|
||||||
|
*
|
||||||
|
* @returns The loaded script, or `null` if the user cancelled at either step.
|
||||||
|
*/
|
||||||
|
export async function openScript(
|
||||||
|
currentScript: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (currentScript.trim() !== "") {
|
||||||
|
const proceed = await confirm(
|
||||||
|
"Opening a script replaces the one you're editing now.",
|
||||||
|
{ title: "Replace current script?", kind: "warning" },
|
||||||
|
);
|
||||||
|
if (!proceed) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = await open({
|
||||||
|
title: "Open script",
|
||||||
|
filters: await filter(),
|
||||||
|
multiple: false,
|
||||||
|
directory: false,
|
||||||
|
});
|
||||||
|
if (!path || Array.isArray(path)) return null;
|
||||||
|
return api.openScriptFile(path);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue