1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
//! Application and window configuration.
use std::collections::HashMap;
use std::time::Duration;
use serde_json::{Map, Value};
use crate::types::{PlushieType, Theme};
/// Application-level settings.
///
/// All fields are optional. The renderer uses sensible defaults
/// when fields are omitted.
#[derive(Debug, Clone, Default)]
pub struct Settings {
/// The default font family name (e.g. `"monospace"`).
pub default_font: Option<String>,
/// Default text size in logical pixels.
pub default_text_size: Option<f32>,
/// Enable multi-sample anti-aliasing.
pub antialiasing: Option<bool>,
/// Enable vertical sync.
pub vsync: Option<bool>,
/// Global DPI scale factor override.
pub scale_factor: Option<f32>,
/// Application-wide theme.
pub theme: Option<Theme>,
/// Paths to font files to load at startup.
pub fonts: Vec<String>,
/// Maximum event rate in events per second (throttling).
pub default_event_rate: Option<u32>,
/// Per-widget-type configuration passed to native widgets.
pub widget_config: HashMap<String, Value>,
/// Native widget type names this app requires to be present in
/// the renderer. Validated during the Settings handshake: any
/// missing names are surfaced via a `required_widgets_missing`
/// diagnostic. Non-fatal by design; host SDKs decide whether to
/// warn or halt based on the diagnostic.
pub required_widgets: Vec<String>,
}
impl Settings {
/// Encode the settings into the canonical wire-format JSON object.
///
/// This is the single source of truth for the Settings JSON shape.
/// Both wire mode (subprocess renderer over stdin/stdout) and
/// direct mode (in-process renderer) feed the renderer through
/// this same canonical shape, so any new field added to
/// [`Settings`] must be handled here once and is automatically
/// honoured by every code path that ingests the JSON.
///
/// The shape mirrors what [`crate::protocol::IncomingMessage::Settings`]
/// expects: e.g. `default_font` is an object with a `family` key,
/// not a bare string. Fields whose value is `None` (or empty
/// collection) are omitted from the output so the renderer
/// applies its own defaults.
///
/// `protocol_version` is intentionally not included: it lives in
/// the outer message envelope, not the Settings payload, and is
/// added by the caller (wire mode prepends it before sending).
pub fn to_wire_json(&self) -> Value {
let mut obj = Map::new();
if let Some(ref font) = self.default_font {
let mut font_obj = Map::new();
font_obj.insert("family".to_string(), Value::String(font.clone()));
obj.insert("default_font".to_string(), Value::Object(font_obj));
}
if let Some(size) = self.default_text_size {
obj.insert("default_text_size".to_string(), serde_json::json!(size));
}
if let Some(antialiasing) = self.antialiasing {
obj.insert("antialiasing".to_string(), Value::Bool(antialiasing));
}
if let Some(vsync) = self.vsync {
obj.insert("vsync".to_string(), Value::Bool(vsync));
}
if let Some(scale) = self.scale_factor {
obj.insert("scale_factor".to_string(), serde_json::json!(scale));
}
if let Some(rate) = self.default_event_rate {
obj.insert("default_event_rate".to_string(), serde_json::json!(rate));
}
if !self.fonts.is_empty() {
obj.insert("fonts".to_string(), serde_json::json!(self.fonts));
}
if !self.widget_config.is_empty() {
obj.insert(
"widget_config".to_string(),
serde_json::to_value(&self.widget_config).unwrap_or(Value::Null),
);
}
if !self.required_widgets.is_empty() {
obj.insert(
"required_widgets".to_string(),
serde_json::json!(self.required_widgets),
);
}
if let Some(ref theme) = self.theme {
obj.insert("theme".to_string(), Value::from(theme.wire_encode()));
}
Value::Object(obj)
}
}
/// Per-window defaults. Returned from the SDK's `App::window_config`
/// (defined in the `plushie` crate, not here).
#[derive(Debug, Clone, Default)]
pub struct WindowConfig {
/// Window title bar text.
pub title: Option<String>,
/// Initial window width in logical pixels.
pub width: Option<f32>,
/// Initial window height in logical pixels.
pub height: Option<f32>,
/// Initial window position as (x, y) in logical pixels.
pub position: Option<(f32, f32)>,
/// Minimum window size as (width, height).
pub min_size: Option<(f32, f32)>,
/// Maximum window size as (width, height).
pub max_size: Option<(f32, f32)>,
/// Whether the window starts maximized.
pub maximized: Option<bool>,
/// Whether the window starts in fullscreen mode.
pub fullscreen: Option<bool>,
/// Whether the window is initially visible.
pub visible: Option<bool>,
/// Whether the user can resize the window.
pub resizable: Option<bool>,
/// Whether the window has title bar and borders.
pub decorations: Option<bool>,
/// Whether the window background is transparent.
pub transparent: Option<bool>,
/// Whether the window close button is shown.
pub closeable: Option<bool>,
/// Whether the window can be minimized.
pub minimizable: Option<bool>,
/// Blur the window background (platform-dependent).
pub blur: Option<bool>,
/// Window stacking level.
pub level: Option<crate::ops::WindowLevel>,
/// Whether closing the window exits the application.
pub exit_on_close_request: Option<bool>,
/// Max events per second for coalescable events.
pub event_rate: Option<u32>,
/// Accessibility annotations.
pub a11y: Option<Value>,
/// Per-window theme override.
pub theme: Option<Theme>,
/// Per-window DPI scale factor override.
pub scale_factor: Option<f32>,
}
/// Reason the renderer process exited (wire mode only).
///
/// Passed to `App::handle_renderer_exit` before the runner returns.
/// The variants mirror the Elixir bridge's exit categorisation so
/// behaviour is consistent across SDKs.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ExitReason {
/// Renderer crashed. `message` carries the I/O error or panic
/// description; `code` is the subprocess exit code if we were
/// able to reap it.
Crash {
/// Human-readable message.
message: String,
/// Error code.
code: Option<i32>,
},
/// Lost connection to the renderer (pipe closed cleanly without
/// a full message).
ConnectionLost,
/// Renderer shut down at our request (e.g. `Command::Exit`).
Shutdown,
/// No messages received within the configured heartbeat interval.
HeartbeatTimeout,
/// Auto-restart gave up after exhausting
/// [`RestartPolicy::max_restarts`]. `last_reason` is the reason
/// for the final restart attempt.
MaxRestartsReached {
/// Last reason.
last_reason: Box<ExitReason>,
},
/// Dev-mode hot-reload requested a renderer swap after a
/// successful widget-crate rebuild. Treated like a clean exit
/// for restart-policy purposes: doesn't count against
/// `max_restarts`, no backoff, just respawn.
RendererSwap,
}
impl ExitReason {
/// Short human-friendly label, useful for logs.
pub fn label(&self) -> &'static str {
match self {
Self::Crash { .. } => "crash",
Self::ConnectionLost => "connection_lost",
Self::Shutdown => "shutdown",
Self::HeartbeatTimeout => "heartbeat_timeout",
Self::MaxRestartsReached { .. } => "max_restarts_reached",
Self::RendererSwap => "renderer_swap",
}
}
}
/// Restart policy for wire mode.
///
/// Returned from `App::restart_policy` to configure auto-restart
/// behaviour on renderer crashes.
///
/// `max_restarts = 0` disables auto-restart entirely; the first crash
/// delivers [`ExitReason::Crash`] to `App::handle_renderer_exit` and
/// the runner returns with `plushie::Error::RendererExit`.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RestartPolicy {
/// Maximum consecutive restart attempts before giving up.
pub max_restarts: u32,
/// Base delay for exponential backoff. Actual delay is
/// `restart_delay * 2.pow(restart_count)`.
pub restart_delay: Duration,
/// If `Some`, a watchdog triggers a restart if no wire message is
/// received within this interval. `None` disables heartbeats.
pub heartbeat_interval: Option<Duration>,
}
impl Default for RestartPolicy {
fn default() -> Self {
Self {
max_restarts: 5,
restart_delay: Duration::from_millis(100),
heartbeat_interval: Some(Duration::from_secs(30)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Theme;
#[test]
fn empty_settings_serialize_to_empty_object() {
let json = Settings::default().to_wire_json();
assert_eq!(json, serde_json::json!({}));
}
#[test]
fn default_font_is_an_object_with_family() {
// The renderer expects `default_font` as an object so it can
// also carry a fallback chain. A bare string was a latent bug
// that broke runtime resolution.
let settings = Settings {
default_font: Some("monospace".into()),
..Default::default()
};
let json = settings.to_wire_json();
assert_eq!(
json.get("default_font"),
Some(&serde_json::json!({"family": "monospace"}))
);
}
#[test]
fn populated_settings_serialize_round_trip() {
let settings = Settings {
default_font: Some("monospace".into()),
default_text_size: Some(15.0),
antialiasing: Some(true),
vsync: Some(false),
scale_factor: Some(1.25),
theme: Some(Theme::Named("dark".into())),
fonts: vec!["/tmp/a.ttf".into()],
default_event_rate: Some(60),
widget_config: HashMap::from([("gauge".into(), serde_json::json!({"k": 1}))]),
required_widgets: vec!["gauge".into()],
};
let json = settings.to_wire_json();
let obj = json.as_object().expect("object");
assert_eq!(
obj.get("default_font"),
Some(&serde_json::json!({"family": "monospace"}))
);
assert_eq!(obj.get("default_text_size"), Some(&serde_json::json!(15.0)));
assert_eq!(obj.get("antialiasing"), Some(&serde_json::json!(true)));
assert_eq!(obj.get("vsync"), Some(&serde_json::json!(false)));
assert_eq!(obj.get("scale_factor"), Some(&serde_json::json!(1.25)));
assert_eq!(obj.get("theme"), Some(&serde_json::json!("dark")));
assert_eq!(obj.get("fonts"), Some(&serde_json::json!(["/tmp/a.ttf"])));
assert_eq!(obj.get("default_event_rate"), Some(&serde_json::json!(60)));
assert_eq!(
obj.get("widget_config"),
Some(&serde_json::json!({"gauge": {"k": 1}}))
);
assert_eq!(
obj.get("required_widgets"),
Some(&serde_json::json!(["gauge"]))
);
assert!(obj.get("protocol_version").is_none());
}
}