Skip to main content

lingxia_surface/
model.rs

1//! Surface node data model (§1.1 of the Adaptive Surface Layout spec).
2//!
3//! A `Surface` is one content unit in the graph. Its `role` expresses the
4//! relationship to the main content; `content` is what it shows; `owner`
5//! drives lifecycle; `placement` is a non-authoritative hint.
6
7use serde::{Deserialize, Serialize};
8
9/// Stable identifier of a surface within a graph.
10pub type SurfaceId = String;
11
12/// Relationship of a surface to the main content. The single core abstraction
13/// behind every platform skin (window / panel / sheet / tab …).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Role {
17    /// Switchable top-level content. Only one is the active `primary` at a time.
18    Main,
19    /// Companion shown beside the main content (split axis).
20    Aside,
21    /// Floats above, never occupies the main layout.
22    Float,
23}
24
25/// Which edge a surface docks to (asides) or anchors from.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum Edge {
29    Left,
30    Right,
31    Top,
32    Bottom,
33}
34
35/// What a surface shows. Only two kinds: a declared catalog `entry`
36/// (resolved by the host from `lingxia.toml`) or an ad-hoc `web` page.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(tag = "kind", rename_all = "lowercase")]
39pub enum SurfaceContent {
40    /// A `lingxia.toml`-declared app / system feature, opened by id.
41    Entry {
42        id: String,
43        /// Initial route only; subsequent navigation goes through `lx.navigator`.
44        #[serde(default, skip_serializing_if = "Option::is_none")]
45        path: Option<String>,
46    },
47    /// An ad-hoc web page / PDF rendered by the in-app chromed browser. Whether
48    /// it presents as a main browser tab or a docked browser aside is decided by
49    /// `role`, not by the content — the browser always carries its own chrome.
50    Web {
51        url: String,
52        /// Reopening the same URL normally focuses the existing browser aside.
53        /// Callback surfaces opt out because their navigation and data-store
54        /// policy must never inherit an ordinary tab's WebView.
55        #[serde(
56            default = "default_reuse_by_url",
57            skip_serializing_if = "is_reuse_by_url"
58        )]
59        reuse_by_url: bool,
60    },
61}
62
63const fn default_reuse_by_url() -> bool {
64    true
65}
66
67fn is_reuse_by_url(value: &bool) -> bool {
68    *value
69}
70
71/// Aside slot grouping: the aside area holds at most one region per render
72/// engine — lxapp, browser, native — and multiple contents of one kind live
73/// inside that region as tabs.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase")]
76pub enum SlotKind {
77    Lxapp,
78    Browser,
79    Native,
80}
81
82impl SurfaceContent {
83    /// The aside slot this content belongs to. The built-in terminal is the
84    /// only native capability today; native contents get a first-class marker
85    /// when a second capability lands.
86    pub fn slot_kind(&self) -> SlotKind {
87        match self {
88            SurfaceContent::Web { .. } => SlotKind::Browser,
89            SurfaceContent::Entry { id, .. } if id == "terminal" => SlotKind::Native,
90            SurfaceContent::Entry { .. } => SlotKind::Lxapp,
91        }
92    }
93}
94
95/// Owner scope: decides when the surface is closed (§5).
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[serde(tag = "scope", rename_all = "camelCase")]
98pub enum SurfaceOwner {
99    Page { page_instance_id: String },
100    Lxapp { app_id: String },
101    Host,
102}
103
104/// Placement hint (input). Authoritative layout is the `LayoutTree` (output).
105#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub struct Placement {
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub edge: Option<Edge>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub preferred_size: Option<f64>,
112}
113
114/// Runtime state of a surface.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "lowercase")]
117pub enum SurfaceState {
118    Mounted,
119    Hidden,
120    Minimized,
121}
122
123/// How a `float` surface anchors.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(tag = "to", rename_all = "lowercase")]
126pub enum FloatAnchor {
127    Screen,
128    Surface { surface_id: SurfaceId },
129}
130
131/// How a `float` surface is dismissed.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub enum FloatDismiss {
135    TapOutside,
136    Manual,
137}
138
139/// User interaction contract for a dynamically presented surface.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct SurfaceInteraction {
143    pub close_button: bool,
144    pub dismiss: FloatDismiss,
145    pub modal: bool,
146}
147
148impl SurfaceInteraction {
149    pub const fn standard() -> Self {
150        Self {
151            close_button: false,
152            dismiss: FloatDismiss::TapOutside,
153            modal: false,
154        }
155    }
156
157    pub const fn url_callback() -> Self {
158        Self {
159            close_button: true,
160            dismiss: FloatDismiss::Manual,
161            modal: true,
162        }
163    }
164
165    pub const fn window() -> Self {
166        Self {
167            close_button: false,
168            dismiss: FloatDismiss::Manual,
169            modal: false,
170        }
171    }
172}
173
174impl Default for SurfaceInteraction {
175    fn default() -> Self {
176        Self::standard()
177    }
178}
179
180/// Minimal semantics carried only by `float` surfaces (§1.1).
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct FloatSpec {
184    pub anchor: FloatAnchor,
185    pub dismiss: FloatDismiss,
186    /// Whether it blocks input to layers below (drives focus-restore, §1.5).
187    pub modal: bool,
188    /// Whether the platform renders the standard circular close control.
189    pub close_button: bool,
190}
191
192impl Default for FloatSpec {
193    fn default() -> Self {
194        Self {
195            anchor: FloatAnchor::Screen,
196            dismiss: FloatDismiss::TapOutside,
197            modal: false,
198            close_button: false,
199        }
200    }
201}
202
203/// One node in the Surface Graph.
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct Surface {
207    pub id: SurfaceId,
208    pub role: Role,
209    pub content: SurfaceContent,
210    pub owner: SurfaceOwner,
211    #[serde(default)]
212    pub placement: Placement,
213    pub state: SurfaceState,
214    /// Present only for `role == Float`.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub float: Option<FloatSpec>,
217}
218
219impl Surface {
220    /// Convenience constructor for an entry-backed surface.
221    pub fn entry(id: impl Into<SurfaceId>, role: Role, entry_id: impl Into<String>) -> Self {
222        Self {
223            id: id.into(),
224            role,
225            content: SurfaceContent::Entry {
226                id: entry_id.into(),
227                path: None,
228            },
229            owner: SurfaceOwner::Host,
230            placement: Placement::default(),
231            state: SurfaceState::Mounted,
232            float: if role == Role::Float {
233                Some(FloatSpec::default())
234            } else {
235                None
236            },
237        }
238    }
239
240    pub fn is_modal_float(&self) -> bool {
241        self.role == Role::Float && self.float.as_ref().is_some_and(|f| f.modal)
242    }
243}