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, 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() } }