Skip to main content

ferroui_hot_reload/
styles.rs

1//! Ownership-aware replacement of an explicitly registered style contribution.
2use crate::{PreparedReload, ReloadError, ReloadTarget};
3use ferroui_base::{
4    styling::{style_ptr_eq, IStyle, Styles},
5    Ref, WeakRef,
6};
7use ferroui_markup_xaml::{
8    source_overlay::Sources, FromXamlObject, RuntimeXamlLoaderConfiguration,
9    RuntimeXamlLoaderDocument,
10};
11use std::{
12    cell::RefCell,
13    rc::{Rc, Weak},
14};
15
16/// Replaces one style or nested Styles contribution, retaining its container and
17/// application-owned siblings. Native style removal detaches the previous frames;
18/// addition invalidates styling for the owner and its logical descendants.
19/// Register static-resource consumers as dependencies of this document.
20/// Applications caching the replaced style need an explicit refresh adapter.
21pub struct StyleReloadTarget {
22    container: WeakRef<Styles>,
23    current: Rc<RefCell<Weak<dyn IStyle>>>,
24    uri: String,
25    dependencies: Vec<String>,
26    configuration: RuntimeXamlLoaderConfiguration,
27}
28impl StyleReloadTarget {
29    /// Declares resources/styles outside the document's inline includes, such as
30    /// application resources consumed through StaticResource. Their revisions are
31    /// prepared and committed in the same transaction as this target.
32    pub fn with_dependencies(mut self, dependencies: impl IntoIterator<Item = String>) -> Self {
33        self.dependencies = dependencies
34            .into_iter()
35            .map(|uri| crate::document_key(&uri))
36            .collect();
37        self
38    }
39
40    pub fn contribution(
41        container: &Ref<Styles>,
42        index: usize,
43        uri: String,
44        configuration: RuntimeXamlLoaderConfiguration,
45    ) -> Result<Self, ReloadError> {
46        let provider = container
47            .snapshot()
48            .get(index)
49            .cloned()
50            .ok_or_else(|| ReloadError::Failed("Invalid style contribution index".into()))?;
51        Ok(Self {
52            container: container.downgrade(),
53            current: Rc::new(RefCell::new(Rc::downgrade(&provider))),
54            uri: crate::document_key(&uri),
55            dependencies: Vec::new(),
56            configuration,
57        })
58    }
59    /// Discovers direct style includes without evaluating their markup.
60    pub fn discover(
61        container: &Ref<Styles>,
62        configuration: RuntimeXamlLoaderConfiguration,
63    ) -> Result<Vec<Self>, ReloadError> {
64        container
65            .snapshot()
66            .iter()
67            .enumerate()
68            .filter_map(|(index, style)| {
69                style
70                    .as_resource_provider()?
71                    .development_source()
72                    .map(|uri| (index, uri))
73            })
74            .map(|(index, uri)| Self::contribution(container, index, uri, configuration.clone()))
75            .collect()
76    }
77
78    pub fn is_alive(&self) -> bool {
79        self.container.upgrade().is_some()
80    }
81    pub(crate) fn tracks(&self, style: &Rc<dyn IStyle>) -> bool {
82        self.current
83            .borrow()
84            .upgrade()
85            .is_some_and(|current| style_ptr_eq(&current, style))
86    }
87    pub(crate) fn is_attached(&self) -> bool {
88        self.container.upgrade().is_some_and(|container| {
89            container.snapshot().iter().any(|style| self.tracks(style))
90        })
91    }
92}
93struct Change {
94    container: Ref<Styles>,
95    index: usize,
96    old: Rc<dyn IStyle>,
97    new: Rc<dyn IStyle>,
98    current: Rc<RefCell<Weak<dyn IStyle>>>,
99    started: bool,
100}
101impl PreparedReload for Change {
102    fn commit(&mut self) -> Result<(), ReloadError> {
103        let entries = self.container.snapshot();
104        if entries
105            .get(self.index)
106            .is_none_or(|entry| !style_ptr_eq(entry, &self.old))
107        {
108            return Err(ReloadError::RestartRequired(
109                "Owned style contribution changed after preparation".into(),
110            ));
111        }
112        self.started = true;
113        // Replace transfers resource ownership and detaches/invalidates style frames.
114        self.container.set(self.index, self.new.clone());
115        Ok(())
116    }
117    fn rollback(&mut self) {
118        if self.started {
119            // Styles::set removes before inserting. An observer may panic after
120            // removal, so rollback must not overwrite the next application style.
121            let entries = self.container.snapshot();
122            if let Some(index) = entries
123                .iter()
124                .position(|entry| style_ptr_eq(entry, &self.new))
125            {
126                self.container.set(index, self.old.clone());
127            } else if !entries.iter().any(|entry| style_ptr_eq(entry, &self.old)) {
128                self.container
129                    .insert(self.index.min(entries.len()), self.old.clone());
130            }
131        }
132    }
133    fn finish(&mut self) {
134        *self.current.borrow_mut() = Rc::downgrade(&self.new);
135    }
136}
137impl ReloadTarget for StyleReloadTarget {
138    fn documents(&self) -> Vec<String> {
139        let mut documents = self.dependencies.clone();
140        documents.push(self.uri.clone());
141        documents
142    }
143    fn prepare(&self, sources: Rc<Sources>) -> Result<Box<dyn PreparedReload>, ReloadError> {
144        self.prepare_change(sources)
145            .map(|change| Box::new(change) as Box<dyn PreparedReload>)
146    }
147    fn resource_preparation(
148        self: Rc<Self>,
149        sources: Rc<Sources>,
150    ) -> Result<Option<crate::ResourcePreparation>, ReloadError> {
151        let old = self.current.borrow().upgrade().ok_or_else(|| {
152            ReloadError::RestartRequired("Owned resource/style contribution was removed".into())
153        })?;
154        let original: Rc<dyn ferroui_base::controls::IResourceNode> = old;
155        Ok(Some(crate::ResourcePreparation::new(original, move || {
156            let change = self.prepare_change(sources.clone())?;
157            let replacement: Rc<dyn ferroui_base::controls::IResourceNode> = change.new.clone();
158            Ok((replacement, Box::new(change)))
159        })))
160    }
161}
162impl StyleReloadTarget {
163    fn prepare_change(&self, sources: Rc<Sources>) -> Result<Change, ReloadError> {
164        let container = self
165            .container
166            .upgrade()
167            .ok_or_else(|| ReloadError::Failed("Style container closed".into()))?;
168        let old = self.current.borrow().upgrade().ok_or_else(|| {
169            ReloadError::RestartRequired("Owned style contribution was removed".into())
170        })?;
171        let entries = container.snapshot();
172        let mut matches = entries
173            .iter()
174            .enumerate()
175            .filter(|(_, entry)| style_ptr_eq(entry, &old));
176        let index = matches.next().map(|(index, _)| index).ok_or_else(|| {
177            ReloadError::RestartRequired("Owned style contribution was replaced".into())
178        })?;
179        if matches.next().is_some() {
180            return Err(ReloadError::RestartRequired(
181                "Ambiguous style contribution ownership".into(),
182            ));
183        }
184        let source = sources
185            .get(&self.uri)
186            .ok_or_else(|| ReloadError::Failed("Missing style source".into()))?;
187        let xml = roxmltree::Document::parse(source)
188            .map_err(|error| ReloadError::Failed(error.to_string()))?;
189        let root = xml.root_element();
190        if !matches!(root.tag_name().name(), "Styles" | "Style")
191            || root.tag_name().namespace() != Some("https://github.com/ferroui")
192            || crate::DocumentContract::parse(source)?.class.is_some()
193        {
194            return Err(ReloadError::RestartRequired(
195                "Custom style roots require an explicit adapter".into(),
196            ));
197        }
198        let uri = ferroui_base::utilities::Uri::try_create(
199            &self.uri,
200            ferroui_base::utilities::UriKind::Absolute,
201        )
202        .ok_or_else(|| ReloadError::Failed("Invalid style URI".into()))?;
203        let candidate = ferroui_markup_xaml_loader::FerroRuntimeXamlLoader::load_document(
204            RuntimeXamlLoaderDocument::with_base_uri(Some(uri), source),
205            Some(self.configuration.clone()),
206        )
207        .map_err(|error| ReloadError::Failed(error.to_string()))?;
208        let new = Rc::<dyn IStyle>::from_xaml_object(&candidate)
209            .ok_or_else(|| ReloadError::Failed("Expected style".into()))?;
210        Ok(Change {
211            container,
212            index,
213            old,
214            new,
215            current: self.current.clone(),
216            started: false,
217        })
218    }
219}