Consolidate desktop controls with editable holidays and a running-window tray

This commit is contained in:
aria committed 2026-10-28 06:02:46 +09:00
1 parent 9908dde9f8
commit 326465952a
55 files changed
+4207 -450

No files matched your search

@@ -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<String>,
}
#[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<Message> {
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()
}
}