@steerable/agent-shell-tauri 0.6.73 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/lib.rs +41 -3
- package/src/zoom.rs +460 -0
package/package.json
CHANGED
package/src/lib.rs
CHANGED
|
@@ -3,6 +3,7 @@ mod commands;
|
|
|
3
3
|
mod host;
|
|
4
4
|
mod python_runner;
|
|
5
5
|
mod update;
|
|
6
|
+
mod zoom;
|
|
6
7
|
|
|
7
8
|
use commands::{
|
|
8
9
|
host_capture_screenshot, host_read_clipboard, host_read_clipboard_text, host_save_text_file,
|
|
@@ -49,7 +50,7 @@ impl DesktopConfig {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
fn install_menu(app: &tauri::App) -> tauri::Result<()> {
|
|
53
|
+
fn install_menu(app: &tauri::App, data_dir_name: &str) -> tauri::Result<()> {
|
|
53
54
|
let new_chat = MenuItemBuilder::with_id("new-chat", "新建对话")
|
|
54
55
|
.accelerator("CmdOrCtrl+N")
|
|
55
56
|
.build(app)?;
|
|
@@ -80,7 +81,37 @@ fn install_menu(app: &tauri::App) -> tauri::Result<()> {
|
|
|
80
81
|
.item(&paste)
|
|
81
82
|
.select_all()
|
|
82
83
|
.build()?;
|
|
84
|
+
let labels = zoom::zoom_menu_labels("zh");
|
|
85
|
+
let zoom_in = MenuItemBuilder::with_id("view-zoom-in", labels.zoom_in)
|
|
86
|
+
.accelerator("CmdOrCtrl+=")
|
|
87
|
+
.build(app)?;
|
|
88
|
+
// One menu item accepts one accelerator. `Plus` is not a muda key code,
|
|
89
|
+
// so the shifted equals key is registered separately for layouts where
|
|
90
|
+
// `+` requires Shift.
|
|
91
|
+
let zoom_in_plus = MenuItemBuilder::with_id("view-zoom-in-plus", labels.zoom_in)
|
|
92
|
+
.accelerator("CmdOrCtrl+Shift+=")
|
|
93
|
+
.build(app)?;
|
|
94
|
+
let zoom_out = MenuItemBuilder::with_id("view-zoom-out", labels.zoom_out)
|
|
95
|
+
.accelerator("CmdOrCtrl+-")
|
|
96
|
+
.build(app)?;
|
|
97
|
+
let zoom_reset = MenuItemBuilder::with_id("view-zoom-reset", labels.zoom_reset)
|
|
98
|
+
.accelerator("CmdOrCtrl+0")
|
|
99
|
+
.build(app)?;
|
|
100
|
+
zoom::bind(
|
|
101
|
+
app,
|
|
102
|
+
data_dir_name,
|
|
103
|
+
zoom_in.clone(),
|
|
104
|
+
zoom_in_plus.clone(),
|
|
105
|
+
zoom_out.clone(),
|
|
106
|
+
zoom_reset.clone(),
|
|
107
|
+
)
|
|
108
|
+
.map_err(|error| tauri::Error::Io(std::io::Error::other(error)))?;
|
|
83
109
|
let view = SubmenuBuilder::new(app, "视图")
|
|
110
|
+
.item(&zoom_in)
|
|
111
|
+
.item(&zoom_in_plus)
|
|
112
|
+
.item(&zoom_out)
|
|
113
|
+
.item(&zoom_reset)
|
|
114
|
+
.separator()
|
|
84
115
|
.item(&open_terminal)
|
|
85
116
|
.separator()
|
|
86
117
|
.fullscreen()
|
|
@@ -96,6 +127,9 @@ fn install_menu(app: &tauri::App) -> tauri::Result<()> {
|
|
|
96
127
|
"open-terminal" => {
|
|
97
128
|
let _ = app.emit("menu:open-terminal", ());
|
|
98
129
|
}
|
|
130
|
+
"view-zoom-in" | "view-zoom-in-plus" => zoom::zoom_in(app),
|
|
131
|
+
"view-zoom-out" => zoom::zoom_out(app),
|
|
132
|
+
"view-zoom-reset" => zoom::zoom_reset(app),
|
|
99
133
|
"edit-paste" => {
|
|
100
134
|
let _ = app.emit("menu:paste", clipboard::read_clipboard());
|
|
101
135
|
}
|
|
@@ -216,10 +250,13 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
|
|
|
216
250
|
python_runner_pick_local,
|
|
217
251
|
python_runner_use_local,
|
|
218
252
|
python_runner_use_default,
|
|
219
|
-
python_runner_restart
|
|
253
|
+
python_runner_restart,
|
|
254
|
+
zoom::ui_get_zoom,
|
|
255
|
+
zoom::ui_set_zoom,
|
|
256
|
+
zoom::ui_set_locale
|
|
220
257
|
])
|
|
221
258
|
.setup(move |app| {
|
|
222
|
-
install_menu(app)?;
|
|
259
|
+
install_menu(app, &config.data_dir_name)?;
|
|
223
260
|
let handle = app.handle().clone();
|
|
224
261
|
thread::spawn(move || {
|
|
225
262
|
let (host, url) = match HostProcess::spawn(&handle, &config) {
|
|
@@ -274,6 +311,7 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
|
|
|
274
311
|
.show(move |_| exit_handle.exit(1));
|
|
275
312
|
return;
|
|
276
313
|
}
|
|
314
|
+
zoom::apply_saved(&handle);
|
|
277
315
|
if REVEAL_WHEN_READY.swap(false, Ordering::SeqCst) {
|
|
278
316
|
focus_main_window(&handle);
|
|
279
317
|
}
|
package/src/zoom.rs
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
//! Window zoom for the desktop webview.
|
|
2
|
+
//!
|
|
3
|
+
//! Steps run from 80% to 200% by 10%. A stored value outside that range, or a
|
|
4
|
+
//! value that is not a number, becomes 100%. A requested step clamps instead.
|
|
5
|
+
|
|
6
|
+
use serde_json::{Map, Value};
|
|
7
|
+
use std::fs;
|
|
8
|
+
use std::path::{Path, PathBuf};
|
|
9
|
+
use std::sync::Mutex;
|
|
10
|
+
use tauri::menu::MenuItem;
|
|
11
|
+
use tauri::{AppHandle, Emitter, Manager, Wry};
|
|
12
|
+
|
|
13
|
+
pub const MIN_ZOOM: f64 = 0.8;
|
|
14
|
+
pub const MAX_ZOOM: f64 = 2.0;
|
|
15
|
+
pub const ZOOM_STEP: f64 = 0.1;
|
|
16
|
+
pub const DEFAULT_ZOOM: f64 = 1.0;
|
|
17
|
+
|
|
18
|
+
#[derive(Clone, serde::Serialize)]
|
|
19
|
+
struct ZoomChanged {
|
|
20
|
+
scale: f64,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
pub struct ZoomControls {
|
|
24
|
+
path: PathBuf,
|
|
25
|
+
scale: Mutex<f64>,
|
|
26
|
+
zoom_in: MenuItem<Wry>,
|
|
27
|
+
zoom_in_plus: MenuItem<Wry>,
|
|
28
|
+
zoom_out: MenuItem<Wry>,
|
|
29
|
+
zoom_reset: MenuItem<Wry>,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pub struct ZoomMenuLabels {
|
|
33
|
+
pub zoom_in: &'static str,
|
|
34
|
+
pub zoom_out: &'static str,
|
|
35
|
+
pub zoom_reset: &'static str,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// View-menu labels for the page locale. Both zoom-in rows share one label.
|
|
39
|
+
pub fn zoom_menu_labels(locale: &str) -> ZoomMenuLabels {
|
|
40
|
+
let normalized = locale.trim().to_lowercase().replace('_', "-");
|
|
41
|
+
if normalized == "zh" || normalized.starts_with("zh-") {
|
|
42
|
+
ZoomMenuLabels {
|
|
43
|
+
zoom_in: "放大",
|
|
44
|
+
zoom_out: "缩小",
|
|
45
|
+
zoom_reset: "实际大小",
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
ZoomMenuLabels {
|
|
49
|
+
zoom_in: "Zoom in",
|
|
50
|
+
zoom_out: "Zoom out",
|
|
51
|
+
zoom_reset: "Actual size",
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
pub struct LoadedZoom {
|
|
57
|
+
pub scale: f64,
|
|
58
|
+
pub rewrite: bool,
|
|
59
|
+
pub invalid: Option<String>,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// Round to the nearest 10% step and keep the result inside 80%–200%.
|
|
63
|
+
pub fn snap_zoom(scale: f64) -> f64 {
|
|
64
|
+
if !scale.is_finite() {
|
|
65
|
+
return DEFAULT_ZOOM;
|
|
66
|
+
}
|
|
67
|
+
let percent = (scale * 100.0).round() as i64;
|
|
68
|
+
let tenths = ((percent as f64) / 10.0).round() as i64;
|
|
69
|
+
let tenths = tenths.clamp((MIN_ZOOM * 10.0) as i64, (MAX_ZOOM * 10.0) as i64);
|
|
70
|
+
(tenths as f64) / 10.0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Move by `direction` steps from the current level. `0` only snaps.
|
|
74
|
+
pub fn step_zoom(current: f64, direction: i32) -> f64 {
|
|
75
|
+
let base = snap_zoom(current);
|
|
76
|
+
snap_zoom(base + f64::from(direction) * ZOOM_STEP)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
pub fn zoom_in_enabled(scale: f64) -> bool {
|
|
80
|
+
snap_zoom(scale) < MAX_ZOOM - 1e-9
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
pub fn zoom_out_enabled(scale: f64) -> bool {
|
|
84
|
+
snap_zoom(scale) > MIN_ZOOM + 1e-9
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Read `zoom` from `window-state.json`.
|
|
88
|
+
///
|
|
89
|
+
/// A missing file or a missing field is 100% and is not an error. A
|
|
90
|
+
/// non-numeric or out-of-range field is 100% and `invalid` explains why.
|
|
91
|
+
/// `rewrite` is set when the file is an object whose stored zoom should be
|
|
92
|
+
/// replaced with `scale`.
|
|
93
|
+
pub fn read_window_zoom(path: &Path) -> LoadedZoom {
|
|
94
|
+
let bytes = match fs::read(path) {
|
|
95
|
+
Ok(bytes) => bytes,
|
|
96
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
97
|
+
return LoadedZoom {
|
|
98
|
+
scale: DEFAULT_ZOOM,
|
|
99
|
+
rewrite: false,
|
|
100
|
+
invalid: None,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
Err(error) => {
|
|
104
|
+
return LoadedZoom {
|
|
105
|
+
scale: DEFAULT_ZOOM,
|
|
106
|
+
rewrite: false,
|
|
107
|
+
invalid: Some(format!("could not read window-state.json: {error}")),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
let value: Value = match serde_json::from_slice(&bytes) {
|
|
112
|
+
Ok(value) => value,
|
|
113
|
+
Err(error) => {
|
|
114
|
+
return LoadedZoom {
|
|
115
|
+
scale: DEFAULT_ZOOM,
|
|
116
|
+
rewrite: false,
|
|
117
|
+
invalid: Some(format!("window-state.json is not JSON: {error}")),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
let Some(object) = value.as_object() else {
|
|
122
|
+
return LoadedZoom {
|
|
123
|
+
scale: DEFAULT_ZOOM,
|
|
124
|
+
rewrite: false,
|
|
125
|
+
invalid: Some("window-state.json is not an object".to_string()),
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
match object.get("zoom") {
|
|
129
|
+
None => LoadedZoom {
|
|
130
|
+
scale: DEFAULT_ZOOM,
|
|
131
|
+
rewrite: false,
|
|
132
|
+
invalid: None,
|
|
133
|
+
},
|
|
134
|
+
Some(zoom) => match interpret_stored_zoom(zoom) {
|
|
135
|
+
Ok(scale) => {
|
|
136
|
+
let stored = zoom.as_f64().unwrap_or(scale);
|
|
137
|
+
LoadedZoom {
|
|
138
|
+
scale,
|
|
139
|
+
rewrite: (scale - stored).abs() > 1e-9,
|
|
140
|
+
invalid: None,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
Err(detail) => LoadedZoom {
|
|
144
|
+
scale: DEFAULT_ZOOM,
|
|
145
|
+
rewrite: true,
|
|
146
|
+
invalid: Some(detail),
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
fn interpret_stored_zoom(value: &Value) -> Result<f64, String> {
|
|
153
|
+
let Some(scale) = value.as_f64() else {
|
|
154
|
+
return Err(format!("zoom is not a number ({value})"));
|
|
155
|
+
};
|
|
156
|
+
if !scale.is_finite() || !(MIN_ZOOM..=MAX_ZOOM).contains(&scale) {
|
|
157
|
+
return Err(format!("zoom {scale} is outside 0.8..=2.0"));
|
|
158
|
+
}
|
|
159
|
+
Ok(snap_zoom(scale))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Write `zoom`, keeping every other field already in the file.
|
|
163
|
+
pub fn write_zoom(path: &Path, scale: f64) -> Result<(), String> {
|
|
164
|
+
let mut object = match fs::read(path) {
|
|
165
|
+
Ok(bytes) => serde_json::from_slice::<Value>(&bytes)
|
|
166
|
+
.ok()
|
|
167
|
+
.and_then(|value| match value {
|
|
168
|
+
Value::Object(object) => Some(object),
|
|
169
|
+
_ => None,
|
|
170
|
+
})
|
|
171
|
+
.unwrap_or_default(),
|
|
172
|
+
Err(_) => Map::new(),
|
|
173
|
+
};
|
|
174
|
+
let quant = snap_zoom(scale);
|
|
175
|
+
let number = serde_json::Number::from_f64(quant)
|
|
176
|
+
.ok_or_else(|| format!("zoom {quant} is not a JSON number"))?;
|
|
177
|
+
object.insert("zoom".to_string(), Value::Number(number));
|
|
178
|
+
if let Some(parent) = path.parent() {
|
|
179
|
+
if !parent.as_os_str().is_empty() {
|
|
180
|
+
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
let mut text =
|
|
184
|
+
serde_json::to_string_pretty(&Value::Object(object)).map_err(|error| error.to_string())?;
|
|
185
|
+
text.push('\n');
|
|
186
|
+
fs::write(path, text).map_err(|error| error.to_string())
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
pub fn user_data_dir(app: &AppHandle, data_dir_name: &str) -> Result<PathBuf, String> {
|
|
190
|
+
let dir = match std::env::var_os("DEEPPATH_USER_DATA_DIR") {
|
|
191
|
+
Some(path) => PathBuf::from(path),
|
|
192
|
+
None => app
|
|
193
|
+
.path()
|
|
194
|
+
.home_dir()
|
|
195
|
+
.map_err(|error| error.to_string())?
|
|
196
|
+
.join(data_dir_name),
|
|
197
|
+
};
|
|
198
|
+
fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
|
199
|
+
Ok(dir)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
pub fn bind(
|
|
203
|
+
app: &tauri::App,
|
|
204
|
+
data_dir_name: &str,
|
|
205
|
+
zoom_in: MenuItem<Wry>,
|
|
206
|
+
zoom_in_plus: MenuItem<Wry>,
|
|
207
|
+
zoom_out: MenuItem<Wry>,
|
|
208
|
+
zoom_reset: MenuItem<Wry>,
|
|
209
|
+
) -> Result<(), String> {
|
|
210
|
+
let path = user_data_dir(app.handle(), data_dir_name)?.join("window-state.json");
|
|
211
|
+
app.manage(ZoomControls {
|
|
212
|
+
path,
|
|
213
|
+
scale: Mutex::new(DEFAULT_ZOOM),
|
|
214
|
+
zoom_in,
|
|
215
|
+
zoom_in_plus,
|
|
216
|
+
zoom_out,
|
|
217
|
+
zoom_reset,
|
|
218
|
+
});
|
|
219
|
+
Ok(())
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
pub fn apply_locale(app: &AppHandle, locale: &str) {
|
|
223
|
+
let Some(controls) = app.try_state::<ZoomControls>() else {
|
|
224
|
+
return;
|
|
225
|
+
};
|
|
226
|
+
let labels = zoom_menu_labels(locale);
|
|
227
|
+
let _ = controls.zoom_in.set_text(labels.zoom_in);
|
|
228
|
+
let _ = controls.zoom_in_plus.set_text(labels.zoom_in);
|
|
229
|
+
let _ = controls.zoom_out.set_text(labels.zoom_out);
|
|
230
|
+
let _ = controls.zoom_reset.set_text(labels.zoom_reset);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
pub fn apply_saved(app: &AppHandle) {
|
|
234
|
+
let Some(controls) = app.try_state::<ZoomControls>() else {
|
|
235
|
+
return;
|
|
236
|
+
};
|
|
237
|
+
let loaded = read_window_zoom(&controls.path);
|
|
238
|
+
if let Some(detail) = &loaded.invalid {
|
|
239
|
+
eprintln!("[zoom] {detail}; using 100%");
|
|
240
|
+
}
|
|
241
|
+
let _ = commit(app, &controls, loaded.scale, loaded.rewrite, false);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
pub fn zoom_in(app: &AppHandle) {
|
|
245
|
+
let Some(controls) = app.try_state::<ZoomControls>() else {
|
|
246
|
+
return;
|
|
247
|
+
};
|
|
248
|
+
let next = step_zoom(current_scale(&controls), 1);
|
|
249
|
+
let _ = commit(app, &controls, next, true, true);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
pub fn zoom_out(app: &AppHandle) {
|
|
253
|
+
let Some(controls) = app.try_state::<ZoomControls>() else {
|
|
254
|
+
return;
|
|
255
|
+
};
|
|
256
|
+
let next = step_zoom(current_scale(&controls), -1);
|
|
257
|
+
let _ = commit(app, &controls, next, true, true);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
pub fn zoom_reset(app: &AppHandle) {
|
|
261
|
+
let Some(controls) = app.try_state::<ZoomControls>() else {
|
|
262
|
+
return;
|
|
263
|
+
};
|
|
264
|
+
let _ = commit(app, &controls, DEFAULT_ZOOM, true, true);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
pub fn current_scale(controls: &ZoomControls) -> f64 {
|
|
268
|
+
*controls
|
|
269
|
+
.scale
|
|
270
|
+
.lock()
|
|
271
|
+
.unwrap_or_else(|poison| poison.into_inner())
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
fn commit(
|
|
275
|
+
app: &AppHandle,
|
|
276
|
+
controls: &ZoomControls,
|
|
277
|
+
scale: f64,
|
|
278
|
+
persist: bool,
|
|
279
|
+
notify: bool,
|
|
280
|
+
) -> Result<f64, String> {
|
|
281
|
+
let scale = snap_zoom(scale);
|
|
282
|
+
if let Some(window) = app.get_webview_window("main") {
|
|
283
|
+
window
|
|
284
|
+
.set_zoom(scale)
|
|
285
|
+
.map_err(|error| format!("set_zoom failed: {error}"))?;
|
|
286
|
+
}
|
|
287
|
+
*controls
|
|
288
|
+
.scale
|
|
289
|
+
.lock()
|
|
290
|
+
.unwrap_or_else(|poison| poison.into_inner()) = scale;
|
|
291
|
+
let _ = controls.zoom_in.set_enabled(zoom_in_enabled(scale));
|
|
292
|
+
let _ = controls.zoom_in_plus.set_enabled(zoom_in_enabled(scale));
|
|
293
|
+
let _ = controls.zoom_out.set_enabled(zoom_out_enabled(scale));
|
|
294
|
+
if persist {
|
|
295
|
+
if let Err(error) = write_zoom(&controls.path, scale) {
|
|
296
|
+
eprintln!("[zoom] failed to write window-state.json: {error}");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if notify {
|
|
300
|
+
let _ = app.emit("ui:zoom-changed", ZoomChanged { scale });
|
|
301
|
+
}
|
|
302
|
+
Ok(scale)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#[tauri::command]
|
|
306
|
+
pub fn ui_get_zoom(app: AppHandle) -> f64 {
|
|
307
|
+
app.try_state::<ZoomControls>()
|
|
308
|
+
.map(|controls| current_scale(&controls))
|
|
309
|
+
.unwrap_or(DEFAULT_ZOOM)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#[tauri::command]
|
|
313
|
+
pub fn ui_set_locale(app: AppHandle, locale: String) {
|
|
314
|
+
apply_locale(&app, &locale);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#[tauri::command]
|
|
318
|
+
pub fn ui_set_zoom(app: AppHandle, scale: f64) -> f64 {
|
|
319
|
+
if !scale.is_finite() {
|
|
320
|
+
eprintln!("[zoom] ignoring non-numeric zoom");
|
|
321
|
+
return ui_get_zoom(app);
|
|
322
|
+
}
|
|
323
|
+
let Some(controls) = app.try_state::<ZoomControls>() else {
|
|
324
|
+
return DEFAULT_ZOOM;
|
|
325
|
+
};
|
|
326
|
+
match commit(&app, &controls, scale, true, true) {
|
|
327
|
+
Ok(applied) => applied,
|
|
328
|
+
Err(error) => {
|
|
329
|
+
eprintln!("[zoom] {error}");
|
|
330
|
+
current_scale(&controls)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#[cfg(test)]
|
|
336
|
+
mod tests {
|
|
337
|
+
use super::*;
|
|
338
|
+
|
|
339
|
+
fn temp_file(name: &str) -> PathBuf {
|
|
340
|
+
let dir = std::env::temp_dir().join(format!(
|
|
341
|
+
"zoom-{}-{}-{}",
|
|
342
|
+
name,
|
|
343
|
+
std::process::id(),
|
|
344
|
+
std::time::SystemTime::now()
|
|
345
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
346
|
+
.expect("clock")
|
|
347
|
+
.as_nanos()
|
|
348
|
+
));
|
|
349
|
+
fs::create_dir_all(&dir).unwrap();
|
|
350
|
+
dir.join("window-state.json")
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
#[test]
|
|
354
|
+
fn menu_steps_match_the_ten_percent_grid() {
|
|
355
|
+
let mut scale = DEFAULT_ZOOM;
|
|
356
|
+
scale = step_zoom(scale, 1);
|
|
357
|
+
assert!((scale - 1.1).abs() < 1e-9);
|
|
358
|
+
scale = step_zoom(scale, 1);
|
|
359
|
+
scale = step_zoom(scale, 1);
|
|
360
|
+
assert!((scale - 1.3).abs() < 1e-9);
|
|
361
|
+
scale = step_zoom(scale, -1);
|
|
362
|
+
assert!((scale - 1.2).abs() < 1e-9);
|
|
363
|
+
assert!((step_zoom(scale, 0) - 1.2).abs() < 1e-9);
|
|
364
|
+
assert!((DEFAULT_ZOOM - 1.0).abs() < 1e-9);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#[test]
|
|
368
|
+
fn menu_labels_follow_the_page_locale() {
|
|
369
|
+
let chinese = zoom_menu_labels("zh-CN");
|
|
370
|
+
assert_eq!(chinese.zoom_in, "放大");
|
|
371
|
+
assert_eq!(chinese.zoom_out, "缩小");
|
|
372
|
+
assert_eq!(chinese.zoom_reset, "实际大小");
|
|
373
|
+
let english = zoom_menu_labels("en");
|
|
374
|
+
assert_eq!(english.zoom_in, "Zoom in");
|
|
375
|
+
assert_eq!(english.zoom_out, "Zoom out");
|
|
376
|
+
assert_eq!(english.zoom_reset, "Actual size");
|
|
377
|
+
assert_eq!(zoom_menu_labels("").zoom_in, "Zoom in");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#[test]
|
|
381
|
+
fn steps_stop_at_the_ends() {
|
|
382
|
+
let mut scale = DEFAULT_ZOOM;
|
|
383
|
+
for _ in 0..20 {
|
|
384
|
+
scale = step_zoom(scale, 1);
|
|
385
|
+
}
|
|
386
|
+
assert!((scale - MAX_ZOOM).abs() < 1e-9);
|
|
387
|
+
assert!(!zoom_in_enabled(scale));
|
|
388
|
+
assert!(zoom_out_enabled(scale));
|
|
389
|
+
scale = DEFAULT_ZOOM;
|
|
390
|
+
for _ in 0..20 {
|
|
391
|
+
scale = step_zoom(scale, -1);
|
|
392
|
+
}
|
|
393
|
+
assert!((scale - MIN_ZOOM).abs() < 1e-9);
|
|
394
|
+
assert!(!zoom_out_enabled(scale));
|
|
395
|
+
assert!(zoom_in_enabled(scale));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#[test]
|
|
399
|
+
fn an_in_range_off_step_value_snaps() {
|
|
400
|
+
assert!((snap_zoom(1.25) - 1.3).abs() < 1e-9);
|
|
401
|
+
assert!((snap_zoom(1.5) - 1.5).abs() < 1e-9);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
#[test]
|
|
405
|
+
fn a_missing_file_is_one_hundred_percent() {
|
|
406
|
+
let path = temp_file("missing");
|
|
407
|
+
let loaded = read_window_zoom(&path);
|
|
408
|
+
assert!(loaded.invalid.is_none());
|
|
409
|
+
assert!(!loaded.rewrite);
|
|
410
|
+
assert!((loaded.scale - 1.0).abs() < 1e-9);
|
|
411
|
+
let _ = fs::remove_dir_all(path.parent().unwrap());
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
#[test]
|
|
415
|
+
fn a_non_numeric_zoom_resets_and_keeps_other_fields() {
|
|
416
|
+
let path = temp_file("bad");
|
|
417
|
+
fs::write(&path, "{\n \"theme\": \"dark\",\n \"zoom\": \"abc\"\n}\n").unwrap();
|
|
418
|
+
let loaded = read_window_zoom(&path);
|
|
419
|
+
assert!(loaded.invalid.unwrap().contains("not a number"));
|
|
420
|
+
assert!(loaded.rewrite);
|
|
421
|
+
assert!((loaded.scale - 1.0).abs() < 1e-9);
|
|
422
|
+
write_zoom(&path, loaded.scale).unwrap();
|
|
423
|
+
let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
|
|
424
|
+
assert_eq!(value["theme"], "dark");
|
|
425
|
+
assert_eq!(value["zoom"], 1.0);
|
|
426
|
+
let _ = fs::remove_dir_all(path.parent().unwrap());
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#[test]
|
|
430
|
+
fn an_out_of_range_zoom_resets() {
|
|
431
|
+
let path = temp_file("range");
|
|
432
|
+
fs::write(&path, "{\"zoom\": 3}\n").unwrap();
|
|
433
|
+
let loaded = read_window_zoom(&path);
|
|
434
|
+
assert!(loaded.invalid.unwrap().contains("outside"));
|
|
435
|
+
assert!((loaded.scale - 1.0).abs() < 1e-9);
|
|
436
|
+
let _ = fs::remove_dir_all(path.parent().unwrap());
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
#[test]
|
|
440
|
+
fn a_stored_step_round_trips() {
|
|
441
|
+
let path = temp_file("round");
|
|
442
|
+
write_zoom(&path, 1.5).unwrap();
|
|
443
|
+
let loaded = read_window_zoom(&path);
|
|
444
|
+
assert!(loaded.invalid.is_none());
|
|
445
|
+
assert!(!loaded.rewrite);
|
|
446
|
+
assert!((loaded.scale - 1.5).abs() < 1e-9);
|
|
447
|
+
let _ = fs::remove_dir_all(path.parent().unwrap());
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#[test]
|
|
451
|
+
fn broken_json_is_not_overwritten() {
|
|
452
|
+
let path = temp_file("broken");
|
|
453
|
+
fs::write(&path, "not-json").unwrap();
|
|
454
|
+
let loaded = read_window_zoom(&path);
|
|
455
|
+
assert!(loaded.invalid.is_some());
|
|
456
|
+
assert!(!loaded.rewrite);
|
|
457
|
+
assert_eq!(fs::read_to_string(&path).unwrap(), "not-json");
|
|
458
|
+
let _ = fs::remove_dir_all(path.parent().unwrap());
|
|
459
|
+
}
|
|
460
|
+
}
|