app.rs (16009B)
1 use crate::config::{APP_ID, PROFILE}; 2 use crate::modals::about::AboutDialog; 3 use crate::pages::finished_page::{self, FinishedPage}; 4 use crate::pages::progressing_page::{ProgressingPage, ProgressingPageMsg}; 5 use crate::pages::select_folder::{InOut, SelectFolder, SelectFolderOut}; 6 use crate::workers::conversion_worker::{ 7 ConversionWorker, ConversionWorkerInputMsg, ConversionWorkerMsg, 8 }; 9 use gettextrs::gettext; 10 use gtk::prelude::*; 11 use gtk::{gio, glib}; 12 use relm4::SimpleComponent; 13 use relm4::{ 14 actions::{RelmAction, RelmActionGroup}, 15 adw, gtk, main_application, Component, ComponentController, ComponentParts, ComponentSender, 16 Controller, WorkerController, 17 }; 18 use std::path::PathBuf; 19 use tracing::info; 20 21 enum Mode { 22 InputSelection, 23 OutputSelection, 24 ConversionSelection, 25 Progressing, 26 Finished, 27 Failed, 28 } 29 30 pub(super) struct App { 31 about_dialog: Controller<AboutDialog>, 32 input_folder_selector: Controller<SelectFolder>, 33 output_folder_selector: Controller<SelectFolder>, 34 progressing_page: Controller<ProgressingPage>, 35 finished_page: Controller<FinishedPage>, 36 input_folder: Option<PathBuf>, 37 output_folder: Option<PathBuf>, 38 conversion_worker: WorkerController<ConversionWorker>, 39 mode: Mode, 40 failure: Option<String>, 41 } 42 43 #[derive(Debug)] 44 pub(super) enum AppMsg { 45 InputFolderSelected(PathBuf), 46 OutputFolderSelected(PathBuf), 47 DeselectInputFolder, 48 DeselectOutputFolder, 49 Convert, 50 ConversionStarted(usize), 51 ProgressUpdate(f64), 52 ConversionComplete, 53 ConversionFailed(String), 54 StartOver, 55 Quit, 56 Noop, 57 } 58 59 relm4::new_action_group!(pub(super) WindowActionGroup, "win"); 60 relm4::new_stateless_action!(PreferencesAction, WindowActionGroup, "preferences"); 61 relm4::new_stateless_action!(pub(super) ShortcutsAction, WindowActionGroup, "show-help-overlay"); 62 relm4::new_stateless_action!(AboutAction, WindowActionGroup, "about"); 63 64 #[relm4::component(pub)] 65 impl SimpleComponent for App { 66 type Init = (); 67 type Input = AppMsg; 68 type Output = (); 69 type Widgets = AppWidgets; 70 71 menu! { 72 primary_menu: { 73 section! { 74 "_Preferences" => PreferencesAction, 75 "_Keyboard" => ShortcutsAction, 76 "_About Heic2JPG" => AboutAction, 77 } 78 } 79 } 80 81 view! { 82 #[root] 83 main_window = adw::ApplicationWindow::new(&main_application()) { 84 set_visible: true, 85 set_default_width: 650, 86 set_default_height: 600, 87 set_width_request: 360, 88 set_height_request: 294, 89 90 connect_close_request[sender] => move |_| { 91 sender.input(AppMsg::Quit); 92 glib::Propagation::Stop 93 }, 94 95 #[wrap(Some)] 96 set_help_overlay: shortcuts = >k::Builder::from_resource( 97 "/dev/nordgedanken/heic2jpg/gtk/help-overlay.ui" 98 ) 99 .object::<gtk::ShortcutsWindow>("help_overlay") 100 .unwrap() -> gtk::ShortcutsWindow { 101 set_transient_for: Some(&main_window), 102 set_application: Some(&main_application()), 103 }, 104 105 add_css_class?: if PROFILE == "Devel" { 106 Some("devel") 107 } else { 108 None 109 }, 110 111 adw::ToolbarView { 112 add_top_bar = &adw::HeaderBar { 113 #[wrap(Some)] 114 set_title_widget = &adw::WindowTitle { 115 set_title: &gettext("Convert Heic to JPG"), 116 }, 117 pack_end = >k::MenuButton { 118 set_icon_name: "open-menu-symbolic", 119 set_menu_model: Some(&primary_menu), 120 } 121 }, 122 123 #[transition = "SlideRight"] 124 match model.mode { 125 Mode::Progressing => { 126 gtk::Box { 127 set_vexpand: true, 128 set_hexpand: true, 129 append = model.progressing_page.widget(), 130 } 131 } 132 Mode::Finished => { 133 gtk::Box { 134 set_vexpand: true, 135 set_hexpand: true, 136 append = model.finished_page.widget(), 137 } 138 } 139 Mode::Failed => { 140 adw::StatusPage { 141 set_hexpand: true, 142 set_vexpand: true, 143 set_title: &gettext("Conversion Failed"), 144 #[watch] 145 set_description: model.failure.as_deref(), 146 set_icon_name: Some("error-outline"), 147 148 gtk::Box { 149 set_halign: gtk::Align::Center, 150 set_orientation: gtk::Orientation::Horizontal, 151 set_spacing: 24, 152 gtk::Button { 153 set_label: &gettext("Close"), 154 connect_clicked[sender] => move |_| { 155 sender.input(AppMsg::Quit); 156 } 157 }, 158 gtk::Button { 159 set_label: "Restart", 160 connect_clicked[sender] => move |_| { 161 sender.input(AppMsg::StartOver); 162 } 163 } 164 } 165 } 166 } 167 Mode::InputSelection => { 168 gtk::Box { 169 set_vexpand: true, 170 set_hexpand: true, 171 append = model.input_folder_selector.widget(), 172 } 173 } 174 Mode::OutputSelection => { 175 gtk::Box { 176 set_vexpand: true, 177 set_hexpand: true, 178 append = model.output_folder_selector.widget(), 179 } 180 } 181 Mode::ConversionSelection => { 182 adw::StatusPage { 183 set_hexpand: true, 184 set_vexpand: true, 185 set_title: &gettext("Start Conversion"), 186 set_description: Some(&gettext("Click the button below to start the conversion")), 187 set_icon_name: Some("blend-tool"), 188 189 gtk::Box { 190 set_halign: gtk::Align::Center, 191 set_orientation: gtk::Orientation::Horizontal, 192 set_spacing: 24, 193 gtk::Button { 194 set_label: &gettext("Convert"), 195 add_css_class: "suggested-action", 196 add_css_class: "pill", 197 connect_clicked[sender] => move |_| { 198 sender.input(AppMsg::Convert); 199 } 200 }, 201 gtk::Button { 202 set_label: "Restart", 203 connect_clicked[sender] => move |_| { 204 sender.input(AppMsg::StartOver); 205 } 206 } 207 } 208 } 209 } 210 } 211 } 212 213 } 214 } 215 216 fn init( 217 _init: Self::Init, 218 root: Self::Root, 219 sender: ComponentSender<Self>, 220 ) -> ComponentParts<Self> { 221 let about_dialog = AboutDialog::builder() 222 .transient_for(&root) 223 .launch(()) 224 .detach(); 225 let input_folder_selector = 226 SelectFolder::builder() 227 .launch(InOut::Input) 228 .forward(sender.input_sender(), |msg| match msg { 229 SelectFolderOut::FolderSelected(path) => AppMsg::InputFolderSelected(path), 230 SelectFolderOut::AbortLast => AppMsg::Noop, 231 }); 232 let output_folder_selector = 233 SelectFolder::builder() 234 .launch(InOut::Output) 235 .forward(sender.input_sender(), |msg| match msg { 236 SelectFolderOut::FolderSelected(path) => AppMsg::OutputFolderSelected(path), 237 SelectFolderOut::AbortLast => AppMsg::DeselectInputFolder, 238 }); 239 let conversion_worker = 240 ConversionWorker::builder() 241 .detach_worker(()) 242 .forward(sender.input_sender(), |msg| match msg { 243 ConversionWorkerMsg::ConversionStarted(number) => { 244 AppMsg::ConversionStarted(number) 245 } 246 ConversionWorkerMsg::ProgressUpdate(number) => AppMsg::ProgressUpdate(number), 247 ConversionWorkerMsg::ConversionComplete => AppMsg::ConversionComplete, 248 ConversionWorkerMsg::ConversionFailed(e) => AppMsg::ConversionFailed(e), 249 }); 250 251 let progressing_page = ProgressingPage::builder().launch(()).detach(); 252 let finished_page = 253 FinishedPage::builder() 254 .launch(()) 255 .forward(sender.input_sender(), |msg| match msg { 256 finished_page::FinishedPageMsg::StartOver => AppMsg::StartOver, 257 finished_page::FinishedPageMsg::Quit => AppMsg::Quit, 258 }); 259 260 let model = Self { 261 about_dialog, 262 input_folder_selector, 263 output_folder_selector, 264 progressing_page, 265 finished_page, 266 conversion_worker, 267 input_folder: None, 268 output_folder: None, 269 mode: Mode::InputSelection, 270 failure: None, 271 }; 272 273 let widgets = view_output!(); 274 275 let mut actions = RelmActionGroup::<WindowActionGroup>::new(); 276 277 let shortcuts_action = { 278 let shortcuts = widgets.shortcuts.clone(); 279 RelmAction::<ShortcutsAction>::new_stateless(move |_| { 280 shortcuts.present(); 281 }) 282 }; 283 284 let about_action = { 285 let sender = model.about_dialog.sender().clone(); 286 RelmAction::<AboutAction>::new_stateless(move |_| { 287 sender.send(()).unwrap(); 288 }) 289 }; 290 291 actions.add_action(shortcuts_action); 292 actions.add_action(about_action); 293 actions.register_for_widget(&widgets.main_window); 294 295 widgets.load_window_size(); 296 297 ComponentParts { model, widgets } 298 } 299 300 fn update(&mut self, message: Self::Input, _sender: ComponentSender<Self>) { 301 match message { 302 AppMsg::ConversionStarted(number) => { 303 self.mode = Mode::Progressing; 304 let _ = self 305 .progressing_page 306 .sender() 307 .send(ProgressingPageMsg::SetFileCount(number)); 308 } 309 AppMsg::InputFolderSelected(path) => { 310 self.input_folder = Some(path); 311 self.mode = Mode::OutputSelection 312 } 313 AppMsg::DeselectInputFolder => { 314 self.input_folder = None; 315 self.mode = Mode::InputSelection 316 } 317 AppMsg::OutputFolderSelected(path) => { 318 self.output_folder = Some(path); 319 self.mode = Mode::ConversionSelection 320 } 321 AppMsg::DeselectOutputFolder => { 322 self.output_folder = None; 323 self.mode = Mode::OutputSelection 324 } 325 AppMsg::Quit => main_application().quit(), 326 AppMsg::Convert => { 327 if let (Some(input_folder), Some(output_folder)) = 328 (&self.input_folder, &self.output_folder) 329 { 330 info!( 331 "Starting conversion from {:?} to {:?}", 332 input_folder, output_folder 333 ); 334 let _ = self.conversion_worker.sender().send( 335 ConversionWorkerInputMsg::ConvertFolder( 336 input_folder.clone(), 337 output_folder.clone(), 338 ), 339 ); 340 } else { 341 self.mode = Mode::Failed; 342 self.failure = 343 Some(gettext("Please select both input and output folders").to_string()); 344 } 345 } 346 AppMsg::ProgressUpdate(progress) => { 347 if let Mode::Progressing = self.mode { 348 let _ = self 349 .progressing_page 350 .sender() 351 .send(ProgressingPageMsg::SetProgress(progress)); 352 } 353 } 354 AppMsg::ConversionComplete => { 355 self.mode = Mode::Finished; 356 let notification = gio::Notification::new(&gettext("Conversion Complete")); 357 notification.set_body(Some(&gettext("The conversion was successful"))); 358 notification.set_category(Some("transfer.complete")); 359 notification.set_priority(gio::NotificationPriority::Normal); 360 let pass_icon = gio::Icon::for_string("test-pass"); 361 if let Ok(pass_icon) = pass_icon { 362 // Ends up as an empty icon on my system. Something is wrong with the icon loading?. 363 //notification.set_icon(&pass_icon); 364 } 365 366 let app = relm4::main_application(); 367 app.send_notification(None, ¬ification); 368 } 369 AppMsg::ConversionFailed(e) => { 370 self.mode = Mode::Failed; 371 self.failure = Some(e); 372 } 373 AppMsg::StartOver => { 374 self.input_folder = None; 375 self.output_folder = None; 376 let _ = self 377 .progressing_page 378 .sender() 379 .send(ProgressingPageMsg::SetProgress(0.0)); 380 self.failure = None; 381 let _ = self 382 .progressing_page 383 .sender() 384 .send(ProgressingPageMsg::SetFileCount(0)); 385 self.mode = Mode::InputSelection; 386 } 387 AppMsg::Noop => {} 388 } 389 } 390 391 fn shutdown(&mut self, widgets: &mut Self::Widgets, _output: relm4::Sender<Self::Output>) { 392 widgets.save_window_size().unwrap(); 393 } 394 } 395 396 impl AppWidgets { 397 fn save_window_size(&self) -> Result<(), glib::BoolError> { 398 let settings = gio::Settings::new(APP_ID); 399 let (width, height) = self.main_window.default_size(); 400 401 settings.set_int("window-width", width)?; 402 settings.set_int("window-height", height)?; 403 404 settings.set_boolean("is-maximized", self.main_window.is_maximized())?; 405 406 Ok(()) 407 } 408 409 fn load_window_size(&self) { 410 let settings = gio::Settings::new(APP_ID); 411 412 let width = settings.int("window-width"); 413 let height = settings.int("window-height"); 414 let is_maximized = settings.boolean("is-maximized"); 415 416 self.main_window.set_default_size(width, height); 417 418 if is_maximized { 419 self.main_window.maximize(); 420 } 421 } 422 }