use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::navbar::NavigationBarPatch;
use super::tabbar::TabBarPatch;
use crate::{LxApp, LxAppError, PageInstance, debug, warn};
use lingxia_platform::PlatformError;
use lingxia_platform::traits::ui::UIUpdate;
use lingxia_webview::WebViewController;
pub use lingxia_app_context::AppearancePreference;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ResolvedAppearance {
#[default]
Light,
Dark,
}
impl ResolvedAppearance {
pub const fn as_str(self) -> &'static str {
match self {
Self::Light => "light",
Self::Dark => "dark",
}
}
pub const fn is_dark(self) -> bool {
matches!(self, Self::Dark)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VisibilityPreference {
#[default]
Auto,
Hidden,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TabBarVisibilityPreference {
#[default]
Auto,
Visible,
Hidden,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TabBarPresentation {
#[default]
Standard,
Immersive,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum PatchField<T> {
#[default]
Missing,
Null,
Value(T),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ValuePatchField<T> {
#[default]
Missing,
Value(T),
}
impl<'de, T> Deserialize<'de> for ValuePatchField<T>
where
T: Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
T::deserialize(deserializer).map(Self::Value)
}
}
impl<'de, T> Deserialize<'de> for PatchField<T>
where
T: Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
if value.is_null() {
return Ok(Self::Null);
}
T::deserialize(value)
.map(Self::Value)
.map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PageChromeColor(u32);
impl PageChromeColor {
pub const fn from_rgba(rgba: u32) -> Self {
Self(rgba)
}
pub const fn rgba(self) -> u32 {
self.0
}
pub const fn argb(self) -> u32 {
self.0.rotate_right(8)
}
pub const fn alpha(self) -> u8 {
self.0 as u8
}
pub const fn is_opaque(self) -> bool {
self.alpha() == u8::MAX
}
pub fn parse(value: &str) -> Result<Self, String> {
let value = value.trim();
let hex = value
.strip_prefix('#')
.ok_or_else(|| "expected #RRGGBB or #RRGGBBAA".to_string())?;
let rgba = match hex.len() {
6 => u32::from_str_radix(hex, 16)
.map(|rgb| (rgb << 8) | 0xff)
.map_err(|_| "expected hexadecimal #RRGGBB".to_string())?,
8 => u32::from_str_radix(hex, 16)
.map_err(|_| "expected hexadecimal #RRGGBBAA".to_string())?,
_ => return Err("expected #RRGGBB or #RRGGBBAA".to_string()),
};
Ok(Self(rgba))
}
}
impl std::fmt::Display for PageChromeColor {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.is_opaque() {
write!(formatter, "#{:06X}", self.0 >> 8)
} else {
write!(formatter, "#{:08X}", self.0)
}
}
}
impl Serialize for PageChromeColor {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for PageChromeColor {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(&value).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PageChromeRect {
pub width: f64,
pub height: f64,
pub top: f64,
pub right: f64,
pub bottom: f64,
pub left: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct EffectivePageChromeLayout {
pub revision: u64,
pub top_inset: f64,
pub bottom_inset: f64,
pub capsule_rect: Option<PageChromeRect>,
pub capsule_inline_end_inset: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LxAppAppearanceState {
pub preference: AppearancePreference,
pub resolved: ResolvedAppearance,
pub revision: u64,
}
impl Default for LxAppAppearanceState {
fn default() -> Self {
Self {
preference: AppearancePreference::Auto,
resolved: ResolvedAppearance::Light,
revision: 0,
}
}
}
pub(crate) fn bootstrap_script(
layout: &EffectivePageChromeLayout,
appearance: ResolvedAppearance,
) -> String {
let layout = serde_json::to_string(layout).unwrap_or_else(|_| "{}".to_string());
let appearance = serde_json::to_string(&appearance).unwrap_or_else(|_| "\"light\"".to_string());
format!(
r#"(() => {{
const publish = (raw, scheme) => {{
const current = globalThis.__lingxiaPageChromeLayout;
if (current && raw.revision < current.revision) return;
const rect = raw.capsuleRect == null ? null : Object.freeze({{ ...raw.capsuleRect }});
const layout = Object.freeze({{ ...raw, capsuleRect: rect }});
const root = document.documentElement;
if (root) {{
root.style.setProperty('--lx-page-chrome-top-inset', `${{layout.topInset}}px`);
root.style.setProperty('--lx-page-chrome-bottom-inset', `${{layout.bottomInset}}px`);
root.style.setProperty('--lx-page-chrome-capsule-inline-end-inset', `${{layout.capsuleInlineEndInset}}px`);
root.style.colorScheme = scheme;
root.setAttribute('data-theme', scheme);
}}
globalThis.__lingxiaPageChromeLayout = layout;
globalThis.dispatchEvent(new CustomEvent('lxpagechromechange', {{ detail: layout }}));
}};
if (!globalThis.__lingxiaApplyPageChrome) {{
Object.defineProperty(globalThis, 'lxPageChrome', {{
configurable: false,
enumerable: true,
value: Object.freeze({{
get layout() {{ return globalThis.__lingxiaPageChromeLayout; }}
}})
}});
Object.defineProperty(globalThis, '__lingxiaApplyPageChrome', {{
configurable: false,
enumerable: false,
value: publish
}});
}}
globalThis.__lingxiaApplyPageChrome({layout}, {appearance});
}})();"#
)
}
fn publication_script(
layout: &EffectivePageChromeLayout,
appearance: ResolvedAppearance,
) -> String {
let layout = serde_json::to_string(layout).unwrap_or_else(|_| "{}".to_string());
format!(
"var f = globalThis.__lingxiaApplyPageChrome; if (f) f({layout}, {});",
serde_json::to_string(&appearance).unwrap_or_else(|_| "\"light\"".to_string())
)
}
impl LxApp {
pub fn appearance_state(&self) -> LxAppAppearanceState {
self.state
.lock()
.unwrap_or_else(|error| error.into_inner())
.appearance
}
pub(crate) fn next_page_chrome_revision(&self) -> u64 {
let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
state.page_chrome_revision = state.page_chrome_revision.saturating_add(1);
state.page_chrome_revision
}
pub(crate) fn publish_page_chrome(
&self,
page: &PageInstance,
revision: u64,
appearance: ResolvedAppearance,
) -> Result<(), LxAppError> {
let instance_id = page.instance_id_string();
let layout = {
let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
if state.page_chrome_revision != revision {
return Ok(());
}
let layout = state.page_chrome_layouts.entry(instance_id).or_default();
layout.revision = revision;
layout.clone()
};
if let Some(webview) = page.webview() {
webview
.exec_js(&publication_script(&layout, appearance))
.map_err(LxAppError::from)?;
}
Ok(())
}
pub(crate) async fn publish_realized_page_chrome(
&self,
page: &PageInstance,
revision: u64,
appearance: ResolvedAppearance,
) -> Result<(), LxAppError> {
let capsule_rect = if self.get_navbar_state(&page.path()).is_custom_navigation() {
self.runtime
.measure_page_chrome_capsule(self.appid.clone())
.await?
.map(|payload| {
serde_json::from_str::<PageChromeRect>(&payload)
.map_err(|error| LxAppError::Runtime(error.to_string()))
})
.transpose()?
} else {
None
};
let top_inset = self.full_chrome_drag_strip_inset(page);
let bottom_inset = self
.get_tabbar()
.filter(|tabbar| {
tabbar.presentation == TabBarPresentation::Immersive
&& tabbar.is_effectively_visible()
})
.map_or(0.0, |_| immersive_tabbar_inset());
let capsule_inline_end_inset = capsule_rect
.map(|rect| rect.width + capsule_trailing_inset())
.unwrap_or(0.0);
{
let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
if state.page_chrome_revision != revision {
return Ok(());
}
state.page_chrome_layouts.insert(
page.instance_id_string(),
EffectivePageChromeLayout {
revision,
top_inset,
bottom_inset,
capsule_rect,
capsule_inline_end_inset,
},
);
}
self.publish_page_chrome(page, revision, appearance)
}
async fn apply_page_chrome_commit(
&self,
page: &PageInstance,
revision: u64,
appearance: ResolvedAppearance,
) -> Result<(), LxAppError> {
match self
.runtime
.apply_page_chrome_revision(self.appid.clone(), revision)
.await
{
Ok(()) => {}
Err(PlatformError::PresenterUnavailable) => {
debug!("page chrome paint deferred until a presenter mounts")
.with_appid(self.appid.clone());
return Ok(());
}
Err(error) => return Err(error.into()),
}
self.publish_realized_page_chrome(page, revision, appearance)
.await
}
fn current_page_for_chrome(&self) -> Result<PageInstance, LxAppError> {
self.current_page().map_err(|error| match error {
LxAppError::WebView(_) => LxAppError::ResourceNotFound("active page".to_string()),
error => error,
})
}
pub async fn commit_navigation_bar(
&self,
page: PageInstance,
patch: NavigationBarPatch,
) -> Result<(), LxAppError> {
let _guard = self.page_chrome_mutation_lock.lock().await;
let changed = page
.get_navbar_state_mut(|state| patch.apply_transactionally(state))
.ok_or_else(|| LxAppError::ResourceNotFound("active page".to_string()))?
.map_err(LxAppError::InvalidParameter)?;
if !changed {
return Ok(());
}
let revision = self.next_page_chrome_revision();
let appearance = self.appearance_state().resolved;
self.apply_page_chrome_commit(&page, revision, appearance)
.await
}
pub async fn commit_tabbar(&self, patch: TabBarPatch) -> Result<(), LxAppError> {
let _guard = self.page_chrome_mutation_lock.lock().await;
let page = self.current_page_for_chrome()?;
let changed = self
.with_tabbar_mut(|tabbar| {
tabbar.apply_patch_transactionally(&patch, |value, path| {
self.resolve_accessible_path(value)
.map(|path| path.to_string_lossy().into_owned())
.map_err(|error| {
let reason = error
.detail()
.map(str::to_string)
.unwrap_or_else(|| error.to_string());
format!("{path}: {reason}")
})
})
})
.ok_or_else(|| LxAppError::ResourceNotFound("declared tabbar".to_string()))?
.map_err(LxAppError::InvalidParameter)?;
if !changed {
return Ok(());
}
let revision = self.next_page_chrome_revision();
let appearance = self.appearance_state().resolved;
self.apply_page_chrome_commit(&page, revision, appearance)
.await
}
pub(crate) fn adopt_host_appearance(&self) -> Option<(ResolvedAppearance, u64)> {
let original = self.appearance_state();
let resolved = resolve_appearance(original.preference);
if original.resolved == resolved {
return None;
}
let revision = self.next_page_chrome_revision();
{
let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
state.appearance = LxAppAppearanceState {
preference: original.preference,
resolved,
revision,
};
}
if let Err(error) = self
.runtime
.apply_lxapp_appearance(&self.appid, resolved.is_dark())
{
{
let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
restore_appearance_state(&mut state.appearance, original);
}
warn!("Failed to apply appearance: {error}").with_appid(self.appid.clone());
return None;
}
crate::appservice::event_bus::publish_app_event(
&self.appid,
crate::APPEARANCE_CHANGE_EVENT,
Some(format!(
"{{\"revision\":{revision},\"resolved\":\"{}\"}}",
resolved.as_str()
)),
);
Some((resolved, revision))
}
pub(crate) async fn refresh_appearance(&self) -> Result<(), LxAppError> {
let _guard = self.page_chrome_mutation_lock.lock().await;
let Some((resolved, revision)) = self.adopt_host_appearance() else {
return Ok(());
};
let current = self.current_page_for_chrome().ok();
if let Some(page) = ¤t
&& let Err(error) = self
.apply_page_chrome_commit(page, revision, resolved)
.await
{
warn!("Failed to repaint chrome for appearance: {error}")
.with_appid(self.appid.clone());
}
self.publish_appearance_to_background_pages(current.as_ref(), revision, resolved);
Ok(())
}
pub fn republish_page_scheme(&self, page: &PageInstance) {
let appearance = self.appearance_state().resolved;
let revision = self
.state
.lock()
.unwrap_or_else(|error| error.into_inner())
.page_chrome_revision;
let _ = self.publish_page_chrome(page, revision, appearance);
}
fn publish_appearance_to_background_pages(
&self,
current: Option<&PageInstance>,
revision: u64,
appearance: ResolvedAppearance,
) {
let current_id = current.map(PageInstance::instance_id_string);
let others: Vec<PageInstance> = {
let state = self.state.lock().unwrap_or_else(|error| error.into_inner());
let pages_by_id = state
.pages_by_id
.lock()
.unwrap_or_else(|error| error.into_inner());
pages_by_id
.values()
.filter(|page| current_id.as_deref() != Some(page.instance_id_string().as_str()))
.cloned()
.collect()
};
for page in others {
let _ = self.publish_page_chrome(&page, revision, appearance);
}
}
}
#[cfg(test)]
const fn rollback_revision(current: u64, failed: u64) -> u64 {
if current == failed {
failed.saturating_sub(1)
} else {
current
}
}
fn restore_appearance_state(current: &mut LxAppAppearanceState, original: LxAppAppearanceState) {
*current = original;
}
pub(crate) fn resolve_appearance(preference: AppearancePreference) -> ResolvedAppearance {
match preference {
AppearancePreference::Light => ResolvedAppearance::Light,
AppearancePreference::Dark => ResolvedAppearance::Dark,
AppearancePreference::Auto => {
if super::host_appearance::host_appearance_dark() {
ResolvedAppearance::Dark
} else {
ResolvedAppearance::Light
}
}
}
}
const fn immersive_tabbar_inset() -> f64 {
#[cfg(target_os = "android")]
{
return 64.0;
}
#[cfg(target_os = "ios")]
{
return 64.0;
}
#[cfg(target_env = "ohos")]
{
return 72.0;
}
#[allow(unreachable_code)]
0.0
}
const fn capsule_trailing_inset() -> f64 {
12.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_manifest_pin_ignores_the_product_scheme() {
assert_eq!(
resolve_appearance(AppearancePreference::Dark),
ResolvedAppearance::Dark
);
assert_eq!(
resolve_appearance(AppearancePreference::Light),
ResolvedAppearance::Light
);
}
#[test]
fn color_parses_css_order_alpha() {
let opaque = PageChromeColor::parse("#A1B2C3").unwrap();
assert_eq!(opaque.rgba(), 0xA1B2C3FF);
assert!(opaque.is_opaque());
assert_eq!(opaque.to_string(), "#A1B2C3");
let translucent = PageChromeColor::parse("#A1B2C380").unwrap();
assert_eq!(translucent.rgba(), 0xA1B2C380);
assert_eq!(translucent.alpha(), 0x80);
assert_eq!(translucent.to_string(), "#A1B2C380");
}
#[test]
fn appearance_rejects_host_global_system_spelling() {
assert_eq!("auto".parse(), Ok(AppearancePreference::Auto));
assert!("system".parse::<AppearancePreference>().is_err());
assert!("DARK".parse::<AppearancePreference>().is_err());
assert!(" dark ".parse::<AppearancePreference>().is_err());
}
#[test]
fn scripts_reject_stale_revisions_without_optional_chaining() {
let bootstrap = bootstrap_script(
&EffectivePageChromeLayout {
revision: 4,
..Default::default()
},
ResolvedAppearance::Dark,
);
assert!(bootstrap.contains("raw.revision < current.revision"));
let publication = publication_script(
&EffectivePageChromeLayout {
revision: 5,
..Default::default()
},
ResolvedAppearance::Light,
);
assert!(publication.contains("var f = globalThis.__lingxiaApplyPageChrome"));
assert!(!publication.contains("?."));
}
#[test]
fn rollback_republishes_the_restored_current_revision() {
assert_eq!(rollback_revision(5, 5), 4);
assert_eq!(rollback_revision(6, 5), 6);
assert_eq!(rollback_revision(0, 0), 0);
}
#[test]
fn appearance_rollback_restores_preference_resolution_and_revision() {
let original = LxAppAppearanceState {
preference: AppearancePreference::Auto,
resolved: ResolvedAppearance::Light,
revision: 4,
};
let mut current = LxAppAppearanceState {
preference: AppearancePreference::Dark,
resolved: ResolvedAppearance::Dark,
revision: 5,
};
restore_appearance_state(&mut current, original);
assert_eq!(current, original);
assert_eq!(rollback_revision(5, 5), current.revision);
}
}