diff --git a/Cargo.lock b/Cargo.lock index c8d590c..3f92bb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", "getrandom 0.3.4", "once_cell", "version_check", @@ -792,6 +793,15 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.4.7" @@ -998,6 +1008,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "static_assertions", + "zmij", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1019,6 +1042,26 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1872,6 +1915,7 @@ dependencies = [ "jiff", "kdl", "regex", + "rhai", "schemars", "serde", "serde_json", @@ -4343,6 +4387,15 @@ dependencies = [ "libc", ] +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +dependencies = [ + "spin", +] + [[package]] name = "nom" version = "7.1.3" @@ -4698,6 +4751,9 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -5566,6 +5622,36 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rhai" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3925b784c0bee6542637e425ed7511a63257bf11b7ecf51ffd845e2d24d9163" +dependencies = [ + "ahash", + "bitflags 2.13.2", + "compact_str", + "no-std-compat", + "num-traits", + "once_cell", + "rhai_codegen", + "smallvec", + "smartstring", + "thin-vec", + "web-time", +] + +[[package]] +name = "rhai_codegen" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d9661edb4a4b8145b017582d634c5eb88ff1a5284a5aa701f83a28f0657a826" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ring" version = "0.17.14" @@ -6018,6 +6104,17 @@ version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + [[package]] name = "smithay" version = "0.7.0" @@ -6181,6 +6278,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" @@ -6375,6 +6478,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thin-vec" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a4b9ba8738cb4a4f399d37e266becfd475e75eb73425b87a05a2f2039ba63e" + [[package]] name = "thiserror" version = "1.0.69" @@ -6454,6 +6563,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny-skia" version = "0.11.4" diff --git a/crates/ferese-config/Cargo.toml b/crates/ferese-config/Cargo.toml index 10d373f..2fa9c05 100644 --- a/crates/ferese-config/Cargo.toml +++ b/crates/ferese-config/Cargo.toml @@ -15,6 +15,7 @@ regex = "1.12" ferese-panel = { path = "../ferese-panel" } ferese-theme-model = { path = "../ferese-theme-model" } jiff.workspace = true +rhai = { version = "1.27", features = ["sync"] } kdl = "6.7.1" serde.workspace = true serde_json.workspace = true diff --git a/crates/ferese-config/data/README.md b/crates/ferese-config/data/README.md new file mode 100644 index 0000000..f284230 --- /dev/null +++ b/crates/ferese-config/data/README.md @@ -0,0 +1,15 @@ +# Japanese holiday function and official reference + +Source: Cabinet Office, [国民の祝日について](https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html). + +- CSV: https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv +- Retrieved: 2026-10-28 +- Original SHA-256: `cec37a743c96995cdb9cb52b685c9003634682a9b0e1a640a6b9b96881fe964a` +- Coverage: 1955-01-01 through 2027-11-23 +- Conversion: CP932 to UTF-8; dates normalized to YYYY-MM-DD; header removed. + +`japanese-holidays.rhai` is the editable, built-in runtime data source. It calculates 1955–2099 Japanese holidays, including historical rules and one-off exceptions. Unpublished years are estimates; equinox holidays become official after publication in the government gazette. See [NAOJ](https://www.nao.ac.jp/faq/a0301.html). + +The CSV is an independent regression reference, including substitute holidays and holidays between national holidays. The full function output is checked against every date and name for 1955–2027. To refresh the reference, download the official CSV, apply the same conversion, update exceptions in the function as needed, and rerun calendar tests. + +Source terms: [Cabinet Office website terms](https://www.cao.go.jp/notice/rule.html). diff --git a/crates/ferese-config/data/japanese-holidays.rhai b/crates/ferese-config/data/japanese-holidays.rhai new file mode 100644 index 0000000..dbee9d2 --- /dev/null +++ b/crates/ferese-config/data/japanese-holidays.rhai @@ -0,0 +1,82 @@ +// 日本祝日计算函数。返回 YYYY-MM-DD → 节假日名称的映射。 +// weekday(date) 的周日为 0;date(year, month, day) 与 add_days(date, n) 生成日期。 +// 规则适用 1955–2099 年;未公布年份的春秋分日为近似计算。 +fn monday(year, month, nth) { + let first = date(year, month, 1); + 1 + (8 - weekday(first)) % 7 + 7 * (nth - 1) +} + +fn japanese_holidays(year) { + let h = #{}; + if year < 1955 || year > 2099 { return h; } + h[date(year, 1, 1)] = "元日"; + h[date(year, 1, if year >= 2000 { monday(year, 1, 2) } else { 15 })] = "成人の日"; + if year >= 1967 { h[date(year, 2, 11)] = "建国記念の日"; } + if year >= 2020 { h[date(year, 2, 23)] = "天皇誕生日"; } + if year >= 1989 && year <= 2018 { h[date(year, 12, 23)] = "天皇誕生日"; } + h[date(year, 4, 29)] = if year >= 2007 { "昭和の日" } + else if year >= 1989 { "みどりの日" } else { "天皇誕生日" }; + + // 春分・秋分近似式。官报公布前的将来日期仅为推算。 + let spring_base = if year < 1980 { 20.8357 } else { 20.8431 }; + let autumn_base = if year < 1980 { 23.2588 } else { 23.2488 }; + let leap_base = if year < 1980 { 1983 } else { 1980 }; + let shift = 0.242194 * (year - 1980) - (year - leap_base) / 4; + h[date(year, 3, floor(spring_base + shift))] = "春分の日"; + h[date(year, 9, floor(autumn_base + shift))] = "秋分の日"; + h[date(year, 5, 3)] = "憲法記念日"; + if year >= 2007 { h[date(year, 5, 4)] = "みどりの日"; } + h[date(year, 5, 5)] = "こどもの日"; + h[date(year, 11, 3)] = "文化の日"; + h[date(year, 11, 23)] = "勤労感謝の日"; + + if year >= 1966 { + h[date(year, 9, if year >= 2003 { monday(year, 9, 3) } else { 15 })] = "敬老の日"; + let sport = if year == 2020 { date(year, 7, 24) } + else if year == 2021 { date(year, 7, 23) } + else { date(year, 10, if year >= 2000 { monday(year, 10, 2) } else { 10 }) }; + h[sport] = if year >= 2020 { "スポーツの日" } + else if year == 2019 { "体育の日(スポーツの日)" } else { "体育の日" }; + } + if year >= 1996 { + let sea = if year == 2020 { date(year, 7, 23) } + else if year == 2021 { date(year, 7, 22) } + else { date(year, 7, if year >= 2003 { monday(year, 7, 3) } else { 20 }) }; + h[sea] = "海の日"; + } + if year >= 2016 { + h[date(year, 8, if year == 2020 { 10 } else if year == 2021 { 8 } else { 11 })] = "山の日"; + } + // 即位・成婚等の特例。 + if year == 1959 { h[date(year, 4, 10)] = "結婚の儀"; } + if year == 1989 { h[date(year, 2, 24)] = "大喪の礼"; } + if year == 1990 { h[date(year, 11, 12)] = "即位礼正殿の儀"; } + if year == 1993 { h[date(year, 6, 9)] = "結婚の儀"; } + if year == 2019 { + h[date(year, 5, 1)] = "休日(祝日扱い)"; + h[date(year, 10, 22)] = "休日(祝日扱い)"; + } + let national = h; + let first = date(year, 1, 1); + // 祝日に挟まれた日:1986 年から適用、日曜日は対象外。 + if year >= 1986 { + for day in 0..days_in_year(year) { + let d = add_days(first, day); + if weekday(d) != 0 && !h.contains(d) + && national.contains(add_days(d, -1)) && national.contains(add_days(d, 1)) { + h[d] = "休日"; + } + } + } + // 振替休日:1973 年導入、2007 年以降は次の祝日でない日まで繰り越す。 + for d in national.keys() { + if d >= "1973-04-12" && weekday(d) == 0 { + let substitute = add_days(d, 1); + if year >= 2007 { + while h.contains(substitute) { substitute = add_days(substitute, 1); } + } + if !h.contains(substitute) { h[substitute] = "休日"; } + } + } + h +} diff --git a/crates/ferese-config/data/jp-holidays.csv b/crates/ferese-config/data/jp-holidays.csv new file mode 100644 index 0000000..d24607f --- /dev/null +++ b/crates/ferese-config/data/jp-holidays.csv @@ -0,0 +1,1067 @@ +1955-01-01,元日 +1955-01-15,成人の日 +1955-03-21,春分の日 +1955-04-29,天皇誕生日 +1955-05-03,憲法記念日 +1955-05-05,こどもの日 +1955-09-24,秋分の日 +1955-11-03,文化の日 +1955-11-23,勤労感謝の日 +1956-01-01,元日 +1956-01-15,成人の日 +1956-03-21,春分の日 +1956-04-29,天皇誕生日 +1956-05-03,憲法記念日 +1956-05-05,こどもの日 +1956-09-23,秋分の日 +1956-11-03,文化の日 +1956-11-23,勤労感謝の日 +1957-01-01,元日 +1957-01-15,成人の日 +1957-03-21,春分の日 +1957-04-29,天皇誕生日 +1957-05-03,憲法記念日 +1957-05-05,こどもの日 +1957-09-23,秋分の日 +1957-11-03,文化の日 +1957-11-23,勤労感謝の日 +1958-01-01,元日 +1958-01-15,成人の日 +1958-03-21,春分の日 +1958-04-29,天皇誕生日 +1958-05-03,憲法記念日 +1958-05-05,こどもの日 +1958-09-23,秋分の日 +1958-11-03,文化の日 +1958-11-23,勤労感謝の日 +1959-01-01,元日 +1959-01-15,成人の日 +1959-03-21,春分の日 +1959-04-10,結婚の儀 +1959-04-29,天皇誕生日 +1959-05-03,憲法記念日 +1959-05-05,こどもの日 +1959-09-24,秋分の日 +1959-11-03,文化の日 +1959-11-23,勤労感謝の日 +1960-01-01,元日 +1960-01-15,成人の日 +1960-03-20,春分の日 +1960-04-29,天皇誕生日 +1960-05-03,憲法記念日 +1960-05-05,こどもの日 +1960-09-23,秋分の日 +1960-11-03,文化の日 +1960-11-23,勤労感謝の日 +1961-01-01,元日 +1961-01-15,成人の日 +1961-03-21,春分の日 +1961-04-29,天皇誕生日 +1961-05-03,憲法記念日 +1961-05-05,こどもの日 +1961-09-23,秋分の日 +1961-11-03,文化の日 +1961-11-23,勤労感謝の日 +1962-01-01,元日 +1962-01-15,成人の日 +1962-03-21,春分の日 +1962-04-29,天皇誕生日 +1962-05-03,憲法記念日 +1962-05-05,こどもの日 +1962-09-23,秋分の日 +1962-11-03,文化の日 +1962-11-23,勤労感謝の日 +1963-01-01,元日 +1963-01-15,成人の日 +1963-03-21,春分の日 +1963-04-29,天皇誕生日 +1963-05-03,憲法記念日 +1963-05-05,こどもの日 +1963-09-24,秋分の日 +1963-11-03,文化の日 +1963-11-23,勤労感謝の日 +1964-01-01,元日 +1964-01-15,成人の日 +1964-03-20,春分の日 +1964-04-29,天皇誕生日 +1964-05-03,憲法記念日 +1964-05-05,こどもの日 +1964-09-23,秋分の日 +1964-11-03,文化の日 +1964-11-23,勤労感謝の日 +1965-01-01,元日 +1965-01-15,成人の日 +1965-03-21,春分の日 +1965-04-29,天皇誕生日 +1965-05-03,憲法記念日 +1965-05-05,こどもの日 +1965-09-23,秋分の日 +1965-11-03,文化の日 +1965-11-23,勤労感謝の日 +1966-01-01,元日 +1966-01-15,成人の日 +1966-03-21,春分の日 +1966-04-29,天皇誕生日 +1966-05-03,憲法記念日 +1966-05-05,こどもの日 +1966-09-15,敬老の日 +1966-09-23,秋分の日 +1966-10-10,体育の日 +1966-11-03,文化の日 +1966-11-23,勤労感謝の日 +1967-01-01,元日 +1967-01-15,成人の日 +1967-02-11,建国記念の日 +1967-03-21,春分の日 +1967-04-29,天皇誕生日 +1967-05-03,憲法記念日 +1967-05-05,こどもの日 +1967-09-15,敬老の日 +1967-09-24,秋分の日 +1967-10-10,体育の日 +1967-11-03,文化の日 +1967-11-23,勤労感謝の日 +1968-01-01,元日 +1968-01-15,成人の日 +1968-02-11,建国記念の日 +1968-03-20,春分の日 +1968-04-29,天皇誕生日 +1968-05-03,憲法記念日 +1968-05-05,こどもの日 +1968-09-15,敬老の日 +1968-09-23,秋分の日 +1968-10-10,体育の日 +1968-11-03,文化の日 +1968-11-23,勤労感謝の日 +1969-01-01,元日 +1969-01-15,成人の日 +1969-02-11,建国記念の日 +1969-03-21,春分の日 +1969-04-29,天皇誕生日 +1969-05-03,憲法記念日 +1969-05-05,こどもの日 +1969-09-15,敬老の日 +1969-09-23,秋分の日 +1969-10-10,体育の日 +1969-11-03,文化の日 +1969-11-23,勤労感謝の日 +1970-01-01,元日 +1970-01-15,成人の日 +1970-02-11,建国記念の日 +1970-03-21,春分の日 +1970-04-29,天皇誕生日 +1970-05-03,憲法記念日 +1970-05-05,こどもの日 +1970-09-15,敬老の日 +1970-09-23,秋分の日 +1970-10-10,体育の日 +1970-11-03,文化の日 +1970-11-23,勤労感謝の日 +1971-01-01,元日 +1971-01-15,成人の日 +1971-02-11,建国記念の日 +1971-03-21,春分の日 +1971-04-29,天皇誕生日 +1971-05-03,憲法記念日 +1971-05-05,こどもの日 +1971-09-15,敬老の日 +1971-09-24,秋分の日 +1971-10-10,体育の日 +1971-11-03,文化の日 +1971-11-23,勤労感謝の日 +1972-01-01,元日 +1972-01-15,成人の日 +1972-02-11,建国記念の日 +1972-03-20,春分の日 +1972-04-29,天皇誕生日 +1972-05-03,憲法記念日 +1972-05-05,こどもの日 +1972-09-15,敬老の日 +1972-09-23,秋分の日 +1972-10-10,体育の日 +1972-11-03,文化の日 +1972-11-23,勤労感謝の日 +1973-01-01,元日 +1973-01-15,成人の日 +1973-02-11,建国記念の日 +1973-03-21,春分の日 +1973-04-29,天皇誕生日 +1973-04-30,休日 +1973-05-03,憲法記念日 +1973-05-05,こどもの日 +1973-09-15,敬老の日 +1973-09-23,秋分の日 +1973-09-24,休日 +1973-10-10,体育の日 +1973-11-03,文化の日 +1973-11-23,勤労感謝の日 +1974-01-01,元日 +1974-01-15,成人の日 +1974-02-11,建国記念の日 +1974-03-21,春分の日 +1974-04-29,天皇誕生日 +1974-05-03,憲法記念日 +1974-05-05,こどもの日 +1974-05-06,休日 +1974-09-15,敬老の日 +1974-09-16,休日 +1974-09-23,秋分の日 +1974-10-10,体育の日 +1974-11-03,文化の日 +1974-11-04,休日 +1974-11-23,勤労感謝の日 +1975-01-01,元日 +1975-01-15,成人の日 +1975-02-11,建国記念の日 +1975-03-21,春分の日 +1975-04-29,天皇誕生日 +1975-05-03,憲法記念日 +1975-05-05,こどもの日 +1975-09-15,敬老の日 +1975-09-24,秋分の日 +1975-10-10,体育の日 +1975-11-03,文化の日 +1975-11-23,勤労感謝の日 +1975-11-24,休日 +1976-01-01,元日 +1976-01-15,成人の日 +1976-02-11,建国記念の日 +1976-03-20,春分の日 +1976-04-29,天皇誕生日 +1976-05-03,憲法記念日 +1976-05-05,こどもの日 +1976-09-15,敬老の日 +1976-09-23,秋分の日 +1976-10-10,体育の日 +1976-10-11,休日 +1976-11-03,文化の日 +1976-11-23,勤労感謝の日 +1977-01-01,元日 +1977-01-15,成人の日 +1977-02-11,建国記念の日 +1977-03-21,春分の日 +1977-04-29,天皇誕生日 +1977-05-03,憲法記念日 +1977-05-05,こどもの日 +1977-09-15,敬老の日 +1977-09-23,秋分の日 +1977-10-10,体育の日 +1977-11-03,文化の日 +1977-11-23,勤労感謝の日 +1978-01-01,元日 +1978-01-02,休日 +1978-01-15,成人の日 +1978-01-16,休日 +1978-02-11,建国記念の日 +1978-03-21,春分の日 +1978-04-29,天皇誕生日 +1978-05-03,憲法記念日 +1978-05-05,こどもの日 +1978-09-15,敬老の日 +1978-09-23,秋分の日 +1978-10-10,体育の日 +1978-11-03,文化の日 +1978-11-23,勤労感謝の日 +1979-01-01,元日 +1979-01-15,成人の日 +1979-02-11,建国記念の日 +1979-02-12,休日 +1979-03-21,春分の日 +1979-04-29,天皇誕生日 +1979-04-30,休日 +1979-05-03,憲法記念日 +1979-05-05,こどもの日 +1979-09-15,敬老の日 +1979-09-24,秋分の日 +1979-10-10,体育の日 +1979-11-03,文化の日 +1979-11-23,勤労感謝の日 +1980-01-01,元日 +1980-01-15,成人の日 +1980-02-11,建国記念の日 +1980-03-20,春分の日 +1980-04-29,天皇誕生日 +1980-05-03,憲法記念日 +1980-05-05,こどもの日 +1980-09-15,敬老の日 +1980-09-23,秋分の日 +1980-10-10,体育の日 +1980-11-03,文化の日 +1980-11-23,勤労感謝の日 +1980-11-24,休日 +1981-01-01,元日 +1981-01-15,成人の日 +1981-02-11,建国記念の日 +1981-03-21,春分の日 +1981-04-29,天皇誕生日 +1981-05-03,憲法記念日 +1981-05-04,休日 +1981-05-05,こどもの日 +1981-09-15,敬老の日 +1981-09-23,秋分の日 +1981-10-10,体育の日 +1981-11-03,文化の日 +1981-11-23,勤労感謝の日 +1982-01-01,元日 +1982-01-15,成人の日 +1982-02-11,建国記念の日 +1982-03-21,春分の日 +1982-03-22,休日 +1982-04-29,天皇誕生日 +1982-05-03,憲法記念日 +1982-05-05,こどもの日 +1982-09-15,敬老の日 +1982-09-23,秋分の日 +1982-10-10,体育の日 +1982-10-11,休日 +1982-11-03,文化の日 +1982-11-23,勤労感謝の日 +1983-01-01,元日 +1983-01-15,成人の日 +1983-02-11,建国記念の日 +1983-03-21,春分の日 +1983-04-29,天皇誕生日 +1983-05-03,憲法記念日 +1983-05-05,こどもの日 +1983-09-15,敬老の日 +1983-09-23,秋分の日 +1983-10-10,体育の日 +1983-11-03,文化の日 +1983-11-23,勤労感謝の日 +1984-01-01,元日 +1984-01-02,休日 +1984-01-15,成人の日 +1984-01-16,休日 +1984-02-11,建国記念の日 +1984-03-20,春分の日 +1984-04-29,天皇誕生日 +1984-04-30,休日 +1984-05-03,憲法記念日 +1984-05-05,こどもの日 +1984-09-15,敬老の日 +1984-09-23,秋分の日 +1984-09-24,休日 +1984-10-10,体育の日 +1984-11-03,文化の日 +1984-11-23,勤労感謝の日 +1985-01-01,元日 +1985-01-15,成人の日 +1985-02-11,建国記念の日 +1985-03-21,春分の日 +1985-04-29,天皇誕生日 +1985-05-03,憲法記念日 +1985-05-05,こどもの日 +1985-05-06,休日 +1985-09-15,敬老の日 +1985-09-16,休日 +1985-09-23,秋分の日 +1985-10-10,体育の日 +1985-11-03,文化の日 +1985-11-04,休日 +1985-11-23,勤労感謝の日 +1986-01-01,元日 +1986-01-15,成人の日 +1986-02-11,建国記念の日 +1986-03-21,春分の日 +1986-04-29,天皇誕生日 +1986-05-03,憲法記念日 +1986-05-05,こどもの日 +1986-09-15,敬老の日 +1986-09-23,秋分の日 +1986-10-10,体育の日 +1986-11-03,文化の日 +1986-11-23,勤労感謝の日 +1986-11-24,休日 +1987-01-01,元日 +1987-01-15,成人の日 +1987-02-11,建国記念の日 +1987-03-21,春分の日 +1987-04-29,天皇誕生日 +1987-05-03,憲法記念日 +1987-05-04,休日 +1987-05-05,こどもの日 +1987-09-15,敬老の日 +1987-09-23,秋分の日 +1987-10-10,体育の日 +1987-11-03,文化の日 +1987-11-23,勤労感謝の日 +1988-01-01,元日 +1988-01-15,成人の日 +1988-02-11,建国記念の日 +1988-03-20,春分の日 +1988-03-21,休日 +1988-04-29,天皇誕生日 +1988-05-03,憲法記念日 +1988-05-04,休日 +1988-05-05,こどもの日 +1988-09-15,敬老の日 +1988-09-23,秋分の日 +1988-10-10,体育の日 +1988-11-03,文化の日 +1988-11-23,勤労感謝の日 +1989-01-01,元日 +1989-01-02,休日 +1989-01-15,成人の日 +1989-01-16,休日 +1989-02-11,建国記念の日 +1989-02-24,大喪の礼 +1989-03-21,春分の日 +1989-04-29,みどりの日 +1989-05-03,憲法記念日 +1989-05-04,休日 +1989-05-05,こどもの日 +1989-09-15,敬老の日 +1989-09-23,秋分の日 +1989-10-10,体育の日 +1989-11-03,文化の日 +1989-11-23,勤労感謝の日 +1989-12-23,天皇誕生日 +1990-01-01,元日 +1990-01-15,成人の日 +1990-02-11,建国記念の日 +1990-02-12,休日 +1990-03-21,春分の日 +1990-04-29,みどりの日 +1990-04-30,休日 +1990-05-03,憲法記念日 +1990-05-04,休日 +1990-05-05,こどもの日 +1990-09-15,敬老の日 +1990-09-23,秋分の日 +1990-09-24,休日 +1990-10-10,体育の日 +1990-11-03,文化の日 +1990-11-12,即位礼正殿の儀 +1990-11-23,勤労感謝の日 +1990-12-23,天皇誕生日 +1990-12-24,休日 +1991-01-01,元日 +1991-01-15,成人の日 +1991-02-11,建国記念の日 +1991-03-21,春分の日 +1991-04-29,みどりの日 +1991-05-03,憲法記念日 +1991-05-04,休日 +1991-05-05,こどもの日 +1991-05-06,休日 +1991-09-15,敬老の日 +1991-09-16,休日 +1991-09-23,秋分の日 +1991-10-10,体育の日 +1991-11-03,文化の日 +1991-11-04,休日 +1991-11-23,勤労感謝の日 +1991-12-23,天皇誕生日 +1992-01-01,元日 +1992-01-15,成人の日 +1992-02-11,建国記念の日 +1992-03-20,春分の日 +1992-04-29,みどりの日 +1992-05-03,憲法記念日 +1992-05-04,休日 +1992-05-05,こどもの日 +1992-09-15,敬老の日 +1992-09-23,秋分の日 +1992-10-10,体育の日 +1992-11-03,文化の日 +1992-11-23,勤労感謝の日 +1992-12-23,天皇誕生日 +1993-01-01,元日 +1993-01-15,成人の日 +1993-02-11,建国記念の日 +1993-03-20,春分の日 +1993-04-29,みどりの日 +1993-05-03,憲法記念日 +1993-05-04,休日 +1993-05-05,こどもの日 +1993-06-09,結婚の儀 +1993-09-15,敬老の日 +1993-09-23,秋分の日 +1993-10-10,体育の日 +1993-10-11,休日 +1993-11-03,文化の日 +1993-11-23,勤労感謝の日 +1993-12-23,天皇誕生日 +1994-01-01,元日 +1994-01-15,成人の日 +1994-02-11,建国記念の日 +1994-03-21,春分の日 +1994-04-29,みどりの日 +1994-05-03,憲法記念日 +1994-05-04,休日 +1994-05-05,こどもの日 +1994-09-15,敬老の日 +1994-09-23,秋分の日 +1994-10-10,体育の日 +1994-11-03,文化の日 +1994-11-23,勤労感謝の日 +1994-12-23,天皇誕生日 +1995-01-01,元日 +1995-01-02,休日 +1995-01-15,成人の日 +1995-01-16,休日 +1995-02-11,建国記念の日 +1995-03-21,春分の日 +1995-04-29,みどりの日 +1995-05-03,憲法記念日 +1995-05-04,休日 +1995-05-05,こどもの日 +1995-09-15,敬老の日 +1995-09-23,秋分の日 +1995-10-10,体育の日 +1995-11-03,文化の日 +1995-11-23,勤労感謝の日 +1995-12-23,天皇誕生日 +1996-01-01,元日 +1996-01-15,成人の日 +1996-02-11,建国記念の日 +1996-02-12,休日 +1996-03-20,春分の日 +1996-04-29,みどりの日 +1996-05-03,憲法記念日 +1996-05-04,休日 +1996-05-05,こどもの日 +1996-05-06,休日 +1996-07-20,海の日 +1996-09-15,敬老の日 +1996-09-16,休日 +1996-09-23,秋分の日 +1996-10-10,体育の日 +1996-11-03,文化の日 +1996-11-04,休日 +1996-11-23,勤労感謝の日 +1996-12-23,天皇誕生日 +1997-01-01,元日 +1997-01-15,成人の日 +1997-02-11,建国記念の日 +1997-03-20,春分の日 +1997-04-29,みどりの日 +1997-05-03,憲法記念日 +1997-05-05,こどもの日 +1997-07-20,海の日 +1997-07-21,休日 +1997-09-15,敬老の日 +1997-09-23,秋分の日 +1997-10-10,体育の日 +1997-11-03,文化の日 +1997-11-23,勤労感謝の日 +1997-11-24,休日 +1997-12-23,天皇誕生日 +1998-01-01,元日 +1998-01-15,成人の日 +1998-02-11,建国記念の日 +1998-03-21,春分の日 +1998-04-29,みどりの日 +1998-05-03,憲法記念日 +1998-05-04,休日 +1998-05-05,こどもの日 +1998-07-20,海の日 +1998-09-15,敬老の日 +1998-09-23,秋分の日 +1998-10-10,体育の日 +1998-11-03,文化の日 +1998-11-23,勤労感謝の日 +1998-12-23,天皇誕生日 +1999-01-01,元日 +1999-01-15,成人の日 +1999-02-11,建国記念の日 +1999-03-21,春分の日 +1999-03-22,休日 +1999-04-29,みどりの日 +1999-05-03,憲法記念日 +1999-05-04,休日 +1999-05-05,こどもの日 +1999-07-20,海の日 +1999-09-15,敬老の日 +1999-09-23,秋分の日 +1999-10-10,体育の日 +1999-10-11,休日 +1999-11-03,文化の日 +1999-11-23,勤労感謝の日 +1999-12-23,天皇誕生日 +2000-01-01,元日 +2000-01-10,成人の日 +2000-02-11,建国記念の日 +2000-03-20,春分の日 +2000-04-29,みどりの日 +2000-05-03,憲法記念日 +2000-05-04,休日 +2000-05-05,こどもの日 +2000-07-20,海の日 +2000-09-15,敬老の日 +2000-09-23,秋分の日 +2000-10-09,体育の日 +2000-11-03,文化の日 +2000-11-23,勤労感謝の日 +2000-12-23,天皇誕生日 +2001-01-01,元日 +2001-01-08,成人の日 +2001-02-11,建国記念の日 +2001-02-12,休日 +2001-03-20,春分の日 +2001-04-29,みどりの日 +2001-04-30,休日 +2001-05-03,憲法記念日 +2001-05-04,休日 +2001-05-05,こどもの日 +2001-07-20,海の日 +2001-09-15,敬老の日 +2001-09-23,秋分の日 +2001-09-24,休日 +2001-10-08,体育の日 +2001-11-03,文化の日 +2001-11-23,勤労感謝の日 +2001-12-23,天皇誕生日 +2001-12-24,休日 +2002-01-01,元日 +2002-01-14,成人の日 +2002-02-11,建国記念の日 +2002-03-21,春分の日 +2002-04-29,みどりの日 +2002-05-03,憲法記念日 +2002-05-04,休日 +2002-05-05,こどもの日 +2002-05-06,休日 +2002-07-20,海の日 +2002-09-15,敬老の日 +2002-09-16,休日 +2002-09-23,秋分の日 +2002-10-14,体育の日 +2002-11-03,文化の日 +2002-11-04,休日 +2002-11-23,勤労感謝の日 +2002-12-23,天皇誕生日 +2003-01-01,元日 +2003-01-13,成人の日 +2003-02-11,建国記念の日 +2003-03-21,春分の日 +2003-04-29,みどりの日 +2003-05-03,憲法記念日 +2003-05-05,こどもの日 +2003-07-21,海の日 +2003-09-15,敬老の日 +2003-09-23,秋分の日 +2003-10-13,体育の日 +2003-11-03,文化の日 +2003-11-23,勤労感謝の日 +2003-11-24,休日 +2003-12-23,天皇誕生日 +2004-01-01,元日 +2004-01-12,成人の日 +2004-02-11,建国記念の日 +2004-03-20,春分の日 +2004-04-29,みどりの日 +2004-05-03,憲法記念日 +2004-05-04,休日 +2004-05-05,こどもの日 +2004-07-19,海の日 +2004-09-20,敬老の日 +2004-09-23,秋分の日 +2004-10-11,体育の日 +2004-11-03,文化の日 +2004-11-23,勤労感謝の日 +2004-12-23,天皇誕生日 +2005-01-01,元日 +2005-01-10,成人の日 +2005-02-11,建国記念の日 +2005-03-20,春分の日 +2005-03-21,休日 +2005-04-29,みどりの日 +2005-05-03,憲法記念日 +2005-05-04,休日 +2005-05-05,こどもの日 +2005-07-18,海の日 +2005-09-19,敬老の日 +2005-09-23,秋分の日 +2005-10-10,体育の日 +2005-11-03,文化の日 +2005-11-23,勤労感謝の日 +2005-12-23,天皇誕生日 +2006-01-01,元日 +2006-01-02,休日 +2006-01-09,成人の日 +2006-02-11,建国記念の日 +2006-03-21,春分の日 +2006-04-29,みどりの日 +2006-05-03,憲法記念日 +2006-05-04,休日 +2006-05-05,こどもの日 +2006-07-17,海の日 +2006-09-18,敬老の日 +2006-09-23,秋分の日 +2006-10-09,体育の日 +2006-11-03,文化の日 +2006-11-23,勤労感謝の日 +2006-12-23,天皇誕生日 +2007-01-01,元日 +2007-01-08,成人の日 +2007-02-11,建国記念の日 +2007-02-12,休日 +2007-03-21,春分の日 +2007-04-29,昭和の日 +2007-04-30,休日 +2007-05-03,憲法記念日 +2007-05-04,みどりの日 +2007-05-05,こどもの日 +2007-07-16,海の日 +2007-09-17,敬老の日 +2007-09-23,秋分の日 +2007-09-24,休日 +2007-10-08,体育の日 +2007-11-03,文化の日 +2007-11-23,勤労感謝の日 +2007-12-23,天皇誕生日 +2007-12-24,休日 +2008-01-01,元日 +2008-01-14,成人の日 +2008-02-11,建国記念の日 +2008-03-20,春分の日 +2008-04-29,昭和の日 +2008-05-03,憲法記念日 +2008-05-04,みどりの日 +2008-05-05,こどもの日 +2008-05-06,休日 +2008-07-21,海の日 +2008-09-15,敬老の日 +2008-09-23,秋分の日 +2008-10-13,体育の日 +2008-11-03,文化の日 +2008-11-23,勤労感謝の日 +2008-11-24,休日 +2008-12-23,天皇誕生日 +2009-01-01,元日 +2009-01-12,成人の日 +2009-02-11,建国記念の日 +2009-03-20,春分の日 +2009-04-29,昭和の日 +2009-05-03,憲法記念日 +2009-05-04,みどりの日 +2009-05-05,こどもの日 +2009-05-06,休日 +2009-07-20,海の日 +2009-09-21,敬老の日 +2009-09-22,休日 +2009-09-23,秋分の日 +2009-10-12,体育の日 +2009-11-03,文化の日 +2009-11-23,勤労感謝の日 +2009-12-23,天皇誕生日 +2010-01-01,元日 +2010-01-11,成人の日 +2010-02-11,建国記念の日 +2010-03-21,春分の日 +2010-03-22,休日 +2010-04-29,昭和の日 +2010-05-03,憲法記念日 +2010-05-04,みどりの日 +2010-05-05,こどもの日 +2010-07-19,海の日 +2010-09-20,敬老の日 +2010-09-23,秋分の日 +2010-10-11,体育の日 +2010-11-03,文化の日 +2010-11-23,勤労感謝の日 +2010-12-23,天皇誕生日 +2011-01-01,元日 +2011-01-10,成人の日 +2011-02-11,建国記念の日 +2011-03-21,春分の日 +2011-04-29,昭和の日 +2011-05-03,憲法記念日 +2011-05-04,みどりの日 +2011-05-05,こどもの日 +2011-07-18,海の日 +2011-09-19,敬老の日 +2011-09-23,秋分の日 +2011-10-10,体育の日 +2011-11-03,文化の日 +2011-11-23,勤労感謝の日 +2011-12-23,天皇誕生日 +2012-01-01,元日 +2012-01-02,休日 +2012-01-09,成人の日 +2012-02-11,建国記念の日 +2012-03-20,春分の日 +2012-04-29,昭和の日 +2012-04-30,休日 +2012-05-03,憲法記念日 +2012-05-04,みどりの日 +2012-05-05,こどもの日 +2012-07-16,海の日 +2012-09-17,敬老の日 +2012-09-22,秋分の日 +2012-10-08,体育の日 +2012-11-03,文化の日 +2012-11-23,勤労感謝の日 +2012-12-23,天皇誕生日 +2012-12-24,休日 +2013-01-01,元日 +2013-01-14,成人の日 +2013-02-11,建国記念の日 +2013-03-20,春分の日 +2013-04-29,昭和の日 +2013-05-03,憲法記念日 +2013-05-04,みどりの日 +2013-05-05,こどもの日 +2013-05-06,休日 +2013-07-15,海の日 +2013-09-16,敬老の日 +2013-09-23,秋分の日 +2013-10-14,体育の日 +2013-11-03,文化の日 +2013-11-04,休日 +2013-11-23,勤労感謝の日 +2013-12-23,天皇誕生日 +2014-01-01,元日 +2014-01-13,成人の日 +2014-02-11,建国記念の日 +2014-03-21,春分の日 +2014-04-29,昭和の日 +2014-05-03,憲法記念日 +2014-05-04,みどりの日 +2014-05-05,こどもの日 +2014-05-06,休日 +2014-07-21,海の日 +2014-09-15,敬老の日 +2014-09-23,秋分の日 +2014-10-13,体育の日 +2014-11-03,文化の日 +2014-11-23,勤労感謝の日 +2014-11-24,休日 +2014-12-23,天皇誕生日 +2015-01-01,元日 +2015-01-12,成人の日 +2015-02-11,建国記念の日 +2015-03-21,春分の日 +2015-04-29,昭和の日 +2015-05-03,憲法記念日 +2015-05-04,みどりの日 +2015-05-05,こどもの日 +2015-05-06,休日 +2015-07-20,海の日 +2015-09-21,敬老の日 +2015-09-22,休日 +2015-09-23,秋分の日 +2015-10-12,体育の日 +2015-11-03,文化の日 +2015-11-23,勤労感謝の日 +2015-12-23,天皇誕生日 +2016-01-01,元日 +2016-01-11,成人の日 +2016-02-11,建国記念の日 +2016-03-20,春分の日 +2016-03-21,休日 +2016-04-29,昭和の日 +2016-05-03,憲法記念日 +2016-05-04,みどりの日 +2016-05-05,こどもの日 +2016-07-18,海の日 +2016-08-11,山の日 +2016-09-19,敬老の日 +2016-09-22,秋分の日 +2016-10-10,体育の日 +2016-11-03,文化の日 +2016-11-23,勤労感謝の日 +2016-12-23,天皇誕生日 +2017-01-01,元日 +2017-01-02,休日 +2017-01-09,成人の日 +2017-02-11,建国記念の日 +2017-03-20,春分の日 +2017-04-29,昭和の日 +2017-05-03,憲法記念日 +2017-05-04,みどりの日 +2017-05-05,こどもの日 +2017-07-17,海の日 +2017-08-11,山の日 +2017-09-18,敬老の日 +2017-09-23,秋分の日 +2017-10-09,体育の日 +2017-11-03,文化の日 +2017-11-23,勤労感謝の日 +2017-12-23,天皇誕生日 +2018-01-01,元日 +2018-01-08,成人の日 +2018-02-11,建国記念の日 +2018-02-12,休日 +2018-03-21,春分の日 +2018-04-29,昭和の日 +2018-04-30,休日 +2018-05-03,憲法記念日 +2018-05-04,みどりの日 +2018-05-05,こどもの日 +2018-07-16,海の日 +2018-08-11,山の日 +2018-09-17,敬老の日 +2018-09-23,秋分の日 +2018-09-24,休日 +2018-10-08,体育の日 +2018-11-03,文化の日 +2018-11-23,勤労感謝の日 +2018-12-23,天皇誕生日 +2018-12-24,休日 +2019-01-01,元日 +2019-01-14,成人の日 +2019-02-11,建国記念の日 +2019-03-21,春分の日 +2019-04-29,昭和の日 +2019-04-30,休日 +2019-05-01,休日(祝日扱い) +2019-05-02,休日 +2019-05-03,憲法記念日 +2019-05-04,みどりの日 +2019-05-05,こどもの日 +2019-05-06,休日 +2019-07-15,海の日 +2019-08-11,山の日 +2019-08-12,休日 +2019-09-16,敬老の日 +2019-09-23,秋分の日 +2019-10-14,体育の日(スポーツの日) +2019-10-22,休日(祝日扱い) +2019-11-03,文化の日 +2019-11-04,休日 +2019-11-23,勤労感謝の日 +2020-01-01,元日 +2020-01-13,成人の日 +2020-02-11,建国記念の日 +2020-02-23,天皇誕生日 +2020-02-24,休日 +2020-03-20,春分の日 +2020-04-29,昭和の日 +2020-05-03,憲法記念日 +2020-05-04,みどりの日 +2020-05-05,こどもの日 +2020-05-06,休日 +2020-07-23,海の日 +2020-07-24,スポーツの日 +2020-08-10,山の日 +2020-09-21,敬老の日 +2020-09-22,秋分の日 +2020-11-03,文化の日 +2020-11-23,勤労感謝の日 +2021-01-01,元日 +2021-01-11,成人の日 +2021-02-11,建国記念の日 +2021-02-23,天皇誕生日 +2021-03-20,春分の日 +2021-04-29,昭和の日 +2021-05-03,憲法記念日 +2021-05-04,みどりの日 +2021-05-05,こどもの日 +2021-07-22,海の日 +2021-07-23,スポーツの日 +2021-08-08,山の日 +2021-08-09,休日 +2021-09-20,敬老の日 +2021-09-23,秋分の日 +2021-11-03,文化の日 +2021-11-23,勤労感謝の日 +2022-01-01,元日 +2022-01-10,成人の日 +2022-02-11,建国記念の日 +2022-02-23,天皇誕生日 +2022-03-21,春分の日 +2022-04-29,昭和の日 +2022-05-03,憲法記念日 +2022-05-04,みどりの日 +2022-05-05,こどもの日 +2022-07-18,海の日 +2022-08-11,山の日 +2022-09-19,敬老の日 +2022-09-23,秋分の日 +2022-10-10,スポーツの日 +2022-11-03,文化の日 +2022-11-23,勤労感謝の日 +2023-01-01,元日 +2023-01-02,休日 +2023-01-09,成人の日 +2023-02-11,建国記念の日 +2023-02-23,天皇誕生日 +2023-03-21,春分の日 +2023-04-29,昭和の日 +2023-05-03,憲法記念日 +2023-05-04,みどりの日 +2023-05-05,こどもの日 +2023-07-17,海の日 +2023-08-11,山の日 +2023-09-18,敬老の日 +2023-09-23,秋分の日 +2023-10-09,スポーツの日 +2023-11-03,文化の日 +2023-11-23,勤労感謝の日 +2024-01-01,元日 +2024-01-08,成人の日 +2024-02-11,建国記念の日 +2024-02-12,休日 +2024-02-23,天皇誕生日 +2024-03-20,春分の日 +2024-04-29,昭和の日 +2024-05-03,憲法記念日 +2024-05-04,みどりの日 +2024-05-05,こどもの日 +2024-05-06,休日 +2024-07-15,海の日 +2024-08-11,山の日 +2024-08-12,休日 +2024-09-16,敬老の日 +2024-09-22,秋分の日 +2024-09-23,休日 +2024-10-14,スポーツの日 +2024-11-03,文化の日 +2024-11-04,休日 +2024-11-23,勤労感謝の日 +2025-01-01,元日 +2025-01-13,成人の日 +2025-02-11,建国記念の日 +2025-02-23,天皇誕生日 +2025-02-24,休日 +2025-03-20,春分の日 +2025-04-29,昭和の日 +2025-05-03,憲法記念日 +2025-05-04,みどりの日 +2025-05-05,こどもの日 +2025-05-06,休日 +2025-07-21,海の日 +2025-08-11,山の日 +2025-09-15,敬老の日 +2025-09-23,秋分の日 +2025-10-13,スポーツの日 +2025-11-03,文化の日 +2025-11-23,勤労感謝の日 +2025-11-24,休日 +2026-01-01,元日 +2026-01-12,成人の日 +2026-02-11,建国記念の日 +2026-02-23,天皇誕生日 +2026-03-20,春分の日 +2026-04-29,昭和の日 +2026-05-03,憲法記念日 +2026-05-04,みどりの日 +2026-05-05,こどもの日 +2026-05-06,休日 +2026-07-20,海の日 +2026-08-11,山の日 +2026-09-21,敬老の日 +2026-09-22,休日 +2026-09-23,秋分の日 +2026-10-12,スポーツの日 +2026-11-03,文化の日 +2026-11-23,勤労感謝の日 +2027-01-01,元日 +2027-01-11,成人の日 +2027-02-11,建国記念の日 +2027-02-23,天皇誕生日 +2027-03-21,春分の日 +2027-03-22,休日 +2027-04-29,昭和の日 +2027-05-03,憲法記念日 +2027-05-04,みどりの日 +2027-05-05,こどもの日 +2027-07-19,海の日 +2027-08-11,山の日 +2027-09-20,敬老の日 +2027-09-23,秋分の日 +2027-10-11,スポーツの日 +2027-11-03,文化の日 +2027-11-23,勤労感謝の日 diff --git a/crates/ferese-config/locales/zh-CN.json b/crates/ferese-config/locales/zh-CN.json index c8526b4..a4c63fc 100644 --- a/crates/ferese-config/locales/zh-CN.json +++ b/crates/ferese-config/locales/zh-CN.json @@ -588,5 +588,455 @@ "Wallpaper image": "壁纸图像", "Shortcut guide": "快捷键指南", "Custom shortcuts": "自定义快捷键", - "Animations": "动画" + "Animations": "动画", + "accent": "强调色", + "active_color": "活动颜色", + "active_opacity": "活动窗口不透明度", + "adaptive": "自适应", + "always": "始终", + "angle": "角度", + "app-icons": "应用图标", + "app_id": "应用 ID", + "attached": "贴合屏幕边缘", + "auto_advance": "自动进入下一阶段", + "auto_back_and_forth": "再次按工作区快捷键返回", + "auto_refresh": "自动刷新", + "background": "背景", + "background_color": "背景颜色", + "bar": "面板", + "block_out_from_screencasts": "在录屏中隐藏", + "blur": "模糊", + "border_width": "边框宽度", + "bottom": "底部", + "bounce": "回弹", + "calendar_height": "日历与控制中心高度", + "capsules": "胶囊", + "center_on_focus": "焦点居中", + "command": "命令", + "compact": "紧凑", + "confirm_timeout": "确认超时", + "control_gap": "控件间距", + "controls_height": "控制区高度", + "corner-wrap": "包裹圆角", + "default_column_display": "默认列显示方式", + "default_column_width": "默认列宽", + "dim_after_seconds": "闲置多久后调暗", + "disabled": "禁用", + "do_not_disturb": "勿扰模式", + "dock-or-suspend": "外接显示器或挂起", + "dots": "圆点", + "duration_ms": "持续时间(毫秒)", + "eager": "主动", + "edge_margin": "屏幕边缘间距", + "en": "English", + "expanded_radius": "展开时圆角", + "expanded_width": "展开时宽度", + "extend": "扩展", + "external-only": "仅外接显示器", + "fail": "失败", + "fill": "填充", + "fit": "适应", + "flash_duration_ms": "闪烁时长(毫秒)", + "flat": "平面", + "flipped": "水平翻转", + "flipped_180": "翻转并旋转 180°", + "flipped_270": "翻转并旋转 270°", + "flipped_90": "翻转并旋转 90°", + "floating": "悬浮", + "focus_effect": "焦点效果", + "focus_ring": "焦点边框", + "focus_ring_width": "焦点边框宽度", + "focus_strategy": "焦点策略", + "font_size": "字号", + "frame": "边框", + "from": "起点", + "fullscreen": "全屏", + "fullscreen-playing": "全屏播放时", + "fullscreen_playback": "全屏播放", + "fused": "融合", + "gap_threshold": "融合间距阈值", + "gradient": "渐变", + "hit_width": "点击区域宽度", + "idle_width": "收起宽度", + "ignore": "忽略", + "inactive_color": "非活动颜色", + "inactive_dim": "非活动窗口调暗", + "inactive_opacity": "非活动窗口不透明度", + "inner_gap": "窗口间距", + "inner_padding": "内部留白", + "internal-only": "仅内置显示器", + "intervals": "阶段计时", + "island_padding": "岛形内边距", + "keysym": "按键符号", + "lid_closed": "合盖状态", + "lid_policy": "合盖行为", + "list_width": "列表宽度", + "long_break_minutes": "长休息时长(分钟)", + "max_total": "总数量上限", + "max_visible": "可见数量上限", + "media_height": "媒体区域高度", + "min_height": "最小高度", + "min_width": "最小宽度", + "minimal": "最少移动", + "mirror": "镜像", + "mirror_source": "镜像来源", + "nested": "嵌套", + "never": "从不", + "none": "无", + "normal": "正常", + "notch": "刘海形状", + "notification_bridge": "通知桥接", + "notifications_height": "通知区域高度", + "numbers": "数字", + "offset_y": "垂直偏移", + "ok": "成功", + "on-demand": "按需", + "outer_gap": "工作区边距", + "outline_color": "轮廓颜色", + "outline_width": "轮廓宽度", + "overshoot": "越界回弹", + "owner_ceiling": "单个来源数量上限", + "padding": "内边距", + "paged": "分页", + "path": "路径", + "peek_height": "预览高度", + "peek_on_hover": "悬停展开预览", + "peek_width": "预览宽度", + "physical": "物理尺寸", + "playing": "播放中", + "pomodoro": "番茄钟", + "process_watch": "进程监视", + "reduced_motion": "减少动画", + "required": "必需", + "restart": "退出后重启", + "result_flash_ms": "结果闪烁时长(毫秒)", + "rotate_180": "旋转 180°", + "rotate_270": "旋转 270°", + "rotate_90": "旋转 90°", + "row_height": "行高", + "segment_gap": "分段间距", + "segment_height": "分段高度", + "shadow": "阴影", + "shell_hook": "Shell 钩子", + "shell_radius": "Shell 圆角", + "short_break_minutes": "短休息时长(分钟)", + "show_badge": "显示数量标记", + "show_popups": "显示弹出通知", + "side_margins": "左右边距", + "sleep_after_seconds": "闲置多久后关闭显示器", + "smart_gaps": "智能间距", + "soft": "柔和", + "solid": "实色", + "sources": "来源", + "speed": "速度", + "spring": "弹簧动画", + "stacked": "堆叠", + "start_delay_ms": "启动延迟(毫秒)", + "startup": "启动时", + "surface": "表面", + "surface_base": "基础表面", + "surface_raised": "抬升表面", + "swipe": "滑动手势", + "tab_indicator": "标签指示器", + "tabbed": "标签式", + "tabs": "标签", + "temperature": "色温", + "text_color": "文字颜色", + "text_primary": "主要文字", + "timeout_ms": "超时时长(毫秒)", + "tint_strength": "染色强度", + "to": "终点", + "toast_height": "通知提示高度", + "toast_width": "通知提示宽度", + "top": "顶部", + "transient": "临时", + "translucent": "半透明", + "tree": "树形", + "viewport_spring": "视口弹簧动画", + "wide": "宽幅", + "width_presets": "宽度预设", + "window-hug": "贴合窗口", + "window-stacks": "窗口堆叠", + "window_clearance": "窗口避让间距", + "window_radius": "窗口圆角", + "work_minutes": "专注时长(分钟)", + "workspace": "工作区", + "workspaces_height": "工作区区域高度", + "zh-CN": "简体中文", + "Config": "配置", + "Docker build": "Docker 构建", + "Git push": "Git 推送", + "Make": "Make 构建", + "Action": "动作", + "Action name, such as spawn or focus": "动作名称,例如 spawn 或 focus。", + "Adapts to available space.": "根据可用空间自动调整。", + "Add a fine line around the panel shape.": "在面板形状周围添加细边框。", + "Add holiday": "添加节假日", + "Allow the device to use service {uuid}?": "允许设备使用服务 {uuid} 吗?", + "Allow this device to pair?": "允许此设备配对吗?", + "Another device is already pairing.": "另一个设备正在配对。", + "Argument": "参数", + "Attached to the screen edge": "贴合屏幕边缘", + "Auto (battery)": "自动(电池)", + "Auto appearance: off": "自动外观:关", + "Auto appearance: on": "自动外观:开", + "Auto follows the GTK/GNOME appearance preference": "自动模式跟随 GTK/GNOME 外观偏好。", + "Background opacity": "背景不透明度", + "Battery percentage": "电量百分比", + "Blocked by a hardware switch or airplane mode": "已被硬件开关或飞行模式禁用", + "Bluetooth": "蓝牙", + "Bluetooth unavailable": "蓝牙不可用", + "Both modes": "深浅两种模式", + "Bottom panel": "底部面板", + "Cancel pairing": "取消配对", + "Changes apply live": "更改即时生效", + "Choose Find devices to discover nearby devices in pairing mode.": "点击“查找设备”,发现附近处于配对模式的设备。", + "Choose a partial KDL theme file": "选择包含主题覆盖项的 KDL 文件", + "Choose the screen edge for the panel.": "选择面板所在的屏幕边缘。", + "Choose when to use the overflow menu.": "选择何时将此项收纳至更多菜单。", + "Collapse advanced customization": "收起高级自定义", + "Collapsed height": "收起时高度", + "Command, direction, or action argument": "命令、方向或动作参数。", + "Configuration schema query timed out": "配置结构查询超时", + "Confirm": "确认", + "Connected groups with rounded ends": "圆角端点相连的分组", + "Connected · ": "已连接 · ", + "Connecting · ": "正在连接 · ", + "Connection services are unavailable. Use Refresh to try again.": "连接服务不可用,请点击刷新重试。", + "Corner radius: {label}": "圆角半径:{label}", + "Corners follow the style and panel height.": "圆角随面板样式和高度调整。", + "Could not load wallpaper preview.": "无法加载壁纸预览。", + "Custom holidays": "自定义节假日", + "Customize corners first.": "请先启用自定义圆角。", + "Customize items first.": "请先启用自定义面板项。", + "Customize swipe down": "自定义向下滑动", + "Customize swipe left": "自定义向左滑动", + "Customize swipe right": "自定义向右滑动", + "Customize swipe up": "自定义向上滑动", + "Dark mode": "深色模式", + "Dark theme": "深色主题", + "Date": "日期", + "Decrease {label}": "减小{label}", + "Decrease {label} radius": "减小{label}圆角", + "Devices": "设备", + "Discovery canceled.": "已取消搜索设备。", + "Display profile": "显示配置", + "Does {key} match the number on your device?": "{key} 与设备上显示的数字一致吗?", + "Edge left": "靠屏幕边缘的左角", + "Edge right": "靠屏幕边缘的右角", + "Enhanced open": "增强型开放网络", + "Enter the PIN shown on your device.": "输入设备上显示的 PIN 码。", + "Enter the six-digit passkey shown on your device.": "输入设备上显示的六位配对码。", + "Enterprise": "企业网络", + "Enterprise and legacy networks need a saved NetworkManager profile with their security settings.": "企业网络及旧式网络需要预先保存包含相应安全设置的 NetworkManager 配置。", + "Expand advanced customization": "展开高级自定义", + "Expand the island when you hover over it.": "鼠标悬停时展开岛形面板。", + "Find devices": "查找设备", + "Follow the GTK/GNOME appearance preference.": "跟随 GTK/GNOME 外观偏好。", + "Follows the active window state": "跟随活动窗口状态", + "Forget this connection?": "忘记此连接吗?", + "Full": "完整", + "Group arrangement & spacing": "分组排列与间距", + "Groups blend where they meet": "分组在相接处融合", + "Headphones": "耳机", + "Hidden network": "隐藏网络", + "Holiday name": "节假日名称", + "Horizontal padding": "水平内边距", + "Increase {label}": "增大{label}", + "Increase {label} radius": "增大{label}圆角", + "Invalid display response": "显示器返回了无效信息", + "Invalid network password.": "网络密码无效。", + "Invalid panel corner.": "无效的面板圆角。", + "Invalid passkey": "配对码无效", + "Island padding": "岛形内边距", + "Item settings": "项目设置", + "Item spacing": "项目间距", + "Japanese public holidays are included. Add personal dates below; MM-DD repeats annually.": "已包含日本法定节假日。可在下方添加个人节假日,MM-DD 格式每年重复。", + "Join a hidden network…": "加入隐藏网络…", + "Join the island to the screen edge with curved shoulders.": "以弧形连接处将岛形面板贴合屏幕边缘。", + "Keep visible": "保持可见", + "Keys or swipe, such as Super+Return": "按键或滑动手势,例如 Super+Return。", + "Laptop": "笔记本电脑", + "Legacy security": "旧式安全协议", + "Light mode": "浅色模式", + "Light theme": "浅色主题", + "Loading connection services…": "正在加载连接服务…", + "Loading preview…": "正在加载预览…", + "Managed by the Ferese session.": "由 Ferese 会话管理。", + "Maximum activities in the collapsed and peek island.": "收起和预览状态下显示的活动数量上限。", + "Missing build directory": "缺少构建目录", + "Missing executable directory": "缺少可执行文件目录", + "Move or remove this group's items before removing the group.": "删除分组前,请先移动或移除其中的项目。", + "Nearby devices": "附近设备", + "Network name (SSID)": "网络名称(SSID)", + "Network password": "网络密码", + "Networks": "网络", + "No Bluetooth adapter is available.": "没有可用的蓝牙适配器。", + "No Wi-Fi adapter": "没有 Wi-Fi 适配器", + "No Wi-Fi adapter is available.": "没有可用的 Wi-Fi 适配器。", + "No matching pairing request": "没有匹配的配对请求", + "No nearby devices found.": "未发现附近设备。", + "No networks found. Scan to look for nearby networks.": "未发现网络,请扫描附近网络。", + "No override file": "未设置覆盖文件", + "No paired devices yet.": "尚无已配对设备。", + "No panel configured.": "尚未配置面板。", + "No wallpaper image selected.": "尚未选择壁纸。", + "Not paired": "未配对", + "Open at login": "登录时启动", + "Outer left": "外侧左角", + "Outer right": "外侧右角", + "Overflow is supplied by the panel.": "更多菜单由面板自动提供。", + "PIN must have 1–16 characters": "PIN 码需要 1–16 个字符", + "PIN or passkey": "PIN 或配对码", + "Pair with {device}": "与 {device} 配对", + "Paired": "已配对", + "Paired devices": "已配对设备", + "Pairing canceled or timed out": "配对已取消或超时", + "Pairing canceled.": "已取消配对。", + "Panel only. Text and icons stay opaque.": "仅影响面板,文字和图标保持不透明。", + "Panel position: {label}": "面板位置:{label}", + "Panel preview unavailable: {error}": "面板预览不可用:{error}", + "Passkey must have up to six digits": "配对码不能超过六位数字", + "Percentage": "百分比", + "Pins stay in place when you change styles. Top and bottom panels mirror them.": "切换样式时保留固定项目;顶部和底部面板会镜像显示。", + "Preferred size": "首选尺寸", + "Program": "程序", + "Put your device in pairing mode. Discovery stops automatically after 30 seconds.": "请将设备设为配对模式,搜索将在 30 秒后自动停止。", + "Reduce transparency is on. Turn it off in Accessibility to change opacity.": "已启用“减少透明效果”,请在辅助功能中关闭后调整不透明度。", + "Remove holiday": "移除节假日", + "Resolution / refresh": "分辨率/刷新率", + "Restart if it exits": "退出后重启", + "Saved credentials or pairing keys will be removed. You will need to connect again.": "将移除保存的凭据或配对密钥,下次使用时需重新连接。", + "Saved profile · applies when active": "已保存的配置 · 启用时生效", + "Saved · ": "已保存 · ", + "Scan": "扫描", + "Schema query failed": "配置结构查询失败", + "Searching for devices…": "正在搜索设备…", + "Select display": "选择显示器", + "Separate floating groups": "独立悬浮分组", + "Shared": "共享", + "Shortcut": "快捷键", + "Show +n for additional activities.": "以 +n 表示更多活动。", + "Show item": "显示项目", + "Show up to {count} activities": "最多显示 {count} 个活动", + "Song title": "歌曲名称", + "Song title · Artist ▷": "歌曲名称 · 艺术家 ▷", + "Stop scan": "停止扫描", + "Style": "样式", + "Submit": "提交", + "Switch between light and dark at set times.": "在指定时间切换深浅外观。", + "The drop target changed. Try dragging again.": "放置目标已变化,请重新拖动。", + "The network connection failed. Check the password and try again.": "网络连接失败,请检查密码后重试。", + "The network did not finish connecting. Check its status before trying again.": "网络连接尚未完成,请检查状态后再重试。", + "Theme file: {label}": "主题文件:{label}", + "This group no longer exists. Reload settings.": "此分组已不存在,请重新加载设置。", + "This item no longer exists. Reload settings.": "此项目已不存在,请重新加载设置。", + "Top panel": "顶部面板", + "Trust": "信任", + "Trusted": "已信任", + "Unknown panel geometry setting.": "未知的面板几何设置。", + "Unknown panel group setting.": "未知的面板分组设置。", + "Unknown panel item setting.": "未知的面板项目设置。", + "Unknown sender": "未知来源", + "Unsupported configuration schema": "不支持的配置结构", + "Untrust": "取消信任", + "Update": "更新", + "Use different themes for light and dark": "为深浅模式分别设置主题", + "Using shell opacity. Text and icons stay opaque.": "使用 Shell 不透明度,文字和图标保持不透明。", + "Vertical padding": "垂直内边距", + "Visible activities": "可见活动", + "When needed": "按需", + "Wi-Fi power": "Wi-Fi 开关", + "Wi-Fi unavailable": "Wi-Fi 不可用", + "Workspace style belongs to Workspaces items.": "工作区样式只能用于工作区项目。", + "YYYY-MM-DD / MM-DD": "YYYY-MM-DD / MM-DD", + "YYYY-MM-DD for one date; MM-DD repeats every year.": "YYYY-MM-DD 为单次日期,MM-DD 为每年重复的日期。", + "Timezone": "时区", + "Translucent": "半透明", + "Minimal": "最少移动", + "Centered": "居中", + "Paged": "分页", + "Move content in the direction of your fingers. Updates immediately.": "内容随手指方向滚动,即时生效。", + "Mouse natural scrolling": "鼠标自然滚动", + "Mouse speed": "鼠标速度", + "Mouse scroll speed": "鼠标滚轮速度", + "Show dates from adjacent months": "显示非本月日期", + "Display the previous and next month's dates in the calendar.": "在日历中显示上个月和下个月的相邻日期。", + "Holiday date must be YYYY-MM-DD or MM-DD.": "节假日日期必须使用 YYYY-MM-DD 或 MM-DD 格式。", + "Holiday name must contain 1–80 characters.": "节假日名称需要 1–80 个字符。", + "calendar": "日历", + "show_adjacent_days": "显示非本月日期", + "custom_holidays": "自定义节假日", + "date": "日期", + "☀ Light": "☀ 浅色", + "☾ Dark": "☾ 深色", + "◐ Auto": "◐ 自动", + "Auto · currently light": "自动 · 当前为浅色", + "Auto · currently dark": "自动 · 当前为深色", + "Open calendar and controls": "打开日历与控制中心", + "Light from {light}, dark from {dark} ({zone})": "{light} 起使用浅色,{dark} 起使用深色({zone})", + " · Trusted": " · 已信任", + " · {percent}% battery": " · 电量 {percent}%", + "{percent}% signal": "信号 {percent}%", + "Join {network}": "加入 {network}", + "Opacity for the current {name} appearance. Text and icons stay opaque.": "当前{name}外观的不透明度,文字和图标保持不透明。", + "24-hour": "24 小时制", + "12-hour": "12 小时制", + "Japanese holidays": "日本祝日", + "Edit holiday calculation function": "编辑祝日计算函数", + "Apply function": "应用函数", + "Checking function…": "正在校验函数…", + "Restore built-in function": "恢复内置函数", + "Holiday function error: {error}": "祝日函数错误:{error}", + "Rhai function japanese_holidays(year) returns a YYYY-MM-DD to name map. Built-in rules cover 1955–2099; unpublished years are estimates.": "Rhai 函数 japanese_holidays(year) 返回日期与名称的映射。内置规则适用 1955–2099 年,未公布年份按规则推算。", + "japanese_holiday_function": "日本祝日计算函数", + "Holiday function exceeds 16 KiB.": "祝日函数不能超过 16 KiB。", + "Edit Notifications": "编辑通知标记", + "Wed, 7 Oct · 14:35": "10 月 7 日 周三 · 14:35", + "Build": "构建", + "Build · 62%": "构建 · 62%", + "Build · 62% +1": "构建 · 62% +1", + "Calendar display": "日期显示", + "Calendar, Japanese holiday rules and custom holidays.": "日历、日本祝日规则与自定义节假日。", + "Reset {label}": "重置{label}", + "Workspace style: {label}": "工作区样式:{label}", + "Edit group {name}": "编辑分组 {name}", + "Edit {label} · {id}": "编辑{label} · {id}", + "Holiday function exceeded its operation limit.": "函数超过运算次数限制,请检查循环是否能够结束。", + "Holiday function exceeded its call depth limit.": "函数超过调用层数限制,请检查递归是否能够结束。", + "Holiday function must return YYYY-MM-DD keys.": "函数返回的日期必须使用 YYYY-MM-DD 格式。", + "Holiday function returned a date outside the requested year.": "函数返回了所请求年份以外的日期。", + "Holiday function must return text names.": "函数返回的节假日名称必须是文字。", + "now": "刚刚", + "{count}m ago": "{count} 分钟前", + "{count}h ago": "{count} 小时前", + "{count}d ago": "{count} 天前", + "A quiet moment": "暂时没有通知", + "Popups paused": "已暂停弹出通知", + "Popups enabled": "已启用弹出通知", + "{count} notification · {apps} app": "{count} 条通知 · {apps} 个应用", + "{count} notification · {apps} apps": "{count} 条通知 · {apps} 个应用", + "{count} notifications · {apps} app": "{count} 条通知 · {apps} 个应用", + "{count} notifications · {apps} apps": "{count} 条通知 · {apps} 个应用", + "Window tray": "活动窗口", + "window-tray": "活动窗口", + "No audio output available": "暂无可用的音频输出", + "Power mode": "电源模式", + "Power saver": "节能", + "Balanced": "均衡", + "Performance": "性能", + "Connected to power": "已连接电源", + "Running on battery power": "正在使用电池", + "Battery fully charged": "电池已充满", + "Battery status reported by the system": "电池状态由系统提供", + "Power off this computer?": "关闭这台电脑?", + "Restart this computer?": "重启这台电脑?", + "Suspend this computer?": "挂起这台电脑?", + "Log out of Ferese?": "退出 Ferese 会话?", + "Your apps will stay open. The session will not be locked.": "应用将保持打开,会话不会锁定。", + "Save your work. Your apps will close when you log out.": "请保存工作。退出会话时应用将关闭。", + "Save your work before continuing.": "请先保存工作。", + "{action} anyway": "仍然{action}", + "Power off": "关机", + "Default output": "默认输出" } diff --git a/crates/ferese-config/src/calendar.rs b/crates/ferese-config/src/calendar.rs new file mode 100644 index 0000000..749fda1 --- /dev/null +++ b/crates/ferese-config/src/calendar.rs @@ -0,0 +1,202 @@ +//! Editable Japanese holiday calculations and user-authored calendar dates. +pub use crate::calendar_rules::{DEFAULT_JAPANESE_HOLIDAY_FUNCTION, japanese_holidays}; +use jiff::civil::Date; +use serde::{Deserialize, Serialize}; +#[cfg(test)] +use std::{collections::BTreeMap, sync::OnceLock}; + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub show_adjacent_days: bool, + pub custom_holidays: Vec, + pub japanese_holiday_function: String, +} + +impl Default for Settings { + fn default() -> Self { + Self { + show_adjacent_days: true, + custom_holidays: Vec::new(), + japanese_holiday_function: DEFAULT_JAPANESE_HOLIDAY_FUNCTION.into(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Holiday { + /// YYYY-MM-DD for one date, or MM-DD for an annual holiday. + pub date: String, + pub name: String, +} + +impl Holiday { + pub fn validate(&self) -> Result<(), String> { + let valid = match self.date.len() { + 10 => self.date.parse::().is_ok(), + 5 => format!("2000-{}", self.date).parse::().is_ok(), + _ => false, + }; + if !valid { + return Err("Holiday date must be YYYY-MM-DD or MM-DD.".into()); + } + if self.name.trim().is_empty() || self.name.chars().count() > 80 { + return Err("Holiday name must contain 1–80 characters.".into()); + } + Ok(()) + } + fn matches(&self, date: Date) -> bool { + self.date == date.to_string() + || self.date == format!("{:02}-{:02}", date.month(), date.day()) + } +} + +impl Settings { + pub fn validate(&self) -> Result<(), String> { + for holiday in &self.custom_holidays { + holiday.validate()?; + } + validate_japanese_holiday_function(&self.japanese_holiday_function) + } + /// Custom names are combined with official names, preserving both on collisions. + #[must_use] + pub fn holiday_name(&self, date: Date) -> Option { + let mut names: Vec = + japanese_holidays(&self.japanese_holiday_function, date.year()) + .ok() + .and_then(|days| days.get(&date).cloned()) + .into_iter() + .collect(); + for holiday in &self.custom_holidays { + if holiday.matches(date) && !names.contains(&holiday.name) { + names.push(holiday.name.clone()); + } + } + (!names.is_empty()).then(|| names.join(" · ")) + } +} + +#[must_use] +#[cfg(test)] +fn official_japanese_holiday(date: Date) -> Option<&'static str> { + static HOLIDAYS: OnceLock> = OnceLock::new(); + HOLIDAYS + .get_or_init(|| { + include_str!("../data/jp-holidays.csv") + .lines() + .filter_map(|line| line.split_once(',')) + .collect() + }) + .get(date.to_string().as_str()) + .copied() +} + +pub fn validate_japanese_holiday_function(source: &str) -> Result<(), String> { + crate::calendar_rules::validate(source) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn custom_dates_round_trip_through_editable_kdl_records() { + let mut doc = + crate::Document::parse("status { calendar { show-adjacent-days #false; }; }\n") + .unwrap(); + doc.add( + "status.calendar.custom_holidays", + vec![ + ("date".into(), "12-24".into()), + ("name".into(), "个人假日".into()), + ], + ) + .unwrap(); + let source = doc.to_string(); + let mut doc = crate::Document::parse(&source).unwrap(); + let status: crate::status::Settings = + serde_json::from_value(doc.get("status").unwrap().clone()).unwrap(); + status.validate().unwrap(); + assert!(!status.calendar.show_adjacent_days); + assert_eq!( + status + .calendar + .holiday_name("2027-12-24".parse().unwrap()) + .as_deref(), + Some("个人假日") + ); + doc.set( + "status.calendar.custom_holidays.0.date", + "2026-02-30".into(), + ) + .unwrap(); + let status: crate::status::Settings = + serde_json::from_value(doc.get("status").unwrap().clone()).unwrap(); + assert!(status.validate().is_err()); + doc.remove("status.calendar.custom_holidays", 0).unwrap(); + assert!( + doc.get("status.calendar.custom_holidays") + .is_none_or(|v| v.as_array().is_some_and(|v| v.is_empty())) + ); + } + #[test] + fn official_dates_include_substitutes_bridges_and_olympic_exceptions() { + for (date, expected) in [ + ("2026-05-06", Some("休日")), + ("2026-09-22", Some("休日")), + ("2027-03-22", Some("休日")), + ("2021-07-22", Some("海の日")), + ("2021-07-19", None), + ("2026-10-28", None), + ("2028-01-01", None), + ] { + assert_eq!(official_japanese_holiday(date.parse().unwrap()), expected); + } + let dates: Vec<_> = include_str!("../data/jp-holidays.csv") + .lines() + .map(|s| s.split_once(',').unwrap().0) + .collect(); + assert!(dates.windows(2).all(|pair| pair[0] < pair[1])); + assert!(dates.iter().all(|date| date.parse::().is_ok())); + assert_eq!(dates.iter().filter(|d| d.starts_with("2026-")).count(), 18); + assert_eq!(dates.iter().filter(|d| d.starts_with("2027-")).count(), 17); + } + #[test] + fn custom_holidays_validate_and_repeat_without_inventing_leap_days() { + let mut settings = Settings::default(); + assert!(settings.show_adjacent_days); + settings.custom_holidays = vec![ + Holiday { + date: "02-29".into(), + name: "Leap day".into(), + }, + Holiday { + date: "2026-11-03".into(), + name: "My holiday".into(), + }, + ]; + assert!(settings.validate().is_ok()); + assert_eq!( + settings + .holiday_name("2024-02-29".parse().unwrap()) + .as_deref(), + Some("Leap day") + ); + assert_eq!( + settings + .holiday_name("2026-11-03".parse().unwrap()) + .as_deref(), + Some("文化の日 · My holiday") + ); + for date in ["2026-02-29", "13-01", "2026-1-1", "02-30"] { + assert!( + Holiday { + date: date.into(), + name: "Test".into() + } + .validate() + .is_err() + ); + } + } +} diff --git a/crates/ferese-config/src/calendar_rules.rs b/crates/ferese-config/src/calendar_rules.rs new file mode 100644 index 0000000..f9689d8 --- /dev/null +++ b/crates/ferese-config/src/calendar_rules.rs @@ -0,0 +1,206 @@ +use jiff::civil::Date; +use rhai::{AST, Engine, EvalAltResult, INT, ImmutableString, Scope}; +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex, OnceLock}, +}; + +pub const DEFAULT_JAPANESE_HOLIDAY_FUNCTION: &str = include_str!("../data/japanese-holidays.rhai"); +pub type Holidays = Arc>; + +fn evaluation_error(error: &EvalAltResult) -> String { + match error.unwrap_inner() { + EvalAltResult::ErrorTooManyOperations(_) => { + "Holiday function exceeded its operation limit.".into() + } + EvalAltResult::ErrorStackOverflow(_) => { + "Holiday function exceeded its call depth limit.".into() + } + _ => error.to_string(), + } +} + +fn script_date(value: &str) -> Result> { + value + .parse::() + .map_err(|_| format!("Invalid calendar date: {value}").into()) +} + +fn engine() -> Engine { + let mut engine = Engine::new(); + engine.set_module_resolver(rhai::module_resolvers::DummyModuleResolver::new()); + for symbol in ["import", "export", "eval", "print", "debug"] { + engine.disable_symbol(symbol); + } + engine + .set_max_operations(100_000) + .set_max_call_levels(16) + .set_max_expr_depths(32, 32) + .set_max_variables(64) + .set_max_functions(16) + .set_max_string_size(1024) + .set_max_array_size(400) + .set_max_map_size(400); + engine.on_print(|_| {}).on_debug(|_, _, _| {}); + engine.register_fn( + "date", + |year: INT, month: INT, day: INT| -> Result> { + script_date(&format!("{year:04}-{month:02}-{day:02}")).map(|d| d.to_string()) + }, + ); + engine.register_fn( + "weekday", + |date: ImmutableString| -> Result> { + script_date(&date).map(|d| INT::from(d.weekday().since(jiff::civil::Weekday::Sunday))) + }, + ); + engine.register_fn( + "add_days", + |date: ImmutableString, days: INT| -> Result> { + if !(-3660..=3660).contains(&days) { + return Err("Calendar date offset must be within ten years.".into()); + } + script_date(&date)? + .checked_add(jiff::Span::new().days(days)) + .map(|d| d.to_string()) + .map_err(|error| error.to_string().into()) + }, + ); + engine.register_fn("days_in_year", |year: INT| -> INT { + if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) { + 366 + } else { + 365 + } + }); + // Converting float -> integer is explicit, keeping the editable equinox formula readable. + engine.register_fn("floor", |value: rhai::FLOAT| -> INT { + value.floor() as INT + }); + engine +} + +struct Compiled { + source: String, + engine: Engine, + ast: Result, + years: BTreeMap>, +} + +/// Bounded, offline evaluation, compiled once per source and cached per visible year. +/// Scripts receive only civil-date helpers; imports, eval and host I/O are unavailable. +pub fn japanese_holidays(source: &str, year: i16) -> Result { + if source.len() > 16_384 { + return Err("Holiday function exceeds 16 KiB.".into()); + } + static CACHE: OnceLock>> = OnceLock::new(); + let mut cache = CACHE + .get_or_init(|| Mutex::new(None)) + .lock() + .map_err(|_| "Holiday function cache is unavailable.")?; + if cache.as_ref().is_none_or(|cached| cached.source != source) { + let engine = engine(); + let ast = engine + .compile(source) + .map_err(|error| format!("Holiday function: {error}")); + *cache = Some(Compiled { + source: source.into(), + engine, + ast, + years: BTreeMap::new(), + }); + } + let cache = cache.as_mut().unwrap(); + if let Some(result) = cache.years.get(&year) { + return result.clone(); + } + let result = (|| { + let ast = cache.ast.as_ref().map_err(Clone::clone)?; + let result = cache + .engine + .call_fn::( + &mut Scope::new(), + ast, + "japanese_holidays", + (INT::from(year),), + ) + .map_err(|error| evaluation_error(&error))?; + let mut holidays = BTreeMap::new(); + for (date, name) in result { + let date = date + .parse::() + .map_err(|_| "Holiday function must return YYYY-MM-DD keys.")?; + if date.year() != year { + return Err("Holiday function returned a date outside the requested year.".into()); + } + let name = name + .try_cast::() + .ok_or("Holiday function must return text names.")?; + if name.trim().is_empty() || name.chars().count() > 80 { + return Err("Holiday name must contain 1–80 characters.".into()); + } + holidays.insert(date, name.to_string()); + } + Ok(Arc::new(holidays)) + })(); + if cache.years.len() >= 8 { + cache.years.clear(); + } + cache.years.insert(year, result.clone()); + result +} + +pub fn validate(source: &str) -> Result<(), String> { + let year = jiff::Zoned::now().year(); + for year in [year - 1, year, year + 1] { + japanese_holidays(source, year)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn builtin_function_matches_every_official_date_and_name_through_2027() { + let mut official: BTreeMap> = BTreeMap::new(); + for line in include_str!("../data/jp-holidays.csv").lines() { + let (date, name) = line.split_once(',').unwrap(); + let date = date.parse::().unwrap(); + official + .entry(date.year()) + .or_default() + .insert(date, name.into()); + } + for (year, expected) in official { + let actual = japanese_holidays(DEFAULT_JAPANESE_HOLIDAY_FUNCTION, year).unwrap(); + assert_eq!(*actual, expected, "official reference mismatch in {year}"); + } + } + #[test] + fn edited_function_changes_dates_and_invalid_or_unbounded_code_is_rejected() { + let source = + r#"fn japanese_holidays(year) { let h=#{}; h[date(year,10,28)]="测试假日"; h }"#; + let result = japanese_holidays(source, 2026).unwrap(); + assert_eq!( + result + .get(&"2026-10-28".parse::().unwrap()) + .map(String::as_str), + Some("测试假日") + ); + for source in [ + "fn japanese_holidays(year) { loop {} }", + "import \"/etc/passwd\" as x; fn japanese_holidays(year) { #{} }", + "fn japanese_holidays(year) { eval(\"1+1\"); #{} }", + "fn japanese_holidays(year) { #{invalid: 3} }", + "fn japanese_holidays(year) { #{\"2026-02-30\":\"Bad date\"} }", + "fn japanese_holidays(year) { #{\"2025-01-01\":\"Wrong year\"} }", + ] { + assert!( + japanese_holidays(source, 2026).is_err(), + "accepted invalid source {source}" + ); + } + assert!(japanese_holidays(&" ".repeat(16_385), 2026).is_err()); + } +} diff --git a/crates/ferese-config/src/document.rs b/crates/ferese-config/src/document.rs index dc37481..090a107 100644 --- a/crates/ferese-config/src/document.rs +++ b/crates/ferese-config/src/document.rs @@ -101,6 +101,7 @@ fn is_records(key: &str, parent: &str) -> bool { | ("start" | "center" | "end", "groups") | ("groups", "items") | ("activities", "rules") + | ("calendar", "custom_holidays") ) } @@ -212,6 +213,7 @@ pub(crate) fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result &["keys", "action", "argument"], + "custom_holidays" => &["date", "name"], "binding_modes" => &["name"], "output_profiles" => &["name"], "outputs" => &["match"], @@ -918,6 +920,7 @@ fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) -> if rest.len() == 1 { let positions: &[&str] = match key { "bindings" => &["keys", "action", "argument"], + "custom_holidays" => &["date", "name"], "binding_modes" => &["name"], "outputs" => &["match"], "output_profiles" => &["name"], @@ -1035,6 +1038,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result let positions: &[&str] = match key { "rules" if parent == "activities" => &["name"], "bindings" => &["keys", "action", "argument"], + "custom_holidays" => &["date", "name"], "binding_modes" => &["name"], "output_profiles" => &["name"], "outputs" => &["match"], diff --git a/crates/ferese-config/src/lib.rs b/crates/ferese-config/src/lib.rs index 234cff2..ac9b4c5 100644 --- a/crates/ferese-config/src/lib.rs +++ b/crates/ferese-config/src/lib.rs @@ -1,5 +1,7 @@ //! KDL documents, validation and persisted configuration values. pub mod activities; +pub mod calendar; +mod calendar_rules; mod document; pub mod families; pub mod file; diff --git a/crates/ferese-config/src/status.rs b/crates/ferese-config/src/status.rs index 927260d..440e454 100644 --- a/crates/ferese-config/src/status.rs +++ b/crates/ferese-config/src/status.rs @@ -7,6 +7,7 @@ pub struct Settings { pub low_battery_threshold: u8, pub settings_command: Option>, pub tray: Tray, + pub calendar: crate::calendar::Settings, } impl Default for Settings { fn default() -> Self { @@ -15,11 +16,13 @@ impl Default for Settings { low_battery_threshold: 20, settings_command: Some(vec!["ferese-settings".into()]), tray: Tray::default(), + calendar: Default::default(), } } } impl Settings { pub fn validate(&self) -> Result<(), String> { + self.calendar.validate()?; if self.low_battery_threshold > 100 { return Err("low-battery-threshold must be between 0 and 100".into()); } diff --git a/crates/ferese-panel/src/morph.rs b/crates/ferese-panel/src/morph.rs index efa6833..db01987 100644 --- a/crates/ferese-panel/src/morph.rs +++ b/crates/ferese-panel/src/morph.rs @@ -40,7 +40,7 @@ impl Default for Profile { expanded_width: 340., workspaces_height: 176., media_height: 170., - calendar_height: 230., + calendar_height: 650., notifications_height: 176., controls_height: 248., expanded_radius: 26., @@ -415,7 +415,7 @@ mod tests { assert!(!hub.active()); hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.]) .unwrap(); - assert_eq!(hub.target_size(), [340., 230.]); + assert_eq!(hub.target_size(), [340., 600.]); // clipped to the output hub.advance(Duration::from_millis(120), false); assert_eq!(hub.sample().opacity, 0.); assert_eq!(hub.sample().previous_opacity, 0.); @@ -469,7 +469,7 @@ mod tests { } let overshoot = (peak - 340.) / (340. - 150.); assert!((0.04..0.05).contains(&overshoot)); - assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 340., 230.)); + assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 340., 600.)); assert!(!hub.active()); } diff --git a/crates/ferese-panel/src/panel/composition.rs b/crates/ferese-panel/src/panel/composition.rs index 559413f..697e2ca 100644 --- a/crates/ferese-panel/src/panel/composition.rs +++ b/crates/ferese-panel/src/panel/composition.rs @@ -127,6 +127,7 @@ pub enum ItemKind { style: WorkspaceStyle, }, FocusedWindow, + WindowTray, Media, Activities, QuickSettings, @@ -152,6 +153,7 @@ impl ItemKind { pub fn minimum_width(self) -> Option { match self { Self::FocusedWindow => Some(48.), + Self::WindowTray => Some(30.), Self::Workspaces { .. } => Some(24.), Self::Media => Some(112.), _ => None, @@ -164,6 +166,7 @@ impl ItemKind { Self::Overview => "Overview", Self::Workspaces { .. } => "Workspaces", Self::FocusedWindow => "Focused window", + Self::WindowTray => "Window tray", Self::Media => "Media", Self::Activities => "Activities", Self::QuickSettings => "Quick Settings", @@ -188,7 +191,7 @@ impl ItemKind { &[Wide, Compact, Icon] } Self::Battery { percentage: true } => &[Wide, Icon], - Self::Workspaces { .. } | Self::FocusedWindow => &[Wide], + Self::Workspaces { .. } | Self::FocusedWindow | Self::WindowTray => &[Wide], _ => &[Icon], } } @@ -311,7 +314,10 @@ impl Item { metadata: BTreeMap::new(), overflow: if matches!( kind, - ItemKind::Overview | ItemKind::Workspaces { .. } | ItemKind::FocusedWindow + ItemKind::Overview + | ItemKind::Workspaces { .. } + | ItemKind::FocusedWindow + | ItemKind::WindowTray ) { OverflowPolicy::Never } else { @@ -698,6 +704,84 @@ impl Panel { .find(|item| &item.id == id) } + /// Project older panel files onto the consolidated time/control entry without + /// rewriting their source or changing surviving item identities. + pub fn consolidate_clock_controls(&mut self) { + let kinds: Vec<_> = [&self.start, &self.center, &self.end] + .into_iter() + .flat_map(|z| &z.groups) + .flat_map(|g| &g.items) + .map(|i| i.kind) + .collect(); + // Only migrate legacy presets. A deliberately removed or hidden tray in + // a current composition must stay removed or hidden. + if kinds.iter().any(|k| { + matches!( + k, + ItemKind::QuickSettings | ItemKind::Network | ItemKind::NightLight + ) + }) && !kinds.contains(&ItemKind::WindowTray) + { + let mut suffix = 0; + let mut id = ItemId("window-tray".into()); + while self.item(&id).is_some() { + suffix += 1; + id = ItemId(format!("window-tray-{suffix}")); + } + if let Some(group) = self.end.groups.first_mut() { + group + .items + .insert(0, Item::new(&id.0, ItemKind::WindowTray)); + } + } + let has_clock = [&self.start, &self.center, &self.end] + .into_iter() + .flat_map(|z| &z.groups) + .flat_map(|g| &g.items) + .any(|i| i.kind == ItemKind::Clock); + let mut clock_exists = has_clock; + for zone in [&mut self.start, &mut self.center, &mut self.end] { + for group in &mut zone.groups { + group.items.retain_mut(|item| match item.kind { + ItemKind::QuickSettings if !clock_exists => { + item.kind = ItemKind::Clock; + clock_exists = true; + true + } + ItemKind::QuickSettings | ItemKind::Network | ItemKind::NightLight => false, + _ => true, + }); + } + } + if !clock_exists { + return; + } + let mut notices = Vec::new(); + for zone in [&mut self.start, &mut self.center, &mut self.end] { + for group in &mut zone.groups { + group.items.retain(|item| { + if item.kind == ItemKind::Notifications { + notices.push(item.clone()); + false + } else { + true + } + }); + } + } + for zone in [&mut self.start, &mut self.center, &mut self.end] { + for group in &mut zone.groups { + if let Some(index) = group.items.iter().position(|i| i.kind == ItemKind::Clock) { + for item in &mut notices { + item.gap_before = Some(0.); + } + group.items.splice(index + 1..index + 1, notices); + return; + } + } + } + } + /// Build a stable initial composition; presets are an editor convenience. #[must_use] pub fn from_preset(preset: PanelStyle) -> Self { @@ -714,18 +798,17 @@ impl Panel { ); let title = item("focused-window", FocusedWindow); let controls = vec![ + item("window-tray", WindowTray), item("media", Media), - item("quick-settings", QuickSettings), item("tray", Tray), - item("night-light", NightLight), - item("network", Network), item("audio", Audio), item("recording", Recording), item("capture-status", CaptureStatus), - item("notifications", Notifications), item("battery", Battery { percentage: true }), ]; let clock = item("clock", Clock); + let mut notifications = item("notifications", Notifications); + notifications.gap_before = Some(0.); let display = item("display-mode", DisplayMode); let (start, center, end) = match preset { @@ -737,13 +820,13 @@ impl Panel { Group::new("title", vec![title], [0, 0], padding), vec![ Group::new("status", controls, [2, 3], padding), - Group::new("time", vec![clock], [2, 3], padding), + Group::new("time", vec![clock, notifications], [2, 3], padding), Group::new("display", vec![display], [0, 0], padding), ], ), _ => { let mut controls = controls; - controls.extend([clock, display]); + controls.extend([clock, notifications, display]); ( vec![Group::new( "navigation", @@ -835,17 +918,15 @@ mod tests { "overview", "workspaces", "focused-window", + "window-tray", "media", - "quick-settings", "tray", - "night-light", - "network", "audio", "recording", "capture-status", - "notifications", "battery", "clock", + "notifications", "display-mode", ]; for panel in [&continuous, &islands] { @@ -876,8 +957,7 @@ mod tests { "overview", "workspaces", "focused-window", - "quick-settings", - "night-light", + "window-tray", "recording", "clock" ] @@ -898,7 +978,72 @@ mod tests { .map(|item| item.id.0.as_str()) .collect::>() ); - assert_eq!(items(&panel).len(), 15); + assert_eq!(items(&panel).len(), 13); + } + + #[test] + fn legacy_window_tray_is_seeded_once_without_resurrecting_current_removed_items() { + let mut panel = Panel::default(); + panel.end.groups[0] + .items + .retain(|i| i.kind != ItemKind::WindowTray); + panel.consolidate_clock_controls(); + assert!(!items(&panel).iter().any(|i| i.kind == ItemKind::WindowTray)); + panel.end.groups[0] + .items + .push(Item::new("old-controls", ItemKind::QuickSettings)); + // The instance name can already belong to a different authored item. + panel.start.groups[0].items[0].id = ItemId("window-tray".into()); + panel.consolidate_clock_controls(); + let tray = items(&panel) + .into_iter() + .find(|i| i.kind == ItemKind::WindowTray) + .unwrap(); + assert_eq!(tray.id.0, "window-tray-1"); + validate(std::slice::from_ref(&panel)).unwrap(); + let once = panel.clone(); + panel.consolidate_clock_controls(); + assert_eq!(panel, once); + } + + #[test] + fn legacy_controls_consolidate_beside_the_clock_and_preserve_notification_state() { + let mut panel = Panel::default(); + let mut notice = panel.end.groups[1].items.pop().unwrap(); + notice.visible = false; + notice.metadata.insert("custom".into(), "retained".into()); + panel.start.groups[0].items.extend([ + Item::new("old-network", ItemKind::Network), + Item::new("old-controls", ItemKind::QuickSettings), + notice.clone(), + ]); + panel.consolidate_clock_controls(); + let all = items(&panel); + assert!(!all.iter().any(|i| matches!( + i.kind, + ItemKind::Network | ItemKind::QuickSettings | ItemKind::NightLight + ))); + let clock = all.iter().position(|i| i.kind == ItemKind::Clock).unwrap(); + let moved = all[clock + 1]; + assert_eq!(moved.id, notice.id); + assert!(!moved.visible); + assert_eq!(moved.metadata, notice.metadata); + let once = panel.clone(); + panel.consolidate_clock_controls(); + assert_eq!(panel, once); + for zone in [&mut panel.start, &mut panel.center, &mut panel.end] { + for group in &mut zone.groups { + group.items.retain(|i| i.kind != ItemKind::Clock); + } + } + panel.end.groups[0] + .items + .push(Item::new("controls-only", ItemKind::QuickSettings)); + panel.consolidate_clock_controls(); + assert_eq!( + panel.item(&ItemId("controls-only".into())).unwrap().kind, + ItemKind::Clock + ); } #[test] diff --git a/crates/ferese-panel/src/panel/layout.rs b/crates/ferese-panel/src/panel/layout.rs index 43ffccc..3a43079 100644 --- a/crates/ferese-panel/src/panel/layout.rs +++ b/crates/ferese-panel/src/panel/layout.rs @@ -840,14 +840,14 @@ mod tests { .groups .iter_mut() .flat_map(|group| &mut group.items) - .find(|item| item.id.0 == "network") + .find(|item| item.id.0 == "audio") .unwrap() .overflow = OverflowPolicy::Always; let mut measured = measurements(&panel); measured.retain(|item| item.id.0 != "media"); let resolution = resolve(&panel, &measured, 2000., 32.); assert!(!resolution.items.contains_key(&ItemId("media".into()))); - assert_eq!(resolution.overflow, [ItemId("network".into())]); + assert_eq!(resolution.overflow, [ItemId("audio".into())]); } #[test] fn priority_controls_displacement_and_expansion_restores_requested_sizes() { diff --git a/crates/ferese-theme/src/calendar.rs b/crates/ferese-theme/src/calendar.rs index d5db338..c84e073 100644 --- a/crates/ferese-theme/src/calendar.rs +++ b/crates/ferese-theme/src/calendar.rs @@ -36,7 +36,7 @@ pub fn month(today: jiff::civil::Date, offset: i32) -> (jiff::civil::Date, usize let year = month_index.div_euclid(12) as i16; let month = (month_index.rem_euclid(12) + 1) as i8; let first = jiff::civil::Date::new(year, month, 1).expect("calendar month is in range"); - let start = usize::try_from(first.weekday().since(jiff::civil::Weekday::Monday)).unwrap_or(0); + let start = usize::try_from(first.weekday().since(jiff::civil::Weekday::Sunday)).unwrap_or(0); let days = usize::try_from(first.days_in_month()).unwrap_or(31); (first, start, days) } @@ -49,8 +49,11 @@ pub fn grid<'a, M: Clone + 'a>( font: cosmic::font::Font, opacity: f32, navigation: [Element<'a, M>; 3], + settings: &ferese_config::calendar::Settings, ) -> Element<'a, M> { - grid_with_density(today, offset, palette, font, opacity, navigation, false) + grid_with_density( + today, offset, palette, font, opacity, navigation, false, settings, + ) } #[must_use] @@ -61,8 +64,11 @@ pub fn compact_grid<'a, M: Clone + 'a>( font: cosmic::font::Font, opacity: f32, navigation: [Element<'a, M>; 3], + settings: &ferese_config::calendar::Settings, ) -> Element<'a, M> { - grid_with_density(today, offset, palette, font, opacity, navigation, true) + grid_with_density( + today, offset, palette, font, opacity, navigation, true, settings, + ) } fn grid_with_density<'a, M: Clone + 'a>( @@ -73,13 +79,10 @@ fn grid_with_density<'a, M: Clone + 'a>( opacity: f32, navigation: [Element<'a, M>; 3], compact: bool, + settings: &ferese_config::calendar::Settings, ) -> Element<'a, M> { - let cell_width = if compact { - Length::Fill - } else { - Length::Fixed(30.) - }; - let cell_height = if compact { 20. } else { 25. }; + let cell_width = Length::Fill; + let cell_height = if compact { 24. } else { 28. }; let spacing = if compact { 2. } else { 3. }; let months = [ "January", @@ -130,7 +133,7 @@ fn grid_with_density<'a, M: Clone + 'a>( } else { Length::Shrink }); - for label in ["M", "T", "W", "T", "F", "S", "S"] { + for label in weekday_labels(ferese_config::locale::resolved()) { weekdays = weekdays.push( container( crate::text(label, font) @@ -150,42 +153,187 @@ fn grid_with_density<'a, M: Clone + 'a>( }); for weekday in 0..7 { let cell = week * 7 + weekday; - let day = cell - .checked_sub(start) - .map(|day| day + 1) - .filter(|day| *day <= days); - let is_today = - year == today.year() && month == today.month() && day == Some(today.day() as usize); - let label = day.map_or(String::new(), |day| day.to_string()); - dates = dates.push( - container( - crate::text(label, font) - .size(12) - .class(theme::Text::Color(if is_today { primary } else { muted })), - ) - .width(cell_width) - .height(cell_height) - .center_x(cell_width) - .center_y(cell_height) - .class(theme::Container::custom(move |_| container::Style { - background: is_today - .then_some(Background::Color(palette.accent.scale_alpha(0.2 * opacity))), - border: Border { - shape: BorderShape::Continuous, - radius: palette.radius.min(12.).into(), - ..Default::default() - }, + let date = first + .checked_add(jiff::Span::new().days(cell as i64 - start as i64)) + .ok(); + let in_month = date.is_some_and(|date| date.month() == month && date.year() == year); + let visible = in_month || settings.show_adjacent_days; + let date = date.filter(|_| visible); + let is_today = date == Some(today); + let holiday = date.and_then(|date| settings.holiday_name(date)); + let holiday_color = if palette.appearance == ferese_config::theme::Appearance::Light { + cosmic::iced::Color::from_rgb8(184, 38, 49) + } else { + cosmic::iced::Color::from_rgb8(244, 110, 120) + } + .scale_alpha(opacity * if in_month { 1. } else { 0.65 }); + let foreground = if holiday.is_some() { + holiday_color + } else if is_today || in_month { + primary + } else { + muted.scale_alpha(0.6) + }; + let label = date.map_or(String::new(), |date| date.day().to_string()); + let font = cosmic::iced::Font { + weight: if holiday.is_some() { + cosmic::iced::font::Weight::Bold + } else { + cosmic::iced::font::Weight::Normal + }, + ..font + }; + let day: Element<'a, M> = container( + crate::text(label, font) + .size(12) + .class(theme::Text::Color(foreground)), + ) + .id(cosmic::iced::advanced::widget::Id::new(date.map_or_else( + || format!("calendar-empty-{cell}"), + |date| format!("calendar-date-{date}"), + ))) + .width(cell_width) + .height(cell_height) + .center_x(cell_width) + .center_y(cell_height) + .class(theme::Container::custom(move |_| container::Style { + background: is_today + .then_some(Background::Color(palette.accent.scale_alpha(0.2 * opacity))), + border: Border { + shape: BorderShape::Continuous, + radius: palette.radius.min(12.).into(), ..Default::default() - })), - ); + }, + ..Default::default() + })) + .into(); + dates = dates.push(if let Some(name) = holiday { + cosmic::widget::tooltip( + day, + crate::text(name, font).size(11), + cosmic::widget::tooltip::Position::Top, + ) + .into() + } else { + day + }); } grid = grid.push(dates); } + if let Err(error) = + ferese_config::calendar::japanese_holidays(&settings.japanese_holiday_function, year) + { + grid = grid.push( + crate::text( + ferese_config::locale::format( + "Holiday function error: {error}", + &[("error", error)], + ), + font, + ) + .size(10) + .class(theme::Text::Color(muted)), + ); + } grid.into() } +/// Sunday is always the first column; labels follow Ferese's interface language. +#[must_use] +pub fn weekday_labels(language: ferese_config::locale::Language) -> [&'static str; 7] { + if language == ferese_config::locale::Language::Chinese { + ["日", "一", "二", "三", "四", "五", "六"] + } else { + ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + } +} + #[cfg(test)] mod tests { + #[test] + fn sunday_grid_localizes_weekdays_and_can_hide_adjacent_dates_without_reflow() { + use cosmic::iced::advanced::renderer::Headless; + use cosmic::iced::advanced::widget::{Id, Operation, Tree}; + use cosmic::iced::advanced::{Layout, layout}; + use cosmic::iced::{Font, Pixels, Rectangle, Size}; + use ferese_config::locale::Language; + assert_eq!( + super::weekday_labels(Language::Chinese), + ["日", "一", "二", "三", "四", "五", "六"] + ); + assert_eq!( + super::weekday_labels(Language::English), + ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + ); + #[derive(Default)] + struct Dates { + first: Option, + sunday: Option, + holiday: Option, + } + impl Operation for Dates { + fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) { + children(self); + } + fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { + if id == Some(&Id::new("calendar-date-2026-10-01")) { + self.first = Some(bounds); + } + if id == Some(&Id::new("calendar-date-2026-09-27")) { + self.sunday = Some(bounds); + } + if id == Some(&Id::new("calendar-date-2026-10-12")) { + self.holiday = Some(bounds); + } + } + } + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let renderer = runtime + .block_on(::new( + Font::default(), + Pixels(14.), + Some("tiny-skia"), + )) + .unwrap(); + let mut first = None; + for adjacent in [true, false] { + let settings = ferese_config::calendar::Settings { + show_adjacent_days: adjacent, + ..Default::default() + }; + let mut view: cosmic::Element<'_, ()> = super::grid( + "2026-10-28".parse().unwrap(), + 0, + crate::Palette::from_resolved(&ferese_config::theme::default_theme()), + Font::default(), + 1., + std::array::from_fn(|_| cosmic::widget::Space::new().width(20).height(20).into()), + &settings, + ); + let mut tree = Tree::new(&view); + let node = view.as_widget_mut().layout( + &mut tree, + &renderer, + &layout::Limits::new(Size::ZERO, Size::new(330., 600.)), + ); + let mut dates = Dates::default(); + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &renderer, &mut dates); + assert_eq!(dates.sunday.is_some(), adjacent); + assert!(dates.holiday.is_some()); + let current = dates.first.unwrap(); + if let Some(first) = first { + assert_eq!(current, first); + } else { + first = Some(current); + } + if let Some(sunday) = dates.sunday { + assert!(sunday.x < current.x && (sunday.y - current.y).abs() < 0.5); + } + } + } #[test] fn clock_uses_lowercase_date_and_twelve_hour_time() { for (stamp, expected) in [ @@ -210,14 +358,14 @@ mod tests { fn calendar_aligns_leap_months_and_year_boundaries() { let february = jiff::civil::Date::new(2024, 2, 15).unwrap(); let (first, start, days) = super::month(february, 0); - assert_eq!((first.year(), first.month(), start, days), (2024, 2, 3, 29)); + assert_eq!((first.year(), first.month(), start, days), (2024, 2, 4, 29)); let (first, start, days) = super::month(february, -2); assert_eq!( (first.year(), first.month(), start, days), - (2023, 12, 4, 31) + (2023, 12, 5, 31) ); let june = jiff::civil::Date::new(2025, 6, 1).unwrap(); let (_, start, days) = super::month(june, 0); - assert_eq!((start, days), (6, 30)); + assert_eq!((start, days), (0, 30)); } } diff --git a/docs/development/visual-direction.md b/docs/development/visual-direction.md index ddb3f2f..833b0ad 100644 --- a/docs/development/visual-direction.md +++ b/docs/development/visual-direction.md @@ -1,8 +1,8 @@ # Ferese Graphite 视觉方案 -- 文档版本:1.1 +- 文档版本:1.3 - 整理日期:2026-10-28 -- 视觉基线:`1e34ec4`;本版补充内容密度、详细页分组与窗口交互规则。 +- 视觉基线:`1e34ec4`、`9908dde`;本版补充导航滚动、本地化、时间控制面板、活动窗口与可编辑祝日函数。 - 适用范围:Ferese 设置、Shell 共享控件、锁屏与原生 Rime 候选界面的主题协同。 ## 1. 视觉目标 @@ -75,8 +75,9 @@ | 分组标题,含侧栏和详细页 | 14 px | Bold | | 总览卡片标题 | 14 px | Regular | | 侧栏导航、普通字段名称 | 13 px | Regular | -| 字段辅助说明、卡片说明 | 12 px | Regular、次级色 | -| 保存状态与辅助提示 | 11–12 px | Regular、次级色 | +| 字段补充说明与辅助提示 | 10.5 px | Regular、次级色,与 13 px 字段名称明确区分 | +| 卡片说明、代码编辑器 | 11 px | Regular | +| 保存状态 | 11 px | Regular、次级色 | 字段名称与短说明在同一段内自然排版,仅在可用宽度不足时换行;说明使用次级色区分,不另起固定的一行。页面标题下不重复展示泛化副标题。卡片高度跟随实际内容,适应中英文;细则与控件文字均使用标准字重。 @@ -115,7 +116,11 @@ | 导航行内元素间距 | 8 px | | 选中指示条 | 2 × 14 px | | 普通输入框圆角上限 | 7 px | -| 按钮圆角上限 | 9 px | +| 设置按钮圆角上限 | 5 px | +| 按钮组圆角上限 | 6 px | +| 按钮组内边距 | 5 px | +| 选项按钮内边距 | 上下 6 px、左右 10 px;页内标签为 8 / 14 px | +| 导航滚动条 | 宽 4 px,与选项间隔 8 px,单独占位 | | 分区容器圆角上限 | 10 px | | 常规边界 | 1 px | | 键盘焦点轮廓 | 2 px | @@ -131,7 +136,7 @@ Appearance 作为全部设置的入口,内容按实际功能分组: | 快速访问 | 常规、全部配置 | | 桌面 | 主题、壁纸、面板与 Shell、托盘图标 | | 交互 | 窗口、键盘与鼠标、辅助功能 | -| 系统 | 显示器、连接、通知、锁屏、登录启动项 | +| 系统 | 显示器、连接、通知、日历、锁屏、登录启动项 | 总览使用等宽卡片,文字区域占用弹性空间,箭头在卡片末端对齐。 @@ -151,6 +156,7 @@ Appearance 作为全部设置的入口,内容按实际功能分组: | 详细页 | 主要分组 | | --- | --- | | 常规 | 界面 | +| 日历 | 日期显示、日本祝日、自定义节假日 | | 窗口 | 工作区与布局、间距与圆角、焦点与窗口效果 | | 键盘与鼠标 | 键盘布局、按键重复、鼠标、触控板 | | 主题 | 配色与强调色、表面与透明度、字体与排版;自动外观按需展开 | @@ -197,6 +203,43 @@ Appearance 作为全部设置的入口,内容按实际功能分组: - 背景、边界与选中态通过共享主题保持一致。 - 保留数字键选字以及数字行 `-`、`=` / `+` 翻页的既有行为。 +### 7.3 导航、按钮组与本地化 + +- 导航滚动条嵌入独立通道,绘制区域和点击区域均不覆盖导航项。直接打开靠下的页面、切换页面及打开抽屉时,将当前选项完整滚入视口。 +- 按钮组与内部按钮使用保留少量圆角的矩形。组背景采用轻微表面差,扩大内边距,避免形成明显的厚重外框。 +- 标准设置名称、说明、枚举、面板项目、连接操作和提示通过统一语言目录显示。配置键、动作标识、应用名、SSID 和用户内容保留原值。 + +### 7.4 时间入口与通知标记 + +- 时间右侧紧邻通知标记,二者保持紧凑间距。标记为 **3 × 13 px**、圆角 **1.5 px** 的竖条;点击区域固定宽 **11 px**。 +- 有通知时竖条为白色,无通知时透明,状态变化不改变布局宽度。点击仍可打开通知历史。 +- 移除独立控制中心、Wi-Fi 和夜光快捷图标。旧面板配置在显示时合并时间相关入口,保留其余项目的 ID、可见性和自定义内容。 +- 点击时间打开组合组件:上方日历,下方控制中心。控制中心保留外观、网络、蓝牙、音量、亮度与电源管理,移除夜光和勿扰开关。 +- 音量区显示当前输出设备、百分比、音量滑块与明确标注的静音开关;设备暂不可用时显示简短状态。 +- 电源区按设备能力显示节能、均衡、性能模式,以及重启、关机、挂起操作。重启、关机、挂起先打开确认弹窗;取消保持当前会话。 +- 常规组合弹层宽 360 px,高度随内容变化并受可用空间限制;内容过长时可滚动。Morph 样式同步提供组合内容。 + +### 7.5 活动窗口区 + +- 活动窗口区位于面板右侧、系统托盘区左边,与工作区显示空间独立。排列所有已打开窗口,每个窗口一个应用图标,关闭窗口即移除;不保留已关闭应用的固定图标。 +- 点击图标切换到对应窗口,支持同一应用的多个窗口和跨工作区切换。以启动顺序保持稳定位置,突出当前焦点窗口。 +- 图标 18 px,按钮宽 30 px,间隔 2 px;悬停显示窗口标题,标题为空时使用应用标识。窗口过多时在分配宽度内横向滚动,默认最大宽 360 px。 +- 面板编辑器可添加、隐藏、移动“活动窗口”项目;旧预设在合并时间入口时补齐该项目,新配置中主动移除后不会自动恢复。 + +### 7.6 日历与祝日函数 + +- 每周从周日开始;星期标题随 Ferese 语言变化,中文为“日、一、二、三、四、五、六”,英文为 Sun 至 Sat。 +- 日本祝日和自定义节假日的日期使用红色粗体,悬停可查看名称;当前日期底色与节假日文字样式分别表达。 +- 默认显示相邻月份的日期,在“日历 → 日期显示”中可关闭。开关不改变本月日期的列对齐。 +- “日历 → 日本祝日”右侧仅提供代码图标作为函数编辑入口。点击展开 280 px 高的多行编辑器;未应用代码在收起、再次打开后继续保留。 +- 编辑器提供“应用函数”和“恢复内置函数”。恢复只替换编辑内容,仍需应用;语法或日期结果错误时保留草稿和诊断,当前日历继续使用已保存函数。 +- 内置源为 [japanese-holidays.rhai](../../crates/ferese-config/data/japanese-holidays.rhai),入口 `japanese_holidays(year)` 返回 `YYYY-MM-DD → 名称` 映射。辅助函数为 `date(year, month, day)`、`weekday(date)`(周日为 0)、`add_days(date, n)`、`days_in_year(year)` 和 `floor(value)`。 +- 规则包含历史制度调整、振替休日、夹在祝日间的休日、即位等特例,以及 2020 / 2021 年奥运调整。适用范围为 1955–2099 年;已与内阁府公布的 1955–2027 年全部 1067 条日期和名称核对一致。未公布年份按规则推算,春秋分日以官报公布为准。 +- 解释器离线运行,禁用导入与动态求值,并限制代码长度、运算量、调用深度和容器大小。编译结果和年度结果缓存,函数校验在后台执行。 +- 自定义节假日支持 `YYYY-MM-DD` 单次日期和 `MM-DD` 每年重复;同一天可同时显示日本祝日与个人节假日名称。 + +来源:[内阁府祝日资料](https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html)、[国立天文台春秋分日说明](https://www.nao.ac.jp/faq/a0301.html)、[Rhai 运算限制](https://rhai.rs/book/safety/max-operations.html)。官方快照作为回归校验资料,日历运行时使用可编辑函数。 + ## 8. 动效原则 保留现有动画与“减少动画”配置。动效服务于状态变化与空间关系,悬停和按下反馈保持简洁。 @@ -217,6 +260,17 @@ theme { 将 `mode` 改为 `"light"` 可使用同一主题族的浅色外观。现有其他主题仍可从“外观与设置 → 主题”切换。 +```kdl +status { + calendar { + show-adjacent-days #true + custom-holidays "12-24" "个人假日" + } +} +``` + +祝日函数通过设置编辑,保存在 `status.calendar.japanese_holiday_function`;未覆盖时使用内置函数。 + ## 10. 验收与实现索引 ### 验收要点 @@ -238,17 +292,22 @@ theme { | 验证项 | 结果 | | --- | --- | -| 设置测试 | 117 项通过,9 项可选检查默认跳过 | +| 设置测试 | 120 项通过,9 项可选检查默认跳过 | | 滚动渲染专项 | 额外运行 1 项通过,覆盖 WGPU / tiny-skia 与两种主题配置 | -| 配置测试 | 46 项单元测试及 11 项集成测试通过 | +| 配置测试 | 51 项单元测试及 11 项集成测试通过 | +| 共享主题 / 面板 / Shell | 33 / 69 / 253 项通过 | | 布局检查 | 覆盖 480–1800 px 窗口宽度 | | 原生预览 | 已检查深浅外观及 631 px 窄窗口 | -| 导航居中 | 全部 15 项;包含“交互”分组和实际悬停检查 | +| 导航居中 | 全部 16 项;包含独立滚动条通道检查 | | Rime | 原生输入、候选、翻页、主题变化与进程恢复通过 | | 边缘双击 | 原生预览内 1272 px 最大化与 631 px 还原双向切换通过,配置内容不变 | | 导航抽屉 | 已移除关闭按钮,外侧点击和 Esc 关闭均检查通过 | +| 时间控制面板 | 原生预览显示真实音频输出、音量与静音控件、电源模式;关机确认后取消,会话保持运行 | +| 活动窗口区 | 位于右侧托盘左边;三个窗口点击、相邻图标切换、跨工作区激活、关闭移除与重新打开均通过 | +| 祝日函数 | 原生编辑并应用函数可改变日历;无限循环被拒绝且保存配置不变,恢复内置函数后配置校验通过 | +| 通知标记 | 独立预览中创建并移除测试通知,检查白色与透明状态,点击区域宽度保持不变 | -共享主题 32 项、Shell 247 项的验证记录来自前版基线 `1e34ec4`。本版未改动这两部分的实现。 +滚动渲染专项、边缘双击与窄窗口抽屉的记录沿用前版 `9908dde`。本版原生检查使用独立嵌套预览,保留原预览中的应用;没有重新执行关机、挂起或切换实际桌面会话。主机显示器、默认应用关联和 GNOME 输入源配置与开始时一致。 ### 实现位置 @@ -260,8 +319,13 @@ theme { | 页面结构与导航居中 | `shell/ferese-settings/src/pages.rs` | | 详细页字段分组 | `shell/ferese-settings/src/schema.rs` | | 行内说明与标题字重 | `shell/ferese-settings/src/form_controls.rs` | +| 日历设置与代码编辑器 | `shell/ferese-settings/src/calendar_controls.rs` | +| 日历日期和星期显示 | `crates/ferese-theme/src/calendar.rs` | +| 祝日函数执行与校验 | `crates/ferese-config/src/calendar_rules.rs` | +| 时间弹层与控制中心 | `shell/ferese-shell/src/status_ui/` | +| 活动窗口与焦点切换 | `shell/ferese-shell/src/window_tray.rs` | | 总览卡片与双列布局 | `shell/ferese-settings/src/all_options.rs` | | 分区表面与图标 | `shell/ferese-settings/src/visuals.rs` | | 布局及对齐回归检查 | `shell/ferese-settings/src/scroll_tests.rs` | -前版参考帧及导航居中记录保存在 `.artifacts/visual-reference-20261028/`;本版详细页、导航抽屉与窗口双击记录保存在 `.artifacts/settings-density-20261028/`。这些本机验收资料不随仓库分发。 +前版记录保存在 `.artifacts/visual-reference-20261028/` 与 `.artifacts/settings-density-20261028/`;本版日历、函数编辑器和通知标记的记录保存在 `.artifacts/time-hub-20261028/`。这些本机验收资料不随仓库分发。 diff --git a/scripts/rime-preview.py b/scripts/rime-preview.py index 25adfd0..b75fc3d 100644 --- a/scripts/rime-preview.py +++ b/scripts/rime-preview.py @@ -85,6 +85,12 @@ def environment(): "FERESE_ENABLE_TEST_POINTER": "1", "FERESE_ENABLE_SCREENCOPY": "1", "PATH": f"{ROOT}/target/debug:" + os.environ.get("PATH", "/usr/bin"), "XDG_DATA_DIRS": f"{Path.home()}/.local/share:/usr/local/share:/usr/share"} values.update({f"XDG_{kind.upper()}_HOME": str(DATA / "profile" / kind) for kind in ("config", "data", "cache", "state")}) + # Keep Wayland, D-Bus and input methods private while allowing the nested + # shell to inspect the same audio server as the real desktop session. + host_runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) + if (host_runtime / "pipewire-0").exists(): + values["PIPEWIRE_RUNTIME_DIR"] = str(host_runtime) + values["TMPDIR"] = str(DATA / "tmp") return values @@ -124,6 +130,7 @@ def start(): return DATA.mkdir(mode=0o700,parents=True,exist_ok=True) DATA.chmod(0o700) + (DATA / "tmp").mkdir(mode=0o700, exist_ok=True) if not (ROOT / "target/debug/ferese-rime").exists(): raise RuntimeError("Build ferese, feresectl and ferese-rime first") for kind in ("config/ferese", "data", "cache", "state"): diff --git a/shell/ferese-settings/src/all_options.rs b/shell/ferese-settings/src/all_options.rs index ce5f87e..f5f8c8e 100644 --- a/shell/ferese-settings/src/all_options.rs +++ b/shell/ferese-settings/src/all_options.rs @@ -559,7 +559,7 @@ impl App { ..self.font })) .push( - self.label(p.subtitle(), 12.) + self.label(p.subtitle(), 11.) .class(cosmic::theme::Text::Color(palette.muted)), ) .width(Length::Fill), @@ -947,11 +947,9 @@ impl App { for item in group.items { if !matches!( item.kind, - ferese_panel::ItemKind::Network - | ferese_panel::ItemKind::Audio + ferese_panel::ItemKind::Audio | ferese_panel::ItemKind::Battery { .. } | ferese_panel::ItemKind::Notifications - | ferese_panel::ItemKind::NightLight | ferese_panel::ItemKind::Recording | ferese_panel::ItemKind::CaptureStatus ) { diff --git a/shell/ferese-settings/src/calendar_controls.rs b/shell/ferese-settings/src/calendar_controls.rs new file mode 100644 index 0000000..1205d4b --- /dev/null +++ b/shell/ferese-settings/src/calendar_controls.rs @@ -0,0 +1,282 @@ +use super::{App, Element, Length, Message, column, container, row, schema, text_input, visuals}; +use super::{ + Task, + store::{Snapshot, set}, +}; +use cosmic::widget::text_editor; +use ferese_config::tr; + +#[derive(Default)] +pub(super) struct FunctionEditor { + pub expanded: bool, + initialized: bool, + content: text_editor::Content, + validating: bool, + error: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::Application; + #[test] + fn function_icon_preserves_unsaved_code_and_only_applies_matching_validation() { + let mut app = App::init( + cosmic::app::Core::default(), + ( + "/unused/calendar.kdl".into(), + Snapshot::parse(String::new()), + None, + ), + ) + .0; + app.saving = true; + let original = app.draft.source.clone(); + let _ = app.calendar_function_input(FunctionInput::Toggle); + assert!(app.calendar_editor.expanded); + assert!(!app.calendar_editor.dirty(&app.draft)); + let edited = "fn japanese_holidays(year) { #{} }"; + app.calendar_editor.content = text_editor::Content::with_text(edited); + let _ = app.calendar_function_input(FunctionInput::Toggle); + assert!(!app.calendar_editor.expanded); + let _ = app.calendar_function_input(FunctionInput::Toggle); + assert_eq!(app.calendar_editor.content.text(), edited); + assert_eq!(app.draft.source, original); + let _ = app.calendar_function_input(FunctionInput::Validated("stale".into(), Ok(()))); + assert!(app.pending.is_empty()); + let _ = app.calendar_function_input(FunctionInput::Validated( + edited.into(), + Err("invalid syntax".into()), + )); + assert!(app.pending.is_empty() && app.calendar_editor.error.is_some()); + let _ = app.calendar_function_input(FunctionInput::Validated(edited.into(), Ok(()))); + assert_eq!(app.pending.len(), 1); + assert_eq!( + source(&Snapshot::parse(app.draft.source.clone()).unwrap()), + edited + ); + let _ = app.calendar_function_input(FunctionInput::Restore); + assert_eq!( + source(&app.draft), + edited, + "restoring the editor still requires Apply" + ); + assert!(app.calendar_editor.dirty(&app.draft)); + } +} + +const FUNCTION_PATH: &str = "status.calendar.japanese_holiday_function"; +fn source(snapshot: &Snapshot) -> String { + snapshot.string( + FUNCTION_PATH, + ferese_config::calendar::DEFAULT_JAPANESE_HOLIDAY_FUNCTION, + ) +} + +impl FunctionEditor { + pub fn dirty(&self, snapshot: &Snapshot) -> bool { + self.initialized && self.content.text() != source(snapshot) + } + pub fn refresh(&mut self, snapshot: &Snapshot) { + if self.initialized { + self.content = text_editor::Content::with_text(&source(snapshot)); + } + self.validating = false; + self.error = None; + } +} + +#[derive(Clone, Debug)] +pub(super) enum FunctionInput { + Toggle, + Edit(text_editor::Action), + Apply, + Restore, + Validated(String, Result<(), String>), +} + +impl App { + pub(super) fn calendar_function_input(&mut self, input: FunctionInput) -> Task { + match input { + FunctionInput::Toggle => { + if !self.calendar_editor.initialized || !self.calendar_editor.dirty(&self.draft) { + self.calendar_editor.content = + text_editor::Content::with_text(&source(&self.draft)); + self.calendar_editor.initialized = true; + } + self.calendar_editor.expanded = !self.calendar_editor.expanded; + } + FunctionInput::Edit(action) => { + self.calendar_editor.content.perform(action); + self.calendar_editor.error = None; + } + FunctionInput::Restore => { + self.calendar_editor.content = text_editor::Content::with_text( + ferese_config::calendar::DEFAULT_JAPANESE_HOLIDAY_FUNCTION, + ); + self.calendar_editor.error = None; + } + FunctionInput::Apply if !self.saving && !self.calendar_editor.validating => { + let source = self.calendar_editor.content.text(); + let edited = source.clone(); + self.calendar_editor.validating = true; + return Task::perform( + async move { + tokio::task::spawn_blocking(move || { + ferese_config::calendar::validate_japanese_holiday_function(&source) + }) + .await + .unwrap_or_else(|error| Err(error.to_string())) + }, + move |result| { + cosmic::Action::App(Message::CalendarFunction(FunctionInput::Validated( + edited.clone(), + result, + ))) + }, + ); + } + FunctionInput::Validated(source, result) => { + self.calendar_editor.validating = false; + if source != self.calendar_editor.content.text() { + return Task::none(); + } + match result { + Ok(()) => return self.change(set(FUNCTION_PATH, source)), + Err(error) => { + self.calendar_editor.error = Some(ferese_config::locale::translate(&error)) + } + } + } + _ => {} + } + Task::none() + } + + fn calendar_function_view(&self) -> Element<'_, Message> { + let state = &self.calendar_editor; + let palette = self.native_palette; + let mut rows = column([]).spacing(10).push( + row([]) + .align_y(super::Alignment::Center) + .push( + self.panel_heading(tr!("Japanese holidays"), 14.) + .width(Length::Fill), + ) + .push( + container(self.settings_icon_button( + tr!("Edit holiday calculation function"), + "M8 6l-6 6 6 6 M16 6l6 6-6 6 M14 4l-4 16", + Some(Message::CalendarFunction(FunctionInput::Toggle)), + )) + .id("calendar-function-toggle"), + ), + ); + if state.expanded { + rows=rows.push(self.note(tr!("Rhai function japanese_holidays(year) returns a YYYY-MM-DD to name map. Built-in rules cover 1955–2099; unpublished years are estimates."))) + .push(cosmic::widget::TextEditor::new(&state.content) + // The context-menu wrapper repeats its inner editor ID; + // bypass it so named-state recovery retains the editor tree. + .context_menu(false) + .id("calendar-function-editor").font(self.font).size(11).height(280).padding(12) + .style(move|_,status|text_editor::Style{ + background:palette.card.into(), + border:cosmic::iced::Border{width:1.,radius:5.into(),color:if matches!(status,text_editor::Status::Focused{..}){palette.accent}else{palette.muted.scale_alpha(0.25)},..Default::default()}, + placeholder:palette.muted,value:palette.text,selection:palette.accent.scale_alpha(0.3), + }) + .on_action(|action|Message::CalendarFunction(FunctionInput::Edit(action)))) + .push(row([]).spacing(10) + .push(self.settings_button(if state.validating {tr!("Checking function…")} else {tr!("Apply function")},"M5 12l4 4L19 6",(!state.validating&&!self.saving&&state.dirty(&self.draft)).then_some(Message::CalendarFunction(FunctionInput::Apply)),false)) + .push(self.settings_button(tr!("Restore built-in function"),"M4 11a8 8 0 1 1 2 7 M4 4v7h7",Some(Message::CalendarFunction(FunctionInput::Restore)),false))); + if let Some(error) = &state.error { + rows = rows.push(self.note(&ferese_config::locale::format( + "Holiday function error: {error}", + &[("error", error.clone())], + ))); + } + } + rows.into() + } +} + +impl App { + pub(super) fn calendar_controls(&self) -> Element<'_, Message> { + let palette = self.native_palette; + let date = self + .inputs + .get("$holiday.date") + .cloned() + .unwrap_or_default(); + let name = self + .inputs + .get("$holiday.name") + .cloned() + .unwrap_or_default(); + let valid = ferese_config::calendar::Holiday { + date: date.trim().into(), + name: name.trim().into(), + } + .validate() + .is_ok(); + let mut rows = column([]).spacing(8); + for index in 0..self.draft.records("status.calendar.custom_holidays") { + let prefix = format!("status.calendar.custom_holidays.{index}"); + rows = rows.push( + container( + column([]) + .spacing(4) + .push(self.field(schema::text( + &format!("{prefix}.date"), + "Date", + "YYYY-MM-DD for one date; MM-DD repeats every year.", + "", + ))) + .push(self.field(schema::text( + &format!("{prefix}.name"), + "Holiday name", + "", + "", + ))) + .push(self.settings_button( + tr!("Remove holiday"), + "M5 6h14 M9 6V3h6v3 M6 6l1 15h10l1-15", + (!self.saving).then_some(Message::Remove( + "status.calendar.custom_holidays".into(), + index, + )), + false, + )), + ) + .padding(4) + .class(visuals::section(palette)), + ); + } + let input = |placeholder, value, path: &'static str| { + text_input(placeholder, value) + .font(self.font) + .size(12) + .padding([7, 10]) + .style(visuals::input_style(palette)) + .on_input(move |value| Message::Draft(path.into(), value)) + .on_submit(|_| Message::AddHoliday) + }; + rows = rows.push( + row([]) + .spacing(10) + .align_y(super::Alignment::Center) + .push(input(tr!("YYYY-MM-DD / MM-DD"), date, "$holiday.date").width(170)) + .push(input(tr!("Holiday name"), name, "$holiday.name").width(Length::Fill)) + .push(self.settings_button( + tr!("Add holiday"), + "M12 5v14 M5 12h14", + (!self.saving && valid).then_some(Message::AddHoliday), + false, + )), + ); + column([]).spacing(10) + .push(self.calendar_function_view()) + .push(self.panel_heading(tr!("Custom holidays"),14.)) + .push(self.note(tr!("Japanese public holidays are included. Add personal dates below; MM-DD repeats annually."))) + .push(rows).into() + } +} diff --git a/shell/ferese-settings/src/connections/bluetooth_ui.rs b/shell/ferese-settings/src/connections/bluetooth_ui.rs index e07c8ca..c63a274 100644 --- a/shell/ferese-settings/src/connections/bluetooth_ui.rs +++ b/shell/ferese-settings/src/connections/bluetooth_ui.rs @@ -18,7 +18,7 @@ impl App { return self.connection_card( column([]) .spacing(8) - .push(self.label("Bluetooth unavailable", 14.)) + .push(self.label(ferese_config::tr!("Bluetooth unavailable"), 14.)) .push(self.connection_note(result.as_ref().expect_err( "the unavailable-services branch contains a connection error", ))), @@ -55,11 +55,15 @@ impl App { column([]) .spacing(2) .width(Length::Fill) - .push(self.label("Bluetooth", 15.)) + .push(self.label(ferese_config::tr!("Bluetooth"), 15.)) .push(self.connection_note(&format!( "{} · {}", adapter.name, - if adapter.powered { "On" } else { "Off" } + if adapter.powered { + ferese_config::tr!("On") + } else { + ferese_config::tr!("Off") + } ))), ) .push( @@ -85,7 +89,7 @@ impl App { row([]) .align_y(Alignment::Center) .push( - self.label( + self.ui_label( if scanning { "Searching for devices…" } else { @@ -121,7 +125,7 @@ impl App { .filter(|d| d.adapter == adapter.path) .collect(); for paired in [true, false] { - body = body.push(self.label( + body = body.push(self.ui_label( if paired { "Paired devices" } else { @@ -144,13 +148,21 @@ impl App { } else { "Not paired" }; + let state = ferese_config::locale::text(state); let detail = format!( "{state} · {}{}{}", device.address, - if device.trusted { " · Trusted" } else { "" }, + if device.trusted { + ferese_config::tr!(" · Trusted") + } else { + "" + }, device .battery - .map(|b| format!(" · {b}% battery")) + .map(|b| ferese_config::locale::format( + " · {percent}% battery", + &[("percent", b.to_string())] + )) .unwrap_or_default() ); let mut controls = row([]).spacing(6); diff --git a/shell/ferese-settings/src/connections/pairing_ui.rs b/shell/ferese-settings/src/connections/pairing_ui.rs index 6800d77..e8077e5 100644 --- a/shell/ferese-settings/src/connections/pairing_ui.rs +++ b/shell/ferese-settings/src/connections/pairing_ui.rs @@ -20,31 +20,46 @@ impl App { let explanation = match &prompt.kind { agent::Kind::Pin => "Enter the PIN shown on your device.".into(), agent::Kind::Passkey => "Enter the six-digit passkey shown on your device.".into(), - agent::Kind::Confirm(key) => format!("Does {key:06} match the number on your device?"), + agent::Kind::Confirm(key) => ferese_config::locale::format( + "Does {key} match the number on your device?", + &[("key", format!("{key:06}"))], + ), agent::Kind::Authorize => "Allow this device to pair?".into(), - agent::Kind::Service(uuid) => format!("Allow the device to use service {uuid}?"), + agent::Kind::Service(uuid) => ferese_config::locale::format( + "Allow the device to use service {uuid}?", + &[("uuid", uuid.clone())], + ), agent::Kind::Display(text) => text.clone(), }; let mut body = column([]) .spacing(8) - .push(self.label(format!("Pair with {device}"), 15.)) + .push(self.label( + ferese_config::locale::format( + "Pair with {device}", + &[("device", device.to_owned())], + ), + 15., + )) .push(self.connection_note(&explanation)); let id = prompt.id; let input = matches!(prompt.kind, agent::Kind::Pin | agent::Kind::Passkey); if input { body = body.push( - text_input("PIN or passkey", s.pairing_input.as_str()) - .id(cosmic::widget::Id::new("bluetooth-passkey")) - .password() - .font(self.font) - .size(13) - .padding([5, 8]) - .style(visuals::input_style(p)) - .on_input(move |value| message(Input::PairingValue(id, Zeroizing::new(value)))) - .on_submit(move |mut value| { - value.zeroize(); - message(Input::Answer(id, true)) - }), + text_input( + ferese_config::tr!("PIN or passkey"), + s.pairing_input.as_str(), + ) + .id(cosmic::widget::Id::new("bluetooth-passkey")) + .password() + .font(self.font) + .size(13) + .padding([5, 8]) + .style(visuals::input_style(p)) + .on_input(move |value| message(Input::PairingValue(id, Zeroizing::new(value)))) + .on_submit(move |mut value| { + value.zeroize(); + message(Input::Answer(id, true)) + }), ); } let valid = agent::valid_answer(&prompt.kind, &s.pairing_input); diff --git a/shell/ferese-settings/src/connections/ui.rs b/shell/ferese-settings/src/connections/ui.rs index a6a32eb..af8811d 100644 --- a/shell/ferese-settings/src/connections/ui.rs +++ b/shell/ferese-settings/src/connections/ui.rs @@ -92,7 +92,7 @@ impl App { )); } if !icon_only { - content = content.push(self.label(label.to_owned(), 12.)); + content = content.push(self.label(ferese_config::locale::translate(label), 12.)); } let control = button::custom(content) .name(label.to_owned()) @@ -102,7 +102,7 @@ impl App { if icon_only { widget::tooltip( control, - self.label(label.to_owned(), 11.), + self.label(ferese_config::locale::translate(label), 11.), widget::tooltip::Position::Top, ) .into() @@ -113,7 +113,7 @@ impl App { pub(super) fn connection_note(&self, text: &str) -> Element<'static, Message> { let p = visuals::Palette::from_resolved(&self.resolved.presented); - self.label(text.to_owned(), 11.) + self.label(ferese_config::locale::translate(text), 10.5) .class(cosmic::theme::Text::Color(p.muted)) .into() } @@ -154,7 +154,7 @@ impl App { ); if let Some(error) = &s.error { body = body.push( - container(self.label(error.clone(), 12.)) + container(self.label(ferese_config::locale::translate(error), 12.)) .padding(8) .width(Length::Fill) .class(visuals::surface(p.error, 10.)), @@ -164,7 +164,7 @@ impl App { let mut status = row([]) .align_y(Alignment::Center) .spacing(6) - .push(self.label(busy, 12.)); + .push(self.label(ferese_config::locale::translate(busy), 12.)); if busy == "Pairing…" { status = status.push(self.connection_button( "Cancel", @@ -184,7 +184,7 @@ impl App { self.connection_card( column([]) .spacing(6) - .push(self.label("Forget this connection?", 14.)) + .push(self.label(ferese_config::tr!("Forget this connection?"), 14.)) .push(self.connection_note( "Saved credentials or pairing keys will be removed. You will need to connect again.", )) diff --git a/shell/ferese-settings/src/connections/wifi_ui.rs b/shell/ferese-settings/src/connections/wifi_ui.rs index f0b6f85..c594d2e 100644 --- a/shell/ferese-settings/src/connections/wifi_ui.rs +++ b/shell/ferese-settings/src/connections/wifi_ui.rs @@ -19,7 +19,7 @@ impl App { return self.connection_card( column([]) .spacing(8) - .push(self.label("Wi-Fi unavailable", 14.)) + .push(self.label(ferese_config::tr!("Wi-Fi unavailable"), 14.)) .push(self.connection_note(result.as_ref().expect_err( "the unavailable-services branch contains a connection error", ))), @@ -44,7 +44,7 @@ impl App { column([]) .spacing(2) .width(Length::Fill) - .push(self.label("Wi-Fi", 15.)) + .push(self.label(ferese_config::tr!("Wi-Fi"), 15.)) .push(self.connection_note(status)), ) .push( @@ -65,7 +65,10 @@ impl App { body = body.push( row([]) .align_y(Alignment::Center) - .push(self.label("Networks", 14.).width(Length::Fill)) + .push( + self.label(ferese_config::tr!("Networks"), 14.) + .width(Length::Fill), + ) .push(self.connection_button("Scan", Input::Run(Command::WifiScan), ready, false)), ); if wifi.networks.is_empty() { @@ -88,10 +91,14 @@ impl App { } else { "" }; + let state = ferese_config::locale::text(state); let detail = format!( - "{state}{} · {}% signal{}", - network.security.label(), - network.strength, + "{state}{} · {}{}", + ferese_config::locale::text(network.security.label()), + ferese_config::locale::format( + "{percent}% signal", + &[("percent", network.strength.to_string())] + ), if wifi.radios.len() > 1 { format!(" · {radio}") } else { @@ -171,7 +178,7 @@ impl App { body = body.push(self.wifi_credentials()); } if !wifi.saved.is_empty() { - body = body.push(self.label("Saved networks", 14.)); + body = body.push(self.label(ferese_config::tr!("Saved networks"), 14.)); for saved in &wifi.saved { body = body.push( self.connection_card( @@ -206,28 +213,34 @@ impl App { let s = &self.connections; let p = visuals::Palette::from_resolved(&self.resolved.presented); let target = self.wifi_target(); - let mut body = column([]).spacing(6).push(self.label( + let mut body = column([]).spacing(6).push(self.ui_label( if s.hidden { - "Hidden network".into() + ferese_config::locale::translate("Hidden network") } else { - format!( - "Join {}", - s.selected - .as_ref() - .map(|n| n.name.as_str()) - .unwrap_or("network") + ferese_config::locale::format( + "Join {network}", + &[( + "network", + s.selected + .as_ref() + .map(|n| n.name.clone()) + .unwrap_or_else(|| ferese_config::locale::translate("network")), + )], ) }, 16., )); if s.hidden { body = body.push( - text_input("Network name (SSID)", s.hidden_name.clone()) - .font(self.font) - .size(13) - .padding([5, 8]) - .style(visuals::input_style(p)) - .on_input(|name| message(Input::HiddenName(name))), + text_input( + ferese_config::tr!("Network name (SSID)"), + s.hidden_name.clone(), + ) + .font(self.font) + .size(13) + .padding([5, 8]) + .style(visuals::input_style(p)) + .on_input(|name| message(Input::HiddenName(name))), ); let mut choices = row([]).spacing(6); for security in [ @@ -251,7 +264,7 @@ impl App { .unwrap_or(s.hidden_security); if security.password() { body = body.push( - text_input("Network password", s.password.as_str()) + text_input(ferese_config::tr!("Network password"), s.password.as_str()) .id(cosmic::widget::Id::new("wifi-password")) .password() .font(self.font) diff --git a/shell/ferese-settings/src/displays.rs b/shell/ferese-settings/src/displays.rs index d41b775..7811b38 100644 --- a/shell/ferese-settings/src/displays.rs +++ b/shell/ferese-settings/src/displays.rs @@ -265,7 +265,7 @@ impl std::fmt::Display for Refresh { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Manual(mode) => f.write_str(&mode.label()), - Self::Automatic(_) => f.write_str("Auto (battery)"), + Self::Automatic(_) => f.write_str(ferese_config::tr!("Auto (battery)")), } } } diff --git a/shell/ferese-settings/src/displays_ui.rs b/shell/ferese-settings/src/displays_ui.rs index a7e23fe..55aa03d 100644 --- a/shell/ferese-settings/src/displays_ui.rs +++ b/shell/ferese-settings/src/displays_ui.rs @@ -186,7 +186,10 @@ impl App { .spacing(10) .align_y(Alignment::Center) .padding([7, 10]) - .push(self.label(label.to_owned(), 13.).width(Length::Fill)) + .push( + self.label(ferese_config::locale::translate(label), 13.) + .width(Length::Fill), + ) .push(control) .into() } diff --git a/shell/ferese-settings/src/form_controls.rs b/shell/ferese-settings/src/form_controls.rs index ce51198..4c8b248 100644 --- a/shell/ferese-settings/src/form_controls.rs +++ b/shell/ferese-settings/src/form_controls.rs @@ -12,6 +12,14 @@ impl App { ferese_theme::text(text, self.font).size(size) } + pub(super) fn ui_label<'a>( + &self, + text: impl Into> + 'a, + size: f32, + ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> { + self.label(ferese_config::locale::translate(text.into().as_ref()), size) + } + pub(super) fn window_chrome<'a>( &self, content: impl Into> + 'a, @@ -27,21 +35,24 @@ impl App { text: impl Into> + 'a, size: f32, ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> { - self.label(text, size.min(visuals::SECTION_TITLE_SIZE)) - .font(cosmic::iced::Font { - weight: cosmic::iced::font::Weight::Bold, - ..self.font - }) + self.label( + ferese_config::locale::translate(text.into().as_ref()), + size.min(visuals::SECTION_TITLE_SIZE), + ) + .font(cosmic::iced::Font { + weight: cosmic::iced::font::Weight::Bold, + ..self.font + }) } pub(super) fn note(&self, text: &str) -> Element<'static, Message> { let palette = visuals::Palette::from_resolved(&self.resolved.presented); self.label( - text.to_owned(), + ferese_config::locale::translate(text), if self.page == crate::schema::Page::Bar { - 12. + 10.5 } else { - 11. + 10.5 }, ) .class(cosmic::theme::Text::Color(palette.muted)) @@ -80,7 +91,7 @@ impl App { palette.muted }, )) - .push(self.label(label.to_owned(), 12.)), + .push(self.label(ferese_config::locale::translate(label), 12.)), ) .name(label.to_owned()) .padding([4, 8]) @@ -109,7 +120,7 @@ impl App { .padding(4) .class(self.control_button_style(palette, false)) .on_press_maybe(message), - self.label(label.to_owned(), 11.), + self.label(ferese_config::locale::translate(label), 11.), widget::tooltip::Position::Top, ) .into() @@ -123,8 +134,10 @@ impl App { ferese_config::theme::Appearance::Dark => "dark", }; field.path = format!("theme.{name}.material.opacity"); - field.description = - format!("Opacity for the current {name} appearance. Text and icons stay opaque."); + field.description = ferese_config::locale::format( + "Opacity for the current {name} appearance. Text and icons stay opaque.", + &[("name", ferese_config::locale::translate(name))], + ); if let Kind::Range { default, .. } = &mut field.kind { *default = self.resolved.theme.tokens.material.opacity; } @@ -147,7 +160,7 @@ impl App { ferese_config::locale::translate(&field.description) )) .font(regular) - .size(12.) + .size(10.5) .color(palette.muted), ); } @@ -226,14 +239,14 @@ impl App { for (key, label) in choices { options = options.push( button::custom(self.label(ferese_config::locale::text(label), 12.)) - .padding([4, 8]) + .padding([6, 10]) .class(self.control_button_style(palette, value == *key)) .on_press(Message::Change(set(&path, *key))), ); } container(options) - .padding(3) - .class(visuals::surface(palette.sidebar, 10.)) + .padding(5) + .class(visuals::segment_group(palette)) .into() } Kind::Text { default, argv } => { diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 9d729cd..b016ff0 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -1,4 +1,5 @@ mod all_options; +mod calendar_controls; mod connections; mod form_controls; mod navigation; @@ -66,6 +67,7 @@ fn main() -> cosmic::iced::Result { Some("bar") => Some(InitialPage::Bar), Some("appearance") => Some(InitialPage::Settings(Page::Appearance)), Some("general") => Some(InitialPage::Settings(Page::General)), + Some("calendar") => Some(InitialPage::Settings(Page::Calendar)), Some("theme") => Some(InitialPage::Settings(Page::Theme)), Some("tray") => Some(InitialPage::Settings(Page::Tray)), Some("all-options") => Some(InitialPage::Settings(Page::Configuration)), @@ -134,6 +136,7 @@ enum Message { RowVisibility(&'static str, usize, bool), Search(String), Sidebar(bool), + RevealNavigation, Change(Edit), PanelClearSelection, PanelPage(panel_controls::PanelPage), @@ -173,6 +176,8 @@ enum Message { LockPreviewStarted(Result<(), String>), WallpaperPicked(wallpaper_controls::Target, Result, String>), NewCommand(String), + AddHoliday, + CalendarFunction(calendar_controls::FunctionInput), AddCommand, AddSwipe(&'static str), Remove(String, usize), @@ -207,6 +212,7 @@ struct App { inputs: HashMap, ranges: HashMap, new_command: String, + calendar_editor: calendar_controls::FunctionEditor, font: cosmic::font::Font, native_palette: visuals::Palette, family_ids: std::sync::Arc>, @@ -324,6 +330,7 @@ impl cosmic::Application for App { inputs: HashMap::new(), ranges: HashMap::new(), new_command: String::new(), + calendar_editor: Default::default(), font, native_palette, gallery_focus: None, @@ -358,7 +365,10 @@ impl cosmic::Application for App { } else { Task::none() }; - let task = Task::batch([task, tray_apps]); + let reveal = + cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation(app.page)) + .map(cosmic::Action::App); + let task = Task::batch([task, tray_apps, reveal]); let connections = if app.page == Page::Connections { app.refresh_connections() } else { @@ -460,6 +470,7 @@ impl cosmic::Application for App { || !self.ranges.is_empty() || !self.pending.is_empty() || self.options.dirty() + || self.calendar_editor.dirty(&self.draft) { self.status = "Config changed externally · Reload when your edits are finished" @@ -469,6 +480,7 @@ impl cosmic::Application for App { &snapshot.doc, )); self.options.reset(&snapshot); + self.calendar_editor.refresh(&snapshot); self.current = snapshot.clone(); self.draft = snapshot; self.family_sections = theme_controls::FamilySections::new( @@ -517,11 +529,21 @@ impl cosmic::Application for App { Message::Sidebar(open) => { self.sidebar_open = open; if open { - return cosmic::iced::widget::operation::focus(widget::Id::new( - "settings-search", - )); + return Task::batch([ + cosmic::iced::widget::operation::focus(widget::Id::new("settings-search")), + cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation( + self.page, + )) + .map(cosmic::Action::App), + ]); } } + Message::RevealNavigation => { + return cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation( + self.page, + )) + .map(cosmic::Action::App); + } Message::Page(page) => { self.sidebar_open = false; if self.page == page && self.search.is_empty() { @@ -545,6 +567,13 @@ impl cosmic::Application for App { widget::Id::new("settings-content"), cosmic::iced::widget::scrollable::RelativeOffset::START.into(), ); + let reset = Task::batch([ + reset, + cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation( + page, + )) + .map(cosmic::Action::App), + ]); if leaving { let cleanup = self.leave_connections(); let load = match page { @@ -839,6 +868,7 @@ impl cosmic::Application for App { Ok(snapshot) => { ferese_config::locale::set(ferese_config::locale::from_document(&snapshot.doc)); self.options.reset(&snapshot); + self.calendar_editor.refresh(&snapshot); self.current = snapshot.clone(); self.draft = snapshot; self.family_sections = @@ -1012,6 +1042,37 @@ impl cosmic::Application for App { return self.apply_wallpaper_edits(wallpaper_controls::restore_defaults()); } Message::NewCommand(value) => self.new_command = value, + Message::CalendarFunction(input) => return self.calendar_function_input(input), + Message::AddHoliday if !self.saving => { + let date = self + .inputs + .get("$holiday.date") + .cloned() + .unwrap_or_default() + .trim() + .to_owned(); + let name = self + .inputs + .get("$holiday.name") + .cloned() + .unwrap_or_default() + .trim() + .to_owned(); + let holiday = ferese_config::calendar::Holiday { date, name }; + if let Err(error) = holiday.validate() { + self.error = Some(ferese_config::locale::translate(&error)); + } else { + self.inputs.remove("$holiday.date"); + self.inputs.remove("$holiday.name"); + return self.change(Edit::Add( + "status.calendar.custom_holidays".into(), + vec![ + ("date".into(), holiday.date.into()), + ("name".into(), holiday.name.into()), + ], + )); + } + } Message::AddCommand if !self.saving => match shlex::split(&self.new_command) .filter(|a| !a.is_empty()) { @@ -1342,7 +1403,7 @@ mod tests { app.current = initial.clone(); app.draft = initial.clone(); app.saving = true; // An earlier save has not completed yet. - let id = ItemId("network".into()); + let id = ItemId("audio".into()); let _ = app.update(Message::PanelSelect(id.clone())); let _ = app.update(Message::PanelEdit(panel_edit::Action::Move( id.clone(), @@ -1366,11 +1427,9 @@ mod tests { let panels: Vec = serde_json::from_value(app.draft.item("panels").unwrap().clone()).unwrap(); ferese_panel::validate(&panels).unwrap(); - assert_eq!(panels[0].start.groups[1].items[1].id, id); - assert_eq!( - panels[0].start.groups[1].items[1].overflow, - ferese_panel::OverflowPolicy::Always - ); + let moved = panels[0].start.groups[1].items.last().unwrap(); + assert_eq!(moved.id, id); + assert_eq!(moved.overflow, ferese_panel::OverflowPolicy::Always); assert!( !panels[0].end.groups[0] .items @@ -1419,7 +1478,15 @@ mod tests { let _ = app.update(Message::PanelEdit(panel_edit::Action::Remove(id))); let _ = app.page_view(); assert!(app.error.is_none()); - assert_eq!(app.draft.string("panels.0.end.groups.1.items.0.id", ""), ""); + assert!( + !app.preview_panel() + .unwrap() + .end + .groups + .iter() + .flat_map(|group| &group.items) + .any(|item| item.kind == ferese_panel::ItemKind::Clock) + ); } #[test] @@ -1509,7 +1576,7 @@ mod tests { panel_edit::Zone::Center, ))); let _ = app.update(Message::PanelEdit(panel_edit::Action::Place( - ItemId("network".into()), + ItemId("audio".into()), panel_edit::Destination { zone: "end", group: GroupId("time".into()), @@ -1523,7 +1590,7 @@ mod tests { assert!(app.saving && app.pending.is_empty()); assert_eq!( app.draft.string("panels.0.center.groups.1.items.0.id", ""), - "network" + "audio" ); assert_eq!( app.draft.string("panels.0.center.groups.1.items.1.id", ""), diff --git a/shell/ferese-settings/src/navigation.rs b/shell/ferese-settings/src/navigation.rs index 401b898..e82eba2 100644 --- a/shell/ferese-settings/src/navigation.rs +++ b/shell/ferese-settings/src/navigation.rs @@ -22,6 +22,7 @@ pub(super) fn gallery_row_id(list: &str, row: usize) -> Id { pub(super) struct RevealRow { target: Id, + scroller: Id, bounds: Option, } @@ -33,6 +34,18 @@ impl RevealRow { pub(super) fn for_target(target: Id) -> Self { Self { target, + scroller: Id::new("settings-content"), + bounds: None, + } + } + + pub(super) fn navigation(page: crate::schema::Page) -> Self { + Self { + target: Id::new(format!( + "settings-nav-row-{}", + page.navigation_page() as usize + )), + scroller: Id::new("settings-navigation"), bounds: None, } } @@ -57,7 +70,7 @@ impl Operation for RevealRow { translation: Vector, state: &mut dyn Scrollable, ) { - if id != Some(&Id::new("settings-content")) { + if id != Some(&self.scroller) { return; } diff --git a/shell/ferese-settings/src/pages.rs b/shell/ferese-settings/src/pages.rs index 563a4fa..1408269 100644 --- a/shell/ferese-settings/src/pages.rs +++ b/shell/ferese-settings/src/pages.rs @@ -114,6 +114,10 @@ impl App { section.push( button::custom( container(content) + .id(widget::Id::new(format!( + "settings-nav-row-{}", + page as usize + ))) .center_y(Length::Fill) .width(Length::Fill), ) @@ -131,15 +135,31 @@ impl App { } navigation = navigation.push(section); } - let sidebar = container(sidebar.push(scrollable(navigation).height(Length::Fill))) - .id("settings-sidebar") - .width(if compact { 244 } else { 208 }) - .height(Length::Fill) - .padding([24, 12]) - .class(visuals::sidebar(palette)); - let sidebar = self.window_chrome(sidebar); + let sidebar = container( + sidebar.push( + scrollable(navigation) + .id("settings-navigation") + .direction(cosmic::iced::widget::scrollable::Direction::Vertical( + cosmic::iced::widget::scrollable::Scrollbar::default() + .width(4) + .scroller_width(4) + .spacing(8), + )) + .height(Length::Fill), + ), + ) + .id("settings-sidebar") + .width(if compact { 244 } else { 208 }) + .height(Length::Fill) + .padding([24, 12]) + .class(visuals::sidebar(palette)); + let sidebar = self.window_chrome( + cosmic::iced::widget::sensor(sidebar) + .key("settings-navigation-mount") + .on_show(|_| Message::RevealNavigation), + ); let mut heading = row([]).align_y(Alignment::Center).spacing(16).push( - self.label( + self.ui_label( if self.search.is_empty() { self.page.navigation_page().title() } else { @@ -171,28 +191,36 @@ impl App { ), ); let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule"; - let source = if scheduled { "Schedule" } else { "System" }; + let source = ferese_config::locale::text(if scheduled { "Schedule" } else { "System" }); let label = if automatic { format!( "{source} · {}", if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light { - "Light" + ferese_config::tr!("Light") } else { - "Dark" + ferese_config::tr!("Dark") }, ) } else { source.to_owned() }; let explanation = if scheduled { - format!( - "Auto uses Light from {} and Dark from {} ({})", - self.draft.string("theme.schedule.light_at", "07:00"), - self.draft.string("theme.schedule.dark_at", "19:00"), - self.draft.string("theme.schedule.timezone", "system"), + ferese_config::locale::format( + "Light from {light}, dark from {dark} ({zone})", + &[ + ( + "light", + self.draft.string("theme.schedule.light_at", "07:00"), + ), + ("dark", self.draft.string("theme.schedule.dark_at", "19:00")), + ( + "zone", + self.draft.string("theme.schedule.timezone", "system"), + ), + ], ) } else { - "Auto follows the GTK/GNOME appearance preference".to_owned() + ferese_config::locale::translate("Auto follows the GTK/GNOME appearance preference") }; heading = heading.push(widget::tooltip( self.settings_button( @@ -360,6 +388,7 @@ impl App { } match self.page { + Page::Calendar => body = body.push(self.calendar_controls()), Page::Tray => { body = body .push(self.tray_application_controls()) @@ -550,15 +579,15 @@ impl App { row(self.page.subpages().iter().map(|&(page, title)| { button::custom(self.label(ferese_config::locale::text(title), 13.)) .name(format!("{}: {title}", self.page.navigation_page().title())) - .padding([7, 12]) + .padding([8, 14]) .class(visuals::panel_button(palette, self.page == page)) .on_press(Message::Page(page)) .into() })) .spacing(2), ) - .padding(3) - .class(visuals::surface(palette.sidebar, 9.)), + .padding(5) + .class(visuals::segment_group(palette)), ); } if self.page == Page::Bar && self.search.is_empty() { @@ -618,7 +647,7 @@ impl App { }, palette.muted, ), - self.label( + self.ui_label( if self.saving { "Saving…" } else { @@ -629,7 +658,7 @@ impl App { widget::tooltip::Position::Top, )) .push( - self.label( + self.ui_label( if self.saving { ferese_config::tr!("Saving…").to_owned() } else { diff --git a/shell/ferese-settings/src/panel_controls.rs b/shell/ferese-settings/src/panel_controls.rs index 29ea495..a1f7a7f 100644 --- a/shell/ferese-settings/src/panel_controls.rs +++ b/shell/ferese-settings/src/panel_controls.rs @@ -24,29 +24,27 @@ pub(super) fn initialize(_snapshot: &store::Snapshot) -> Result) -> std::fmt::Result { - formatter.write_str(self.0.label()) + formatter.write_str(ferese_config::locale::text(self.0.label())) } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct StyleChoice(ferese_panel::PanelStyle); impl std::fmt::Display for StyleChoice { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(self.0.label()) + formatter.write_str(ferese_config::locale::text(self.0.label())) } } -const CATALOG: [CatalogItem; 16] = [ +const CATALOG: [CatalogItem; 14] = [ CatalogItem(ItemKind::Overview), CatalogItem(ItemKind::Workspaces { style: ferese_panel::WorkspaceStyle::Dots, }), CatalogItem(ItemKind::FocusedWindow), + CatalogItem(ItemKind::WindowTray), CatalogItem(ItemKind::Media), CatalogItem(ItemKind::Activities), - CatalogItem(ItemKind::QuickSettings), CatalogItem(ItemKind::Tray), - CatalogItem(ItemKind::NightLight), - CatalogItem(ItemKind::Network), CatalogItem(ItemKind::Audio), CatalogItem(ItemKind::Recording), CatalogItem(ItemKind::CaptureStatus), @@ -126,8 +124,11 @@ impl App { } else { "Bottom" }; - widget::button::custom(self.label(label, 12.)) - .name(format!("Panel position: {label}")) + widget::button::custom(self.label(ferese_config::locale::translate(label), 12.)) + .name(ferese_config::locale::format( + "Panel position: {label}", + &[("label", ferese_config::locale::translate(&label))], + )) .padding([8, 10]) .width(cosmic::iced::Length::Fill) .class(crate::visuals::panel_button(palette, edge == position)) @@ -144,8 +145,8 @@ impl App { "Position", "Choose the screen edge for the panel.", widget::container(position) - .padding(3) - .class(crate::visuals::surface(palette.sidebar, 9.)) + .padding(5) + .class(crate::visuals::segment_group(palette)) .into(), ), ) @@ -222,7 +223,10 @@ impl App { let maximum = self.draft.number("activities.max_visible", 2.) as u64; let choices = widget::row([1_u64, 2].into_iter().map(|count| { widget::button::custom(self.label(count.to_string(), 12.)) - .name(format!("Show up to {count} activities")) + .name(ferese_config::locale::format( + "Show up to {count} activities", + &[("count", count.to_string())], + )) .padding([8, 10]) .width(cosmic::iced::Length::Fill) .class(crate::visuals::panel_button(palette, count == maximum)) @@ -237,8 +241,8 @@ impl App { "Visible activities", "Maximum activities in the collapsed and peek island.", widget::container(choices) - .padding(3) - .class(crate::visuals::surface(palette.sidebar, 9.)) + .padding(5) + .class(crate::visuals::segment_group(palette)) .into(), ), ); @@ -336,7 +340,10 @@ impl App { let reset = pinned.then_some(Message::PanelEdit(Action::ClearGeometry( field.path.clone(), ))); - let reset_label = format!("Reset {}", field.label); + let reset_label = ferese_config::locale::format( + "Reset {label}", + &[("label", ferese_config::locale::translate(&field.label))], + ); settings = settings.push( widget::row![ self.field(field), @@ -410,7 +417,7 @@ impl App { .push( widget::column([]) .spacing(4) - .push(self.label(label.to_owned(), 13.)) + .push(self.label(ferese_config::locale::translate(label), 13.)) .push(self.note(description)) .width(cosmic::iced::Length::Fill), ) @@ -512,28 +519,34 @@ impl App { let radii = custom.unwrap_or_else(|| ferese_panel::CornerRadii::uniform(shell_radius)); let modes = widget::row([("Automatic", false), ("Custom", true)].map( |(label, selected)| { - widget::button::custom(self.label(label, 12.).align_x(Alignment::Center)) - .name(format!("Corner radius: {label}")) - .padding([8, 12]) - .width(Length::Fill) - .class(crate::visuals::panel_button( - palette, - custom.is_some() == selected, - )) - .on_press_maybe((custom.is_some() != selected).then(|| { - Message::PanelEdit(if selected { - Action::SetRadius(format!("{shell_radius}px")) - } else { - Action::ClearRadius - }) - })) - .into() + widget::button::custom( + self.label(ferese_config::locale::translate(label), 12.) + .align_x(Alignment::Center), + ) + .name(ferese_config::locale::format( + "Corner radius: {label}", + &[("label", ferese_config::locale::translate(&label))], + )) + .padding([8, 12]) + .width(Length::Fill) + .class(crate::visuals::panel_button( + palette, + custom.is_some() == selected, + )) + .on_press_maybe((custom.is_some() != selected).then(|| { + Message::PanelEdit(if selected { + Action::SetRadius(format!("{shell_radius}px")) + } else { + Action::ClearRadius + }) + })) + .into() }, )) .spacing(2); let modes = widget::container(modes) - .padding(3) - .class(crate::visuals::surface(palette.sidebar, 9.)); + .padding(5) + .class(crate::visuals::segment_group(palette)); let mut corners = Vec::new(); // Match the physical corners: top row first, then bottom-left / bottom-right. for (index, label, icon) in [ @@ -554,9 +567,15 @@ impl App { .spacing(8) .align_y(Alignment::Center) .push(crate::visuals::action_icon(icon, palette.muted)) - .push(self.label(label, 12.).width(Length::Fill)) + .push( + self.label(ferese_config::locale::translate(label), 12.) + .width(Length::Fill), + ) .push(self.settings_icon_button( - &format!("Decrease {label} radius"), + &ferese_config::locale::format( + "Decrease {label} radius", + &[("label", ferese_config::locale::translate(&label))], + ), "M5 12h14", (custom.is_some() && value > 0.).then_some(Message::PanelEdit( Action::SetCorner(index, (value - 1.).max(0.)), @@ -570,7 +589,10 @@ impl App { ) .push( self.settings_icon_button( - &format!("Increase {label} radius"), + &ferese_config::locale::format( + "Increase {label} radius", + &[("label", ferese_config::locale::translate(&label))], + ), "M12 5v14 M5 12h14", custom .is_some() @@ -808,7 +830,10 @@ impl App { )) .push(self.label(group_name(&group.id.0), 13.)), ) - .name(format!("Edit group {}", group.id.0)) + .name(ferese_config::locale::format( + "Edit group {name}", + &[("name", group.id.0.clone())], + )) .padding([7, 6]) .width(Length::Fill) .class(crate::visuals::panel_button( @@ -860,7 +885,13 @@ impl App { })), ), ) - .name(format!("Edit {} · {}", item.kind.label(), item.id.0)) + .name(ferese_config::locale::format( + "Edit {label} · {id}", + &[ + ("label", ferese_config::locale::translate(item.kind.label())), + ("id", item.id.0.clone()), + ], + )) .padding([8, 6]) .width(Length::Fill) .class(crate::visuals::panel_button( @@ -963,6 +994,7 @@ pub(super) fn item_icon(kind: ItemKind, tint: cosmic::iced::Color) -> widget::ic ItemKind::Overview => icons::FERESE, ItemKind::Workspaces { .. } => icons::OVERVIEW, ItemKind::FocusedWindow => icons::DISPLAY, + ItemKind::WindowTray => icons::APPLICATION, ItemKind::Media => icons::MEDIA, ItemKind::Activities => icons::FERESE, ItemKind::QuickSettings => icons::CONTROL_CENTER, diff --git a/shell/ferese-settings/src/panel_edit.rs b/shell/ferese-settings/src/panel_edit.rs index 3edaae5..5eb26f7 100644 --- a/shell/ferese-settings/src/panel_edit.rs +++ b/shell/ferese-settings/src/panel_edit.rs @@ -18,7 +18,8 @@ impl std::fmt::Display for Destination { }; write!( formatter, - "{zone} · {}", + "{} · {}", + ferese_config::locale::text(zone), crate::panel_controls::group_name(&self.group.0) ) } @@ -84,11 +85,11 @@ impl Zone { impl std::fmt::Display for Zone { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(match self { + formatter.write_str(ferese_config::locale::text(match self { Self::Start => "Start", Self::Center => "Center", Self::End => "End", - }) + })) } } diff --git a/shell/ferese-settings/src/panel_inspector.rs b/shell/ferese-settings/src/panel_inspector.rs index 1428296..29cf5cf 100644 --- a/shell/ferese-settings/src/panel_inspector.rs +++ b/shell/ferese-settings/src/panel_inspector.rs @@ -7,7 +7,7 @@ use ferese_panel::{Group, Item, ItemKind, Panel, Representation}; struct Choice(&'static str, &'static str); impl std::fmt::Display for Choice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.1) + f.write_str(ferese_config::locale::text(self.1)) } } @@ -179,7 +179,10 @@ impl App { .align_y(Alignment::Center), ) .id(format!("workspace-style:{}", style.key()).into()) - .name(format!("Workspace style: {}", style.label())) + .name(ferese_config::locale::format( + "Workspace style: {label}", + &[("label", ferese_config::locale::translate(style.label()))], + )) .padding([8, 10]) .width(Length::Fill) .class(crate::visuals::panel_button(palette, style == selected)) @@ -266,13 +269,19 @@ impl App { widget::row([]) .spacing(8) .align_y(Alignment::Center) - .push(self.label(label, 13.).width(Length::Fill)) + .push( + self.label(ferese_config::locale::translate(label), 13.) + .width(Length::Fill), + ) .push( widget::row([]) .spacing(4) .align_y(Alignment::Center) .push(self.settings_icon_button( - &format!("Decrease {label}"), + &ferese_config::locale::format( + "Decrease {label}", + &[("label", ferese_config::locale::translate(&label))], + ), "M5 12h14", (value > 0.).then(|| change((value - 1.).max(0.))), )) @@ -282,7 +291,10 @@ impl App { .align_x(Alignment::Center), ) .push(self.settings_icon_button( - &format!("Increase {label}"), + &ferese_config::locale::format( + "Increase {label}", + &[("label", ferese_config::locale::translate(&label))], + ), "M12 5v14 M5 12h14", (value < maximum).then(|| change((value + 1.).min(maximum))), )), @@ -358,7 +370,7 @@ impl App { .map(|&(key, label)| Choice(key, label)); return widget::column([]) .spacing(8) - .push(self.label(field.label, 13.)) + .push(self.label(ferese_config::locale::translate(&field.label), 13.)) .push(crate::visuals::panel_select( cosmic::iced::widget::pick_list( choices @@ -384,7 +396,7 @@ impl App { .into(); } let options = widget::row(choices.iter().map(|&(key, label)| { - widget::button::custom(self.label(label, 12.)) + widget::button::custom(self.label(ferese_config::locale::translate(label), 12.)) .name(format!("{}: {label}", field.label)) .padding([8, 4]) .width(Length::Fill) @@ -400,11 +412,11 @@ impl App { .width(Length::Fill); let mut rows = widget::column([]) .spacing(8) - .push(self.label(field.label, 13.)) + .push(self.label(ferese_config::locale::translate(&field.label), 13.)) .push( widget::container(options) - .padding(3) - .class(crate::visuals::surface(p.sidebar, 9.)), + .padding(5) + .class(crate::visuals::segment_group(p)), ); if !field.description.is_empty() { rows = rows.push(self.note(&field.description)); @@ -419,7 +431,10 @@ impl App { widget::row([]) .spacing(12) .align_y(Alignment::Center) - .push(self.label(field.label.clone(), 13.).width(Length::Fill)) + .push( + self.label(ferese_config::locale::translate(&field.label), 13.) + .width(Length::Fill), + ) .push( ferese_theme::controls::switch(enabled, p) .name(format!( diff --git a/shell/ferese-settings/src/panel_preview.rs b/shell/ferese-settings/src/panel_preview.rs index cceee65..8844e37 100644 --- a/shell/ferese-settings/src/panel_preview.rs +++ b/shell/ferese-settings/src/panel_preview.rs @@ -118,6 +118,10 @@ impl App { .map_err(|error| error.to_string())? .into_iter() .next() + .map(|mut panel| { + panel.consolidate_clock_controls(); + panel + }) .ok_or_else(|| "No panel configured.".into()) } @@ -383,6 +387,25 @@ impl App { let control_height = (self.draft.number("panels.0.geometry.height", 36.) as f32 - 4.).max(20.); let palette = visuals::Palette::from_resolved(&self.resolved.presented); + if item.kind == ItemKind::Notifications { + let mark = container(cosmic::widget::Space::new().width(3).height(13)) + .id(format!("preview-icon:{}", item.id.0)) + .class(visuals::surface(cosmic::iced::Color::WHITE, 1.5)); + let control = button::custom(container(mark).center_y(control_height)) + .name(ferese_config::tr!("Edit Notifications")) + .padding([0, 4]) + .height(control_height) + .on_press(Message::PanelSelect(item.id.clone())) + .class(preview_button( + palette, + self.panel_selection.as_ref() == Some(&item.id), + )); + return container(control) + .id(format!("preview-item:{}", item.id.0)) + .width(width.map_or(Length::Shrink, Length::Fixed)) + .clip(true) + .into(); + } if let ItemKind::Workspaces { style } = item.kind { let sample = ferese_theme::workspaces::sample(style, palette, self.font, 20.); let control = @@ -405,6 +428,7 @@ impl App { ItemKind::Overview => icons::FERESE, ItemKind::Workspaces { .. } => icons::OVERVIEW, ItemKind::FocusedWindow => icons::DISPLAY, + ItemKind::WindowTray => icons::APPLICATION, ItemKind::Media => icons::MEDIA, ItemKind::Activities => icons::FERESE, ItemKind::QuickSettings => icons::CONTROL_CENTER, @@ -447,9 +471,14 @@ impl App { .id(format!("preview-icon:{}", item.id.0)), ); + if item.kind == ItemKind::WindowTray { + for source in [icons::DISPLAY, icons::FERESE] { + content = content.push(icons::tinted(source, 16, palette.text)); + } + } if !label.is_empty() { content = content.push( - self.label(label, 12.) + self.label(ferese_config::locale::text(label), 12.) .wrapping(cosmic::iced::widget::text::Wrapping::None), ); } diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index eb5feec..d4d364d 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -17,6 +17,7 @@ pub enum Page { Startup, Displays, Connections, + Calendar, } impl Page { @@ -36,6 +37,7 @@ impl Page { Self::Displays, Self::Connections, Self::Notifications, + Self::Calendar, Self::LockScreen, Self::Startup, ], @@ -75,7 +77,7 @@ impl Page { } #[cfg(test)] - pub const ALL: [Self; 17] = [ + pub const ALL: [Self; 18] = [ Self::Appearance, Self::General, Self::Theme, @@ -93,6 +95,7 @@ impl Page { Self::Startup, Self::Displays, Self::Connections, + Self::Calendar, ]; pub fn title(self) -> &'static str { @@ -117,6 +120,7 @@ impl Page { Self::Startup => "Login items", Self::Displays => "Displays", Self::Connections => "Connections", + Self::Calendar => "Calendar", } } @@ -141,6 +145,7 @@ impl Page { Self::Startup => "Ready when you sign in.", Self::Displays => "A place for every screen.", Self::Connections => "Wi-Fi networks and Bluetooth devices.", + Self::Calendar => "Calendar, Japanese holiday rules and custom holidays.", }) } @@ -159,6 +164,9 @@ impl Page { Self::Wallpaper => "M4 4h16v16H4z M4 16l5-5 4 4 3-3 4 4 M15 8h.01", Self::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01", Self::Notifications => "M6 8a6 6 0 0 1 12 0v6l2 3H4l2-3z M10 21h4", + Self::Calendar => { + "M4 5h16v16H4z M8 3v4 M16 3v4 M4 10h16 M8 14h.01 M12 14h.01 M16 14h.01 M8 18h.01 M12 18h.01" + } Self::LockScreen => "M7 10V7a5 5 0 0 1 10 0v3 M5 10h14v11H5z M12 14v3", Self::Windows => "M3 4h12v12H3z M8 9h13v12H8z", Self::Motion => "M3 8h9a3 3 0 1 0-3-3 M3 12h15a3 3 0 1 1-3 3 M3 16h5", @@ -971,6 +979,12 @@ pub fn fields(page: Page) -> Vec { ("en", "English"), ], )], + Page::Calendar => vec![toggle( + "status.calendar.show_adjacent_days", + "Show dates from adjacent months", + "Display the previous and next month's dates in the calendar.", + true, + )], Page::Tray => vec![ toggle( "status.tray.enabled", @@ -1027,6 +1041,7 @@ pub fn grouped_fields(page: Page, fields: Vec) -> Vec { let p = field.path.as_str(); let title = match page { Page::General => "Interface", + Page::Calendar => "Calendar display", Page::Windows => { if p.starts_with("workspaces.") || p == "layout.mode" || p.starts_with("scrolling.") { @@ -1115,6 +1130,36 @@ pub fn grouped_fields(page: Page, fields: Vec) -> Vec { mod group_tests { use super::*; #[test] + fn every_standard_field_and_choice_has_chinese_interface_text() { + use ferese_config::locale::{Language, in_language}; + for page in Page::ALL { + for field in fields(page) { + for text in [&field.label, &field.description] { + if !text.is_empty() { + assert_ne!( + in_language(Language::Chinese, text), + text, + "{}: {text}", + field.path + ); + } + } + if let Kind::Choice { choices, .. } = field.kind { + for (_, text) in choices { + if !matches!(*text, "English" | "简体中文") { + assert_ne!( + in_language(Language::Chinese, text), + *text, + "{}: {text}", + field.path + ); + } + } + } + } + } + } + #[test] fn semantic_groups_keep_every_field_once_and_separate_input_devices() { for page in Page::ALL { let mut expected = fields(page).into_iter().map(|f| f.path).collect::>(); diff --git a/shell/ferese-settings/src/scroll_tests.rs b/shell/ferese-settings/src/scroll_tests.rs index f230e70..ba8e68e 100644 --- a/shell/ferese-settings/src/scroll_tests.rs +++ b/shell/ferese-settings/src/scroll_tests.rs @@ -1137,7 +1137,7 @@ fn appearance_cards_reflow_without_overlap_and_keep_every_destination() { .operate(&mut tree, Layout::new(&node), &renderer, &mut cards); assert_eq!( cards.bounds.len(), - 14, + 15, "{width}: all settings destinations must be reachable" ); for bounds in cards.bounds.values() { @@ -1174,6 +1174,7 @@ fn sidebar_rows_center_inside_the_full_button_hit_area() { struct Centers { buttons: std::collections::HashMap, parts: Vec<(usize, Rectangle)>, + sidebar: Option, } impl Operation for Centers { fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) { @@ -1197,6 +1198,9 @@ fn sidebar_rows_center_inside_the_full_button_hit_area() { } } fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { + if id == Some(&widget::Id::new("settings-sidebar")) { + self.sidebar = Some(bounds); + } for page in Page::ALL { for part in ["content", "icon", "label", "marker"] { if id @@ -1243,8 +1247,16 @@ fn sidebar_rows_center_inside_the_full_button_hit_area() { let mut centers = Centers::default(); view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut centers); - assert_eq!(centers.buttons.len(), 15); - assert_eq!(centers.parts.len(), 60); + assert_eq!(centers.buttons.len(), 16); + assert_eq!(centers.parts.len(), 64); + let sidebar = centers.sidebar.unwrap(); + assert!( + centers + .buttons + .values() + .all(|button| button.x + button.width <= sidebar.x + sidebar.width - 24. + 0.5), + "navigation hitboxes must leave a separate scrollbar lane" + ); for (page, part) in ¢ers.parts { let button = centers.buttons[page]; assert_eq!(button.height, 36.); @@ -1365,3 +1377,69 @@ fn double_clicking_blank_edges_toggles_maximize_without_changing_settings() { "field labels must not act as window chrome" ); } + +#[test] +fn holiday_function_editor_keeps_state_when_focus_and_text_rebuild_the_page() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let renderer = runtime + .block_on(::new( + Font::default(), + Pixels(14.), + Some("tiny-skia"), + )) + .unwrap(); + let mut app = App::init( + Core::default(), + ( + "/unused/calendar-editor.kdl".into(), + Snapshot::parse(String::new()), + Some(InitialPage::Settings(Page::Calendar)), + ), + ) + .0; + let _ = app.update(Message::CalendarFunction( + calendar_controls::FunctionInput::Toggle, + )); + let mut tree = None; + for action in [ + cosmic::widget::text_editor::Action::Click(Point::new(20., 20.)), + cosmic::widget::text_editor::Action::SelectAll, + cosmic::widget::text_editor::Action::Edit(cosmic::widget::text_editor::Edit::Paste( + std::sync::Arc::new("fn japanese_holidays(year) { #{} }".into()), + )), + cosmic::widget::text_editor::Action::Move( + cosmic::widget::text_editor::Motion::DocumentStart, + ), + ] { + let mut view = app.page_view(); + let mounted = tree.is_some(); + let tree = tree.get_or_insert_with(|| Tree::new(&view)); + if mounted { + cosmic::iced::advanced::widget::tree::NAMED + .with(|named| *named.borrow_mut() = tree.take_all_named()); + } + tree.diff(view.as_widget_mut()); + let node = view.as_widget_mut().layout( + tree, + &renderer, + &layout::Limits::new(Size::ZERO, Size::new(1280., 760.)), + ); + cosmic::iced::advanced::widget::tree::NAMED.with(|named| named.borrow_mut().clear()); + let mut focus = cosmic::iced::advanced::widget::operation::focusable::focus::<()>( + widget::Id::new("calendar-function-editor"), + ); + view.as_widget_mut() + .operate(tree, Layout::new(&node), &renderer, &mut focus); + drop(view); + let _ = app.update(Message::CalendarFunction( + calendar_controls::FunctionInput::Edit(action), + )); + } + assert!( + app.pending.is_empty(), + "editor changes require explicit Apply" + ); +} diff --git a/shell/ferese-settings/src/theme_controls.rs b/shell/ferese-settings/src/theme_controls.rs index 8835ab0..b0d2a3e 100644 --- a/shell/ferese-settings/src/theme_controls.rs +++ b/shell/ferese-settings/src/theme_controls.rs @@ -45,7 +45,7 @@ impl App { let path = ["theme.file", "theme.light.file", "theme.dark.file"][self.theme_file_target]; let file = self.draft.string(path, ""); let filename = if file.is_empty() { - "No override file".to_owned() + ferese_config::locale::translate("No override file") } else { PathBuf::from(&file) .file_name() @@ -60,9 +60,9 @@ impl App { .push(widget::tooltip( self.label(filename, 11.) .class(cosmic::theme::Text::Color(palette.muted)), - self.label( + self.ui_label( if file.is_empty() { - "Choose a partial KDL theme file".to_owned() + ferese_config::locale::translate("Choose a partial KDL theme file") } else { file.clone() }, @@ -73,9 +73,9 @@ impl App { let mut scopes = row([]).spacing(2); for (index, label) in ["Shared", "Light", "Dark"].into_iter().enumerate() { scopes = scopes.push( - button::custom(self.label(label, 12.)) + button::custom(self.label(ferese_config::locale::text(label), 12.)) .name(format!("Theme file: {label}")) - .padding([4, 8]) + .padding([6, 10]) .class(visuals::button_style( palette, index == self.theme_file_target, @@ -88,8 +88,8 @@ impl App { .align_y(Alignment::Center) .push( container(scopes) - .padding(3) - .class(visuals::surface(palette.sidebar, 10.)), + .padding(5) + .class(visuals::segment_group(palette)), ) .push(self.settings_button( if file.is_empty() { @@ -159,7 +159,7 @@ impl App { ); if scheduled { let mut times = row([]).spacing(6).align_y(Alignment::Center); - for field in schema::fields(Page::Appearance) + for field in schema::fields(Page::Theme) .into_iter() .filter(|field| field.path.starts_with("theme.schedule.")) { @@ -175,7 +175,7 @@ impl App { let submit = field.clone(); let input = text_input(*default, value) .font(self.font) - .padding([4, 8]) + .padding([6, 10]) .size(12) .style(visuals::input_style(palette)) .on_input(move |value| Message::Draft(path.clone(), value)) @@ -190,7 +190,7 @@ impl App { row([]) .spacing(6) .align_y(Alignment::Center) - .push(self.label(field.label, 12.)) + .push(self.label(ferese_config::locale::translate(&field.label), 12.)) .push(input), ); } @@ -209,7 +209,7 @@ impl App { ) -> Element<'_, Message> { use ferese_config::theme::Appearance::{Dark, Light}; let mut heading = row([]).align_y(Alignment::Center).spacing(6).push( - self.label( + self.ui_label( match appearance { Some(Light) => "Light theme", Some(Dark) => "Dark theme", diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index 8b98b10..a265dd4 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -4,9 +4,19 @@ use cosmic::widget::icon as svg_icon; pub use ferese_theme::Palette; pub const PAGE_TITLE_SIZE: f32 = 16.; pub const SECTION_TITLE_SIZE: f32 = 14.; -pub use ferese_theme::controls::{ - button_style, navigation_style, settings_input as input_style, surface, -}; +pub use ferese_theme::controls::{navigation_style, settings_input as input_style, surface}; + +pub fn button_style(mut p: Palette, selected: bool) -> cosmic::theme::Button { + p.radius = p.radius.min(5.); + ferese_theme::controls::button_style(p, selected) +} + +pub fn segment_group(p: Palette) -> cosmic::theme::Container<'static> { + surface( + ferese_theme::mix(p.background, p.sidebar, 0.35), + p.radius.min(6.), + ) +} use crate::Message; use crate::schema::Page; @@ -33,7 +43,7 @@ pub fn panel_button(p: Palette, selected: bool) -> cosmic::theme::Button { p.muted }), icon_color: Some(if enabled { p.text } else { p.muted }), - border_radius: 8.into(), + border_radius: p.radius.min(5.).into(), border_width: if focused { 2. } else if selected { diff --git a/shell/ferese-shell/src/bar.rs b/shell/ferese-shell/src/bar.rs index 99aaeb3..edef067 100644 --- a/shell/ferese-shell/src/bar.rs +++ b/shell/ferese-shell/src/bar.rs @@ -112,6 +112,9 @@ impl FereseShell { .iter() .filter(|item| item.available(availability)) { + if item.kind == ItemKind::WindowTray && self.snapshot.windows.is_empty() { + continue; + } if matches!(item.kind, ItemKind::FocusedWindow) && focused_bar_title(&self.snapshot, self.output_for_bar(id)).is_empty() { @@ -474,6 +477,12 @@ impl FereseShell { .into(); return (container(element).max_width(360).into(), !title.is_empty()); } + ItemKind::WindowTray => { + return ( + self.view_window_tray(id, &item.id.0, width, control_height), + !self.snapshot.windows.is_empty(), + ); + } ItemKind::Activities => { return self.view_activity_item( id, @@ -542,7 +551,7 @@ impl FereseShell { button::custom(bar_content(content, bar.group_item_height)) .padding([0, 4]) .height(bar.group_item_height) - .name(ferese_config::tr!("Open calendar")) + .name(ferese_config::tr!("Open calendar and controls")) .on_press_with_rectangle(move |offset, bounds| { cosmic::Action::App(Message::OpenMenu( status_ui::Menu::Calendar, @@ -1288,12 +1297,10 @@ mod island_tests { .iter() .map(|(id, _)| id.as_str()) .collect::>(), - ["quick-settings", "night-light", "recording", "clock"] + ["window-tray", "recording", "clock"] ); - assert_eq!(end.items[1].1.x - end.items[0].1.x, 21.); - assert_eq!(end.items[2].1.x - end.items[1].1.x, 21.); assert_eq!( - end.items[3].1.x - end.items[2].1.x, + end.items[2].1.x - end.items[1].1.x, if islands { 21. } else { 34. } ); if islands { @@ -1301,7 +1308,7 @@ mod island_tests { end.islands[0], Rectangle::new( (0., 0.).into(), - (129. + 2. * padding, panel.resolved_geometry().height).into() + (108. + 2. * padding, panel.resolved_geometry().height).into() ) ); } diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index b0bf28a..1a8d943 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -431,7 +431,13 @@ fn parse_document( theme_mode: mode, panels: config .panels - .unwrap_or_else(|| vec![crate::panel::Panel::default()]), + .unwrap_or_else(|| vec![crate::panel::Panel::default()]) + .into_iter() + .map(|mut panel| { + panel.consolidate_clock_controls(); + panel + }) + .collect(), status: StatusConfig { low_battery_threshold: config.status.low_battery_threshold.min(100), ..config.status diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index b59a9eb..6a0c2e4 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -30,6 +30,7 @@ mod rendering; mod status; mod status_ui; mod system_modal; +mod window_tray; mod workspace_ui; use std::sync::atomic::{AtomicBool, Ordering}; @@ -269,6 +270,8 @@ enum Message { MediaArt(media::Artwork), ControlReady, ActivateWorkspace(u64), + ActivateWindow(u64), + HoverWindow(window::Id, u64, cosmic::iced::advanced::widget::Id, bool), HoverWorkspace(window::Id, u64, cosmic::iced::advanced::widget::Id, bool), WorkspaceTooltipDelay(u64), WorkspaceTooltipBounds(u64, Option), @@ -1121,6 +1124,17 @@ impl cosmic::Application for FereseShell { } self.dismiss_workspace_tooltip() } + Message::ActivateWindow(id) => { + if self.snapshot.windows.iter().any(|window| window.id == id) + && let Some(control) = &self.control + { + control.activate_window(id); + } + self.dismiss_workspace_tooltip() + } + Message::HoverWindow(bar, window, target, entered) => { + self.hover_window(bar, window, target, entered) + } Message::HoverWorkspace(bar, workspace, target, entered) => { self.hover_workspace(bar, workspace, target, entered) } @@ -1141,6 +1155,13 @@ impl cosmic::Application for FereseShell { if let Some(control) = &self.control { control.set_background_title_updates( self.launcher.is_some() + || self.config.panels.iter().any(|panel| { + [&panel.start, &panel.center, &panel.end] + .into_iter() + .flat_map(|zone| &zone.groups) + .flat_map(|group| &group.items) + .any(|item| item.visible && item.kind == panel::ItemKind::WindowTray) + }) || self.outputs.iter().any(|output| { output.runtime.hub.as_ref().is_some_and(|hub| { hub.borrow().hub.mode() diff --git a/shell/ferese-shell/src/notification_ui.rs b/shell/ferese-shell/src/notification_ui.rs index a1ff1c0..67366a0 100644 --- a/shell/ferese-shell/src/notification_ui.rs +++ b/shell/ferese-shell/src/notification_ui.rs @@ -112,10 +112,19 @@ fn icon_well<'a>( fn age_label(age: Duration) -> String { match age.as_secs() { - 0..60 => "now".into(), - 60..3600 => format!("{}m ago", age.as_secs() / 60), - 3600..86400 => format!("{}h ago", age.as_secs() / 3600), - seconds => format!("{}d ago", seconds / 86400), + 0..60 => ferese_config::locale::translate("now"), + 60..3600 => ferese_config::locale::format( + "{count}m ago", + &[("count", (age.as_secs() / 60).to_string())], + ), + 3600..86400 => ferese_config::locale::format( + "{count}h ago", + &[("count", (age.as_secs() / 3600).to_string())], + ), + seconds => ferese_config::locale::format( + "{count}d ago", + &[("count", (seconds / 86400).to_string())], + ), } } @@ -706,17 +715,20 @@ impl FereseShell { let groups = self.notifications.history_groups(); let count = self.notifications.entries.len(); let subtitle = if count == 0 { - "A quiet moment".to_owned() + ferese_config::locale::translate("A quiet moment") } else { - format!( - "{count} {} · {} {}", - if count == 1 { - "notification" - } else { - "notifications" - }, - groups.len(), - if groups.len() == 1 { "app" } else { "apps" } + let key = match (count == 1, groups.len() == 1) { + (true, true) => "{count} notification · {apps} app", + (true, false) => "{count} notification · {apps} apps", + (false, true) => "{count} notifications · {apps} app", + (false, false) => "{count} notifications · {apps} apps", + }; + ferese_config::locale::format( + key, + &[ + ("count", count.to_string()), + ("apps", groups.len().to_string()), + ], ) }; let heading = column([]) @@ -775,9 +787,9 @@ impl FereseShell { .push( self.render .text(if self.notifications.dnd { - "Popups paused" + ferese_config::tr!("Popups paused") } else { - "Popups enabled" + ferese_config::tr!("Popups enabled") }) .size(11) .class(theme::Text::Color(muted)), @@ -790,7 +802,11 @@ impl FereseShell { button::custom( container( self.render - .text(if self.notifications.dnd { "On" } else { "Off" }) + .text(if self.notifications.dnd { + ferese_config::tr!("On") + } else { + ferese_config::tr!("Off") + }) .size(12) .font(bold) .class(theme::Text::Color(dnd_color)), diff --git a/shell/ferese-shell/src/panel_hub.rs b/shell/ferese-shell/src/panel_hub.rs index 15c03ac..9fb5e0e 100644 --- a/shell/ferese-shell/src/panel_hub.rs +++ b/shell/ferese-shell/src/panel_hub.rs @@ -1090,7 +1090,6 @@ impl FereseShell { .width(Length::Fill); let tabs = row::with_capacity(2).spacing(2).align_y(Alignment::Center); let mut tabs = tabs.push(navigation); - tabs = tabs.push(self.hub_shortcut_button(id, ItemKind::NightLight, menu, opacity)); if let Some(battery) = self.hub_battery(id, menu == Some(status_ui::Menu::Battery), opacity) { @@ -3447,9 +3446,9 @@ mod tests { let mut shell = shell(); let id = shell.outputs[0].bar; for (playback, counts) in [ - (None, [0, 2, 2]), - (Some(ferese_ipc::media::Playback::Playing), [0, 2, 2]), - (Some(ferese_ipc::media::Playback::Paused), [0, 2, 2]), + (None, [0, 1, 1]), + (Some(ferese_ipc::media::Playback::Playing), [0, 1, 1]), + (Some(ferese_ipc::media::Playback::Paused), [0, 1, 1]), ] { std::sync::Arc::make_mut(&mut shell.media.snapshot).selected = playback.map(|status| ferese_ipc::media::Player { @@ -3462,7 +3461,7 @@ mod tests { assert_eq!(shortcuts.len(), count); assert!(!shortcuts.contains(&ItemKind::NightLight)); assert!(!shortcuts.contains(&ItemKind::Network)); - assert_eq!(shortcuts.contains(&ItemKind::Recording), count > 1); + assert_eq!(shortcuts.contains(&ItemKind::Recording), count > 0); assert!(!shortcuts.contains(&ItemKind::DisplayMode)); assert!(!shortcuts.contains(&ItemKind::Audio)); assert!( diff --git a/shell/ferese-shell/src/rendering.rs b/shell/ferese-shell/src/rendering.rs index 9b10f18..73a6f1a 100644 --- a/shell/ferese-shell/src/rendering.rs +++ b/shell/ferese-shell/src/rendering.rs @@ -21,6 +21,13 @@ impl Rendering { self.radius.min(limit) } + pub fn with_radius_limit(self, limit: f32) -> Self { + Self { + radius: self.radius(limit), + ..self + } + } + pub fn text<'a>( self, content: impl Into> + 'a, diff --git a/shell/ferese-shell/src/status.rs b/shell/ferese-shell/src/status.rs index 6e0562e..a4b288c 100644 --- a/shell/ferese-shell/src/status.rs +++ b/shell/ferese-shell/src/status.rs @@ -563,11 +563,8 @@ fn audio() -> Option