Merge Luma launcher fork into monorepo
Move trampoline files (boot-hook, watcher, overlay) into trampoline/ subdirectory. Add upstream Luma v2.3.1 source at repo root with our additions: - Page::Ha (4th nav tab) with placeholder empty state - HA tab config schema and default entity mapping (ha-home/) - Nav keys renamed: "home" → "start", new "ha" tab between Apps and Settings - All state.rs/view.rs/main.rs/tests.rs updated for 4-tab navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc3ca0f3b9
commit
cbea5a322f
204 files changed
+25056
-65
No files matched your search
+403
@@ -0,0 +1,403 @@
|
||||
//! Bundled English, German, Spanish, Chinese, Brazilian Portuguese and Italian message catalog.
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::{collections::BTreeMap, sync::OnceLock};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
type Messages = BTreeMap<String, BTreeMap<String, String>>;
|
||||
fn messages() -> &'static Messages {
|
||||
static CATALOG: OnceLock<Messages> = OnceLock::new();
|
||||
CATALOG.get_or_init(|| {
|
||||
serde_json::from_str(include_str!("../assets/messages.json"))
|
||||
.expect("Bundled translations must be valid")
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Language {
|
||||
#[default]
|
||||
En,
|
||||
De,
|
||||
Zh,
|
||||
Es,
|
||||
Pt,
|
||||
It,
|
||||
}
|
||||
impl Language {
|
||||
pub const ALL: [Self; 6] = [Self::En, Self::De, Self::Zh, Self::Es, Self::Pt, Self::It];
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::En => "en",
|
||||
Self::De => "de",
|
||||
Self::Zh => "zh",
|
||||
Self::Es => "es",
|
||||
Self::Pt => "pt",
|
||||
Self::It => "it",
|
||||
}
|
||||
}
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::En => "English",
|
||||
Self::De => "Deutsch",
|
||||
Self::Zh => "简体中文",
|
||||
Self::Es => "Español",
|
||||
Self::Pt => "Português (Brasil)",
|
||||
Self::It => "Italiano",
|
||||
}
|
||||
}
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Self::En => 0,
|
||||
Self::De => 1,
|
||||
Self::Zh => 2,
|
||||
Self::Es => 3,
|
||||
Self::Pt => 4,
|
||||
Self::It => 5,
|
||||
}
|
||||
}
|
||||
pub fn t(self, key: &str) -> &str {
|
||||
messages()
|
||||
.get(key)
|
||||
.and_then(|entry| entry.get(self.code()).or_else(|| entry.get("en")))
|
||||
.map(String::as_str)
|
||||
.unwrap_or(key)
|
||||
}
|
||||
pub fn format(self, key: &str, values: &[(&str, &str)]) -> String {
|
||||
let mut value = self.t(key).to_owned();
|
||||
for (name, replacement) in values {
|
||||
value = value.replace(&format!("{{{name}}}"), replacement);
|
||||
}
|
||||
value
|
||||
}
|
||||
pub fn category(self, value: &str) -> &str {
|
||||
match value {
|
||||
"Streaming" => self.t("categoryStreaming"),
|
||||
"Music" => self.t("categoryMusic"),
|
||||
"Gaming" => self.t("categoryGaming"),
|
||||
"Utilities" => self.t("categoryUtilities"),
|
||||
_ => self.t("categoryUtilities"),
|
||||
}
|
||||
}
|
||||
pub fn wallpaper(self, id: &str) -> &str {
|
||||
self.t(match id {
|
||||
"dunes" => "monumentValley",
|
||||
"san-francisco" => "sanFrancisco",
|
||||
"northern-lights" => "northernLights",
|
||||
_ => id,
|
||||
})
|
||||
}
|
||||
pub fn greeting(self, hour: u8, name: &str) -> String {
|
||||
let key = match hour {
|
||||
0..=4 => "greetingNight",
|
||||
5..=11 => "morning",
|
||||
12..=17 => "afternoon",
|
||||
_ => "evening",
|
||||
};
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
self.t(key).to_owned()
|
||||
} else {
|
||||
self.format(&format!("{key}Named"), &[("name", name)])
|
||||
}
|
||||
}
|
||||
pub fn date(self, now: OffsetDateTime) -> String {
|
||||
let weekday = now.weekday().number_days_from_monday() as usize;
|
||||
let month = now.month() as usize - 1;
|
||||
match self {
|
||||
Self::En => format!(
|
||||
"{}, {} {}",
|
||||
[
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday"
|
||||
][weekday],
|
||||
[
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December"
|
||||
][month],
|
||||
now.day()
|
||||
)
|
||||
.to_uppercase(),
|
||||
Self::De => format!(
|
||||
"{}, {}. {}",
|
||||
[
|
||||
"Montag",
|
||||
"Dienstag",
|
||||
"Mittwoch",
|
||||
"Donnerstag",
|
||||
"Freitag",
|
||||
"Samstag",
|
||||
"Sonntag"
|
||||
][weekday],
|
||||
now.day(),
|
||||
[
|
||||
"Januar",
|
||||
"Februar",
|
||||
"März",
|
||||
"April",
|
||||
"Mai",
|
||||
"Juni",
|
||||
"Juli",
|
||||
"August",
|
||||
"September",
|
||||
"Oktober",
|
||||
"November",
|
||||
"Dezember"
|
||||
][month]
|
||||
)
|
||||
.to_uppercase(),
|
||||
Self::Zh => format!(
|
||||
"{}月{}日星期{}",
|
||||
now.month() as u8,
|
||||
now.day(),
|
||||
["一", "二", "三", "四", "五", "六", "日"][weekday]
|
||||
),
|
||||
Self::Es => format!(
|
||||
"{}, {} de {}",
|
||||
[
|
||||
"lunes",
|
||||
"martes",
|
||||
"miércoles",
|
||||
"jueves",
|
||||
"viernes",
|
||||
"sábado",
|
||||
"domingo"
|
||||
][weekday],
|
||||
now.day(),
|
||||
[
|
||||
"enero",
|
||||
"febrero",
|
||||
"marzo",
|
||||
"abril",
|
||||
"mayo",
|
||||
"junio",
|
||||
"julio",
|
||||
"agosto",
|
||||
"septiembre",
|
||||
"octubre",
|
||||
"noviembre",
|
||||
"diciembre"
|
||||
][month]
|
||||
)
|
||||
.to_uppercase(),
|
||||
Self::Pt => format!(
|
||||
"{}, {} de {}",
|
||||
[
|
||||
"segunda-feira",
|
||||
"terça-feira",
|
||||
"quarta-feira",
|
||||
"quinta-feira",
|
||||
"sexta-feira",
|
||||
"sábado",
|
||||
"domingo"
|
||||
][weekday],
|
||||
now.day(),
|
||||
[
|
||||
"janeiro",
|
||||
"fevereiro",
|
||||
"março",
|
||||
"abril",
|
||||
"maio",
|
||||
"junho",
|
||||
"julho",
|
||||
"agosto",
|
||||
"setembro",
|
||||
"outubro",
|
||||
"novembro",
|
||||
"dezembro"
|
||||
][month]
|
||||
)
|
||||
.to_uppercase(),
|
||||
Self::It => format!(
|
||||
"{} {} {}",
|
||||
[
|
||||
"lunedì",
|
||||
"martedì",
|
||||
"mercoledì",
|
||||
"giovedì",
|
||||
"venerdì",
|
||||
"sabato",
|
||||
"domenica"
|
||||
][weekday],
|
||||
now.day(),
|
||||
[
|
||||
"gennaio",
|
||||
"febbraio",
|
||||
"marzo",
|
||||
"aprile",
|
||||
"maggio",
|
||||
"giugno",
|
||||
"luglio",
|
||||
"agosto",
|
||||
"settembre",
|
||||
"ottobre",
|
||||
"novembre",
|
||||
"dicembre"
|
||||
][month]
|
||||
)
|
||||
.to_uppercase(),
|
||||
}
|
||||
}
|
||||
pub fn clock(self, now: OffsetDateTime, hour24: bool, seconds: bool) -> String {
|
||||
let base = self.clock_base(now, hour24);
|
||||
if !seconds {
|
||||
return base;
|
||||
}
|
||||
let tail = format!(":{:02}", now.second());
|
||||
match base.find(' ') {
|
||||
Some(space) => format!("{}{tail}{}", &base[..space], &base[space..]),
|
||||
None => format!("{base}{tail}"),
|
||||
}
|
||||
}
|
||||
fn clock_base(self, now: OffsetDateTime, hour24: bool) -> String {
|
||||
if hour24 {
|
||||
return if self == Self::En {
|
||||
format!("{:02}:{:02}", now.hour(), now.minute())
|
||||
} else {
|
||||
format!("{}:{:02}", now.hour(), now.minute())
|
||||
};
|
||||
}
|
||||
let hour = match now.hour() % 12 {
|
||||
0 => 12,
|
||||
value => value,
|
||||
};
|
||||
if self == Self::Zh {
|
||||
format!(
|
||||
"{}{hour}:{:02}",
|
||||
if now.hour() >= 12 { "下午" } else { "上午" },
|
||||
now.minute()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{hour}:{:02} {}",
|
||||
now.minute(),
|
||||
if now.hour() >= 12 { "PM" } else { "AM" }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn deserialize_language<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Language, D::Error> {
|
||||
Ok(
|
||||
match serde_json::Value::deserialize(deserializer)?.as_str() {
|
||||
Some("de") => Language::De,
|
||||
Some("zh") => Language::Zh,
|
||||
Some("es") => Language::Es,
|
||||
Some("pt") => Language::Pt,
|
||||
Some("it") => Language::It,
|
||||
_ => Language::En,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn every_message_has_all_languages_and_no_empty_values() {
|
||||
for (key, entry) in messages() {
|
||||
for language in Language::ALL {
|
||||
assert!(
|
||||
!entry
|
||||
.get(language.code())
|
||||
.unwrap_or_else(|| panic!("Missing {key} / {}", language.code()))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn every_translation_preserves_the_english_message_placeholders() {
|
||||
fn placeholders(message: &str) -> std::collections::BTreeSet<&str> {
|
||||
message
|
||||
.split('{')
|
||||
.skip(1)
|
||||
.map(|part| {
|
||||
part.split_once('}')
|
||||
.expect("Unclosed message placeholder")
|
||||
.0
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
for (key, entry) in messages() {
|
||||
let expected = placeholders(&entry["en"]);
|
||||
for language in Language::ALL {
|
||||
assert_eq!(
|
||||
placeholders(&entry[language.code()]),
|
||||
expected,
|
||||
"Mismatched placeholders in {key} / {}",
|
||||
language.code()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn localization_keeps_user_provided_names_intact() {
|
||||
assert_eq!(
|
||||
Language::De.format("favoriteAdded", &[("app", "Netflix")]),
|
||||
"Netflix zu Favoriten hinzugefügt."
|
||||
);
|
||||
assert_eq!(
|
||||
Language::Es.format("favoriteAdded", &[("app", "Netflix")]),
|
||||
"Netflix añadida a favoritos."
|
||||
);
|
||||
assert_eq!(
|
||||
Language::Pt.format("favoriteAdded", &[("app", "Netflix")]),
|
||||
"Netflix adicionado aos favoritos."
|
||||
);
|
||||
assert_eq!(
|
||||
Language::Zh.format("hidden", &[("count", "3")]),
|
||||
"已隐藏(3)"
|
||||
);
|
||||
assert_eq!(Language::De.category("Gaming"), "Spiele");
|
||||
assert_eq!(Language::En.greeting(15, ""), "Good afternoon.");
|
||||
assert_eq!(Language::En.greeting(15, " Ana "), "Good afternoon, Ana.");
|
||||
assert_eq!(Language::Es.greeting(2, "Ana"), "¿Aún despiertos, Ana?");
|
||||
assert_eq!(Language::Zh.greeting(20, "小明"), "小明,晚上好。");
|
||||
}
|
||||
#[test]
|
||||
fn clock_and_date_follow_the_selected_locale() {
|
||||
let now = OffsetDateTime::parse(
|
||||
"2026-10-21T20:41:00+02:00",
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(Language::De.date(now), "MITTWOCH, 21. OKTOBER");
|
||||
assert_eq!(Language::Es.date(now), "MIÉRCOLES, 21 DE OCTUBRE");
|
||||
assert_eq!(Language::Pt.date(now), "QUARTA-FEIRA, 21 DE OUTUBRO");
|
||||
assert_eq!(Language::Zh.date(now), "10月21日星期三");
|
||||
assert_eq!(Language::Zh.clock(now, false, false), "下午8:41");
|
||||
assert_eq!(Language::Zh.clock(now, false, true), "下午8:41:00");
|
||||
assert_eq!(Language::En.clock(now, true, false), "20:41");
|
||||
assert_eq!(Language::En.clock(now, true, true), "20:41:00");
|
||||
assert_eq!(Language::En.clock(now, false, true), "8:41:00 PM");
|
||||
}
|
||||
#[test]
|
||||
fn portuguese_dates_include_accented_names_and_handle_leap_days() {
|
||||
for (date, expected) in [
|
||||
("2024-02-29T00:05:00-03:00", "QUINTA-FEIRA, 29 DE FEVEREIRO"),
|
||||
("2026-03-03T12:00:00-03:00", "TERÇA-FEIRA, 3 DE MARÇO"),
|
||||
("2026-10-24T20:41:00-03:00", "SÁBADO, 24 DE OUTUBRO"),
|
||||
] {
|
||||
let now = OffsetDateTime::parse(date, &time::format_description::well_known::Rfc3339)
|
||||
.unwrap();
|
||||
assert_eq!(Language::Pt.date(now), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user