use adw::prelude::*; use chrono::Datelike; use gtk::{gdk, glib}; use outlay_core::db::Database; use std::cell::Cell; use std::rc::Rc; use crate::budgets_view::BudgetsView; use crate::icon_theme; use crate::charts_view::ChartsView; use crate::credit_cards_view::CreditCardsView; use crate::forecast_view::ForecastView; use crate::insights_view::InsightsView; use crate::goals_view::GoalsView; use crate::history_view::HistoryView; use crate::log_view::LogView; use crate::recurring_view::RecurringView; use crate::settings_view::SettingsView; use crate::subscriptions_view::SubscriptionsView; use crate::wishlist_view::WishlistView; pub struct MainWindow { pub window: adw::ApplicationWindow, pub log_view: Rc, pub history_view: Rc, pub charts_view: Rc, pub budgets_view: Rc, pub insights_view: Rc, sidebar_list: gtk::ListBox, content_stack: gtk::Stack, content_page: adw::NavigationPage, } struct SidebarItem { id: &'static str, label: &'static str, icon: &'static str, color: &'static str, // CSS color visible in both light and dark modes } struct SidebarSection { label: &'static str, items: &'static [SidebarItem], } const SIDEBAR_SECTIONS: &[SidebarSection] = &[ SidebarSection { label: "TRACKING", items: &[ SidebarItem { id: "log", label: "Log", icon: "outlay-log", color: "#4dabf7" }, SidebarItem { id: "history", label: "History", icon: "outlay-history", color: "#9775fa" }, SidebarItem { id: "charts", label: "Charts", icon: "outlay-charts", color: "#ff8787" }, ], }, SidebarSection { label: "PLANNING", items: &[ SidebarItem { id: "budgets", label: "Budgets", icon: "outlay-budgets", color: "#69db7c" }, SidebarItem { id: "goals", label: "Goals", icon: "outlay-goals", color: "#ffd43b" }, SidebarItem { id: "forecast", label: "Forecast", icon: "outlay-forecast", color: "#74c0fc" }, ], }, SidebarSection { label: "MANAGEMENT", items: &[ SidebarItem { id: "recurring", label: "Recurring", icon: "outlay-recurring", color: "#38d9a9" }, SidebarItem { id: "subscriptions", label: "Subscriptions", icon: "outlay-subscriptions", color: "#e599f7" }, SidebarItem { id: "wishlist", label: "Wishlist", icon: "outlay-wishlist", color: "#ffa94d" }, SidebarItem { id: "creditcards", label: "Credit Cards", icon: "outlay-creditcards", color: "#a9e34b" }, SidebarItem { id: "insights", label: "Insights", icon: "outlay-insights", color: "#f783ac" }, ], }, ]; const SETTINGS_ITEM: SidebarItem = SidebarItem { id: "settings", label: "Settings", icon: "outlay-settings", color: "#adb5bd" }; fn all_sidebar_items() -> Vec<&'static SidebarItem> { SIDEBAR_SECTIONS.iter().flat_map(|s| s.items.iter()).collect() } impl MainWindow { pub fn new(app: &adw::Application, db: Rc) -> Self { let content_stack = gtk::Stack::new(); content_stack.set_transition_type(gtk::StackTransitionType::Crossfade); // Log view let log_view = Rc::new(LogView::new(db.clone(), app)); let log_scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .child(&log_view.container) .build(); content_stack.add_named(&log_scroll, Some("log")); // History view let history_view = HistoryView::new(db.clone()); let history_scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .child(&history_view.container) .build(); content_stack.add_named(&history_scroll, Some("history")); // Charts view let charts_view = ChartsView::new(db.clone()); content_stack.add_named(&charts_view.container, Some("charts")); // Budgets view let budgets_view = BudgetsView::new(db.clone()); content_stack.add_named(&budgets_view.container, Some("budgets")); // Goals view let goals_view = GoalsView::new(db.clone()); content_stack.add_named(&goals_view.container, Some("goals")); // Recurring view let recurring_view = Rc::new(RecurringView::new(db.clone())); content_stack.add_named(&recurring_view.container, Some("recurring")); // Subscriptions view let subscriptions_view = Rc::new(SubscriptionsView::new(db.clone())); content_stack.add_named(&subscriptions_view.container, Some("subscriptions")); // Cross-view refresh: changes in subscriptions refresh recurring and vice versa { let rec_ref = recurring_view.clone(); subscriptions_view.set_on_change(move || { rec_ref.refresh(); }); } { let sub_ref = subscriptions_view.clone(); recurring_view.set_on_change(move || { sub_ref.refresh(); }); } // Wishlist view let wishlist_view = WishlistView::new(db.clone()); content_stack.add_named(&wishlist_view.container, Some("wishlist")); // Forecast view let forecast_view = ForecastView::new(db.clone()); content_stack.add_named(&forecast_view.container, Some("forecast")); // Insights view let insights_view = InsightsView::new(db.clone()); content_stack.add_named(&insights_view.container, Some("insights")); // Credit Cards view let credit_cards_view = CreditCardsView::new(db.clone()); content_stack.add_named(&credit_cards_view.container, Some("creditcards")); // Settings view let settings_view = SettingsView::new(db.clone(), app); content_stack.add_named(&settings_view.container, Some("settings")); // Main sidebar items (top) - grouped by section with headers let sidebar_list = gtk::ListBox::new(); sidebar_list.set_selection_mode(gtk::SelectionMode::Single); sidebar_list.add_css_class("navigation-sidebar"); let all_items = all_sidebar_items(); for item in &all_items { let row = Self::make_sidebar_row(item); sidebar_list.append(&row); } // Compute section boundary indices: the row index where each section starts // TRACKING: 0, PLANNING: 3, MANAGEMENT: 6 let mut section_starts: Vec<(i32, &'static str)> = Vec::new(); let mut idx = 0i32; for section in SIDEBAR_SECTIONS { section_starts.push((idx, section.label)); idx += section.items.len() as i32; } sidebar_list.set_header_func(move |row, _before| { let ri = row.index(); for &(start, label) in §ion_starts { if ri == start { let header_box = gtk::Box::new(gtk::Orientation::Vertical, 0); // Add separator before all sections except the first if start > 0 { let sep = gtk::Separator::new(gtk::Orientation::Horizontal); sep.set_margin_top(6); sep.set_margin_bottom(2); sep.set_margin_start(12); sep.set_margin_end(12); header_box.append(&sep); } let section_label = gtk::Label::new(Some(label)); section_label.add_css_class("sidebar-section-label"); section_label.set_halign(gtk::Align::Start); header_box.append(§ion_label); row.set_header(Some(&header_box)); return; } } row.set_header(gtk::Widget::NONE); }); // Settings item (bottom) let settings_list = gtk::ListBox::new(); settings_list.set_selection_mode(gtk::SelectionMode::Single); settings_list.add_css_class("navigation-sidebar"); settings_list.append(&Self::make_sidebar_row(&SETTINGS_ITEM)); let sidebar_box = gtk::Box::new(gtk::Orientation::Vertical, 0); sidebar_list.set_vexpand(true); sidebar_box.append(&sidebar_list); sidebar_box.append(>k::Separator::new(gtk::Orientation::Horizontal)); sidebar_box.append(&settings_list); let sidebar_toolbar = adw::ToolbarView::new(); sidebar_toolbar.add_top_bar(&adw::HeaderBar::new()); sidebar_toolbar.set_content(Some(&sidebar_box)); let sidebar_page = adw::NavigationPage::builder() .title("Outlay") .child(&sidebar_toolbar) .build(); let content_toolbar = adw::ToolbarView::new(); content_toolbar.add_top_bar(&adw::HeaderBar::new()); content_toolbar.set_content(Some(&content_stack)); let content_page = adw::NavigationPage::builder() .title("Log") .child(&content_toolbar) .build(); // Wire sidebar selection to switch content, update title, and refresh views let history_view_ref = Rc::new(history_view); let charts_view_ref = Rc::new(charts_view); let budgets_view_ref = Rc::new(budgets_view); let insights_view_ref = Rc::new(insights_view); // Wire up data reset callback to refresh all views { let log_ref = log_view.clone(); let hist_ref = history_view_ref.clone(); let chart_ref = charts_view_ref.clone(); let budget_ref = budgets_view_ref.clone(); let insights_ref = insights_view_ref.clone(); settings_view.set_on_data_reset(move || { log_ref.refresh_categories(); hist_ref.refresh(); chart_ref.refresh(); let today = chrono::Local::now().date_naive(); budget_ref.set_month(today.year(), today.month()); insights_ref.refresh(); }); } // Shared month state for syncing across views let shared_month: Rc> = { let today = chrono::Local::now().date_naive(); Rc::new(Cell::new((today.year(), today.month()))) }; // Main list selection - deselect settings { let content_stack_ref = content_stack.clone(); let content_page_ref = content_page.clone(); let history_ref = history_view_ref.clone(); let charts_ref = charts_view_ref.clone(); let budgets_ref = budgets_view_ref.clone(); let log_ref = log_view.clone(); let insights_ref = insights_view_ref.clone(); let settings_list_ref = settings_list.clone(); let shared = shared_month.clone(); sidebar_list.connect_row_selected(move |_, row| { if let Some(row) = row { settings_list_ref.unselect_all(); let idx = row.index() as usize; if idx < all_sidebar_items().len() { // Save month from the view we're leaving let current_view = content_stack_ref.visible_child_name(); if let Some(ref name) = current_view { let month = match name.as_str() { "history" => Some(history_ref.get_month()), "charts" => Some(charts_ref.get_month()), "budgets" => Some(budgets_ref.get_month()), _ => None, }; if let Some(m) = month { shared.set(m); } } content_stack_ref.set_visible_child_name(all_sidebar_items()[idx].id); content_page_ref.set_title(all_sidebar_items()[idx].label); // Sync month to the view we're entering and refresh let (sy, sm) = shared.get(); match all_sidebar_items()[idx].id { "log" => log_ref.refresh_categories(), "history" => { history_ref.set_month(sy, sm); } "charts" => { charts_ref.set_month(sy, sm); } "budgets" => { budgets_ref.set_month(sy, sm); } "insights" => { insights_ref.refresh(); } _ => {} } } } }); } // Settings list selection - deselect main list { let content_stack_ref = content_stack.clone(); let content_page_ref = content_page.clone(); let sidebar_list_ref = sidebar_list.clone(); settings_list.connect_row_selected(move |_, row| { if row.is_some() { sidebar_list_ref.unselect_all(); content_stack_ref.set_visible_child_name(SETTINGS_ITEM.id); content_page_ref.set_title(SETTINGS_ITEM.label); } }); } // Select the first row by default if let Some(first_row) = sidebar_list.row_at_index(0) { sidebar_list.select_row(Some(&first_row)); } let split_view = adw::NavigationSplitView::new(); split_view.set_sidebar(Some(&sidebar_page)); split_view.set_content(Some(&content_page)); // Restore window size from settings let saved_width: i32 = db .get_setting("window_width") .ok() .flatten() .and_then(|s| s.parse().ok()) .unwrap_or(900); let saved_height: i32 = db .get_setting("window_height") .ok() .flatten() .and_then(|s| s.parse().ok()) .unwrap_or(600); let saved_maximized: bool = db .get_setting("window_maximized") .ok() .flatten() .map(|s| s == "true") .unwrap_or(false); let window = adw::ApplicationWindow::builder() .application(app) .title("Outlay") .default_width(saved_width) .default_height(saved_height) .content(&split_view) .build(); if saved_maximized { window.maximize(); } // Hide window on close instead of quitting { let db_ref = db.clone(); window.connect_close_request(move |win| { let (width, height) = win.default_size(); db_ref.set_setting("window_width", &width.to_string()).ok(); db_ref.set_setting("window_height", &height.to_string()).ok(); db_ref .set_setting("window_maximized", if win.is_maximized() { "true" } else { "false" }) .ok(); win.set_visible(false); glib::Propagation::Stop }); } // Keyboard shortcuts { let key_ctrl = gtk::EventControllerKey::new(); let sidebar_ref = sidebar_list.clone(); let settings_list_ref = settings_list.clone(); let content_ref = content_stack.clone(); let page_ref = content_page.clone(); let log_ref = log_view.clone(); let hist_ref = history_view_ref.clone(); let chart_ref = charts_view_ref.clone(); let window_ref = window.clone(); key_ctrl.connect_key_pressed(move |_, key, _, modifier| { if !modifier.contains(gdk::ModifierType::CONTROL_MASK) { return glib::Propagation::Proceed; } match key { gdk::Key::_1 | gdk::Key::_2 | gdk::Key::_3 | gdk::Key::_4 | gdk::Key::_5 | gdk::Key::_6 | gdk::Key::_7 | gdk::Key::_8 | gdk::Key::_9 | gdk::Key::_0 => { let idx = match key { gdk::Key::_1 => 0, gdk::Key::_2 => 1, gdk::Key::_3 => 2, gdk::Key::_4 => 3, gdk::Key::_5 => 4, gdk::Key::_6 => 5, gdk::Key::_7 => 6, gdk::Key::_8 => 7, gdk::Key::_9 => 8, gdk::Key::_0 => 9, _ => 0, }; if let Some(row) = sidebar_ref.row_at_index(idx) { settings_list_ref.unselect_all(); sidebar_ref.select_row(Some(&row)); content_ref.set_visible_child_name(all_sidebar_items()[idx as usize].id); page_ref.set_title(all_sidebar_items()[idx as usize].label); if idx == 0 { log_ref.refresh_categories(); } if idx == 1 { hist_ref.refresh(); } if idx == 2 { chart_ref.refresh(); } } } gdk::Key::comma => { sidebar_ref.unselect_all(); settings_list_ref.select_row(settings_list_ref.row_at_index(0).as_ref()); content_ref.set_visible_child_name(SETTINGS_ITEM.id); page_ref.set_title(SETTINGS_ITEM.label); } gdk::Key::e => { if let Some(row) = sidebar_ref.row_at_index(0) { settings_list_ref.unselect_all(); sidebar_ref.select_row(Some(&row)); } content_ref.set_visible_child_name("log"); page_ref.set_title("Log"); log_ref.refresh_categories(); log_ref.set_income_mode(false); log_ref.focus_amount(); } gdk::Key::i => { if let Some(row) = sidebar_ref.row_at_index(0) { settings_list_ref.unselect_all(); sidebar_ref.select_row(Some(&row)); } content_ref.set_visible_child_name("log"); page_ref.set_title("Log"); log_ref.refresh_categories(); log_ref.set_income_mode(true); log_ref.focus_amount(); } gdk::Key::question => { Self::show_shortcuts_window(&window_ref); } _ => return glib::Propagation::Proceed, } glib::Propagation::Stop }); window.add_controller(key_ctrl); } MainWindow { window, log_view, history_view: history_view_ref, charts_view: charts_view_ref, budgets_view: budgets_view_ref, insights_view: insights_view_ref, sidebar_list, content_stack, content_page, } } pub fn show(&self) { self.window.set_visible(true); self.window.present(); } pub fn switch_to_log(&self, income: bool) { if let Some(row) = self.sidebar_list.row_at_index(0) { self.sidebar_list.select_row(Some(&row)); } self.content_stack.set_visible_child_name("log"); self.content_page.set_title("Log"); self.log_view.refresh_categories(); self.log_view.set_income_mode(income); self.log_view.focus_amount(); } pub fn switch_to_history_filtered(&self, category_id: i64) { if let Some(row) = self.sidebar_list.row_at_index(1) { self.sidebar_list.select_row(Some(&row)); } self.content_stack.set_visible_child_name("history"); self.content_page.set_title("History"); self.history_view.refresh(); self.history_view.set_category_filter(category_id); } pub fn switch_to_insights(&self) { // Find the sidebar index for "insights" let idx = all_sidebar_items().iter().position(|item| item.id == "insights"); if let Some(i) = idx { if let Some(row) = self.sidebar_list.row_at_index(i as i32) { self.sidebar_list.select_row(Some(&row)); } } self.content_stack.set_visible_child_name("insights"); self.content_page.set_title("Insights"); self.insights_view.refresh(); } pub fn save_window_state(&self, db: &Database) { let (width, height) = self.window.default_size(); db.set_setting("window_width", &width.to_string()).ok(); db.set_setting("window_height", &height.to_string()).ok(); db.set_setting( "window_maximized", if self.window.is_maximized() { "true" } else { "false" }, ) .ok(); } fn show_shortcuts_window(parent: &adw::ApplicationWindow) { let ui = r#" true Outlay 12 Navigation <Ctrl>1 Log <Ctrl>2 History <Ctrl>3 Charts <Ctrl>4 Budgets <Ctrl>5 Goals <Ctrl>6 Recurring <Ctrl>7 Subscriptions <Ctrl>8 Wishlist <Ctrl>9 Forecast <Ctrl>0 Insights <Ctrl>comma Settings Transaction Entry <Ctrl>e New expense <Ctrl>i New income General <Ctrl>question Keyboard shortcuts <Ctrl>q Quit "#; let builder = gtk::Builder::from_string(ui); let win: gtk::ShortcutsWindow = builder.object("shortcuts_window").unwrap(); win.set_transient_for(Some(parent)); win.present(); } fn make_sidebar_row(item: &SidebarItem) -> gtk::ListBoxRow { let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 14); hbox.set_margin_top(12); hbox.set_margin_bottom(12); hbox.set_margin_start(16); hbox.set_margin_end(12); let tinted = icon_theme::get_tinted_icon_name(item.icon, item.color); let icon = gtk::Image::from_icon_name(&tinted); icon.set_pixel_size(24); let label = gtk::Label::new(Some(item.label)); label.set_halign(gtk::Align::Start); label.add_css_class("sidebar-label"); hbox.append(&icon); hbox.append(&label); let row = gtk::ListBoxRow::new(); row.set_child(Some(&hbox)); row } }