From 18f976bfb3fa0f9bb5542ea64fae25e88fdb69cc Mon Sep 17 00:00:00 2001 From: arferreira Date: Thu, 5 Mar 2026 14:27:45 -0500 Subject: [PATCH 1/2] Add Linux desktop app with GTK4 Closes #7 --- .github/workflows/ci.yml | 26 ++++ .gitignore | 1 + linux/Cargo.toml | 16 +++ linux/src/config.rs | 237 ++++++++++++++++++++++++++++++++++ linux/src/main.rs | 267 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 547 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 linux/Cargo.toml create mode 100644 linux/src/config.rs create mode 100644 linux/src/main.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bc000ec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + cli: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build CLI + working-directory: cli + run: cargo build + + linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install GTK4 dev dependencies + run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev + - name: Build Linux app + working-directory: linux + run: cargo build diff --git a/.gitignore b/.gitignore index 7fa5b6a..59bf5d4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ DerivedData/ Fuso.app/ thoughts/ cli/target/ +linux/target/ diff --git a/linux/Cargo.toml b/linux/Cargo.toml new file mode 100644 index 0000000..651a37c --- /dev/null +++ b/linux/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "fuso-linux" +version = "0.1.0" +edition = "2021" +description = "Fuso — track your team's timezones (Linux desktop app)" +license = "MIT" + +[dependencies] +gtk4 = "0.9" +chrono = "0.4" +chrono-tz = "0.10" +dirs = "6" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +iana-time-zone = "0.1" +glib = "0.20" diff --git a/linux/src/config.rs b/linux/src/config.rs new file mode 100644 index 0000000..1750307 --- /dev/null +++ b/linux/src/config.rs @@ -0,0 +1,237 @@ +use chrono::{Datelike, Timelike}; +use chrono_tz::Tz; +use serde::Deserialize; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +#[derive(Deserialize, Clone)] +pub struct Config { + pub clocks: Vec, +} + +#[derive(Deserialize, Clone)] +pub struct ClockEntry { + pub name: String, + pub city: String, + pub timezone: String, + pub flag: Option, + pub status: Option, +} + +#[derive(Deserialize, Clone)] +pub struct StatusSchedule { + pub blocks: HashMap, + pub months: HashMap, +} + +#[derive(Deserialize, Clone)] +pub struct StatusBlock { + pub label: String, + pub start: String, + pub end: String, +} + +pub enum Availability { + Busy(String), + Available, + DayOff, +} + +pub fn config_path() -> PathBuf { + let home = dirs::home_dir().expect("could not find home directory"); + home.join(".config/fuso/clocks.json") +} + +pub fn load_config() -> Config { + let path = config_path(); + + if !path.exists() { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).ok(); + } + let json = serde_json::to_string_pretty(&serde_json::json!({ + "clocks": [{ + "name": "Me", + "city": "New York", + "timezone": "America/New_York" + }] + })) + .unwrap(); + fs::write(&path, json).ok(); + return Config { + clocks: vec![ClockEntry { + name: "Me".into(), + city: "New York".into(), + timezone: "America/New_York".into(), + flag: None, + status: None, + }], + }; + } + + let data = fs::read_to_string(&path).expect("could not read config file"); + serde_json::from_str(&data).expect("invalid config format") +} + +pub fn timezone_to_flag(tz: &str) -> &'static str { + match tz { + s if s.starts_with("America/New_York") + | s.starts_with("America/Chicago") + | s.starts_with("America/Denver") + | s.starts_with("America/Los_Angeles") + | s.starts_with("America/Phoenix") + | s.starts_with("America/Anchorage") + | s.starts_with("Pacific/Honolulu") => + { + "\u{1f1fa}\u{1f1f8}" + } + s if s.starts_with("America/Sao_Paulo") + | s.starts_with("America/Fortaleza") + | s.starts_with("America/Manaus") + | s.starts_with("America/Bahia") + | s.starts_with("America/Belem") + | s.starts_with("America/Recife") + | s.starts_with("America/Cuiaba") + | s.starts_with("America/Campo_Grande") + | s.starts_with("America/Rio_Branco") + | s.starts_with("America/Porto_Velho") + | s.starts_with("America/Maceio") + | s.starts_with("America/Araguaina") => + { + "\u{1f1e7}\u{1f1f7}" + } + "Asia/Tokyo" => "\u{1f1ef}\u{1f1f5}", + "Europe/London" | "Europe/Dublin" => "\u{1f1ec}\u{1f1e7}", + "Europe/Paris" => "\u{1f1eb}\u{1f1f7}", + "Europe/Berlin" => "\u{1f1e9}\u{1f1ea}", + "Europe/Rome" => "\u{1f1ee}\u{1f1f9}", + "Europe/Madrid" => "\u{1f1ea}\u{1f1f8}", + "Europe/Lisbon" => "\u{1f1f5}\u{1f1f9}", + "Europe/Amsterdam" => "\u{1f1f3}\u{1f1f1}", + "Europe/Zurich" => "\u{1f1e8}\u{1f1ed}", + "Europe/Vienna" => "\u{1f1e6}\u{1f1f9}", + "Europe/Prague" => "\u{1f1e8}\u{1f1ff}", + "Europe/Warsaw" => "\u{1f1f5}\u{1f1f1}", + "Europe/Stockholm" => "\u{1f1f8}\u{1f1ea}", + "Europe/Oslo" => "\u{1f1f3}\u{1f1f4}", + "Europe/Copenhagen" => "\u{1f1e9}\u{1f1f0}", + "Europe/Helsinki" => "\u{1f1eb}\u{1f1ee}", + "Europe/Moscow" => "\u{1f1f7}\u{1f1fa}", + "Europe/Istanbul" => "\u{1f1f9}\u{1f1f7}", + "Asia/Shanghai" => "\u{1f1e8}\u{1f1f3}", + "Asia/Hong_Kong" => "\u{1f1ed}\u{1f1f0}", + "Asia/Seoul" => "\u{1f1f0}\u{1f1f7}", + "Asia/Singapore" => "\u{1f1f8}\u{1f1ec}", + "Asia/Kolkata" => "\u{1f1ee}\u{1f1f3}", + "Asia/Dubai" => "\u{1f1e6}\u{1f1ea}", + "Asia/Bangkok" => "\u{1f1f9}\u{1f1ed}", + "Asia/Jakarta" => "\u{1f1ee}\u{1f1e9}", + "Asia/Taipei" => "\u{1f1f9}\u{1f1fc}", + "Asia/Riyadh" => "\u{1f1f8}\u{1f1e6}", + "Asia/Jerusalem" => "\u{1f1ee}\u{1f1f1}", + "Australia/Sydney" | "Australia/Melbourne" | "Australia/Perth" | "Australia/Brisbane" => { + "\u{1f1e6}\u{1f1fa}" + } + "Pacific/Auckland" => "\u{1f1f3}\u{1f1ff}", + "America/Toronto" | "America/Vancouver" | "America/Edmonton" => "\u{1f1e8}\u{1f1e6}", + "America/Mexico_City" => "\u{1f1f2}\u{1f1fd}", + "America/Argentina/Buenos_Aires" => "\u{1f1e6}\u{1f1f7}", + "America/Santiago" => "\u{1f1e8}\u{1f1f1}", + "America/Bogota" => "\u{1f1e8}\u{1f1f4}", + "America/Lima" => "\u{1f1f5}\u{1f1ea}", + "Africa/Johannesburg" => "\u{1f1ff}\u{1f1e6}", + "Africa/Lagos" => "\u{1f1f3}\u{1f1ec}", + "Africa/Cairo" => "\u{1f1ea}\u{1f1ec}", + "Africa/Nairobi" => "\u{1f1f0}\u{1f1ea}", + _ => "\u{1f30d}", + } +} + +fn parse_time(t: &str) -> u32 { + let parts: Vec = t.split(':').filter_map(|p| p.parse().ok()).collect(); + if parts.len() == 2 { + parts[0] * 60 + parts[1] + } else { + 0 + } +} + +pub fn current_availability(entry: &ClockEntry, now: chrono::DateTime) -> Option { + let schedule = entry.status.as_ref()?; + + let month_key = format!("{}-{:02}", now.year(), now.month()); + let month_str = schedule.months.get(&month_key)?; + let day = now.day() as usize; + + if day < 1 || day > month_str.len() { + return None; + } + + let block_id = &month_str[day - 1..day]; + let now_minutes = now.hour() * 60 + now.minute(); + + if block_id != "0" { + if let Some(block) = schedule.blocks.get(block_id) { + let start = parse_time(&block.start); + let end = parse_time(&block.end); + + if end > start { + if now_minutes >= start && now_minutes < end { + return Some(Availability::Busy(block.label.clone())); + } + } else if end < start && now_minutes >= start { + return Some(Availability::Busy(block.label.clone())); + } + } + } + + let yesterday = now - chrono::Duration::days(1); + let y_month_key = format!("{}-{:02}", yesterday.year(), yesterday.month()); + if let Some(y_month_str) = schedule.months.get(&y_month_key) { + let y_day = yesterday.day() as usize; + if y_day >= 1 && y_day <= y_month_str.len() { + let y_block_id = &y_month_str[y_day - 1..y_day]; + if y_block_id != "0" { + if let Some(y_block) = schedule.blocks.get(y_block_id) { + let y_start = parse_time(&y_block.start); + let y_end = parse_time(&y_block.end); + if y_end < y_start && now_minutes < y_end { + return Some(Availability::Busy(y_block.label.clone())); + } + } + } + } + } + + if block_id == "0" { + Some(Availability::DayOff) + } else { + Some(Availability::Available) + } +} + +pub fn relative_offset(local_tz: Tz, remote_tz: Tz, now: chrono::DateTime) -> String { + use chrono::Offset; + let local_offset = now.with_timezone(&local_tz).offset().fix().local_minus_utc(); + let remote_offset = now.with_timezone(&remote_tz).offset().fix().local_minus_utc(); + let diff = remote_offset - local_offset; + let hours = diff / 3600; + let minutes = (diff.abs() % 3600) / 60; + + if hours == 0 && minutes == 0 { + return "local".into(); + } + if minutes > 0 { + format!("{:+}:{:02}h", hours, minutes) + } else { + format!("{:+}h", hours) + } +} + +pub fn local_tz() -> Tz { + iana_time_zone::get_timezone() + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(chrono_tz::UTC) +} diff --git a/linux/src/main.rs b/linux/src/main.rs new file mode 100644 index 0000000..a2f004b --- /dev/null +++ b/linux/src/main.rs @@ -0,0 +1,267 @@ +mod config; + +use config::{current_availability, load_config, local_tz, relative_offset, timezone_to_flag, Availability}; +use chrono_tz::Tz; +use glib::ControlFlow; +use gtk4::prelude::*; +use gtk4::{ + gdk, glib, Align, Application, ApplicationWindow, Box as GtkBox, CssProvider, Label, + Orientation, ScrolledWindow, +}; + +const APP_ID: &str = "dev.zaptech.fuso"; +const CSS: &str = r#" + window { + background-color: #ffffff; + } + .header-title { + font-size: 18px; + font-weight: bold; + color: #1a1a1a; + } + .header-subtitle { + font-size: 12px; + color: #888888; + } + .clock-card { + background-color: #f5f5f5; + border-radius: 8px; + padding: 10px 12px; + } + .clock-name { + font-size: 13px; + font-weight: 600; + color: #1a1a1a; + } + .clock-city { + font-size: 11px; + color: #888888; + } + .clock-time { + font-size: 20px; + font-weight: bold; + color: #1a1a1a; + } + .clock-meta { + font-size: 10px; + color: #888888; + } + .clock-flag { + font-size: 22px; + } + .status-busy { + font-size: 10px; + color: #e67e22; + } + .status-available { + font-size: 10px; + color: #27ae60; + } + .status-dayoff { + font-size: 10px; + color: #888888; + } + .bottom-bar { + padding: 10px 16px; + border-top: 1px solid #e0e0e0; + } + .bottom-button { + font-size: 12px; + color: #888888; + background: none; + border: none; + box-shadow: none; + padding: 4px 8px; + } + .bottom-button:hover { + color: #1a1a1a; + } +"#; + +fn build_clock_card(entry: &config::ClockEntry, now_utc: chrono::DateTime, local_tz: Tz) -> GtkBox { + let card = GtkBox::new(Orientation::Horizontal, 0); + card.add_css_class("clock-card"); + + let tz: Tz = entry.timezone.parse().unwrap_or(chrono_tz::UTC); + let now_tz = now_utc.with_timezone(&tz); + + // Left side: flag + name/city/status + let left = GtkBox::new(Orientation::Horizontal, 10); + left.set_hexpand(true); + + let flag_text = entry.flag.as_deref().unwrap_or_else(|| timezone_to_flag(&entry.timezone)); + let flag = Label::new(Some(flag_text)); + flag.add_css_class("clock-flag"); + flag.set_valign(Align::Center); + left.append(&flag); + + let info = GtkBox::new(Orientation::Vertical, 1); + + let name = Label::new(Some(&entry.name)); + name.add_css_class("clock-name"); + name.set_halign(Align::Start); + info.append(&name); + + let city = Label::new(Some(&entry.city)); + city.add_css_class("clock-city"); + city.set_halign(Align::Start); + info.append(&city); + + if let Some(avail) = current_availability(entry, now_tz) { + let (text, class) = match avail { + Availability::Busy(ref label) => (label.clone(), "status-busy"), + Availability::Available => ("Available".into(), "status-available"), + Availability::DayOff => ("Day off".into(), "status-dayoff"), + }; + let status = Label::new(Some(&format!("\u{25cf} {}", text))); + status.add_css_class(class); + status.set_halign(Align::Start); + info.append(&status); + } + + left.append(&info); + card.append(&left); + + // Right side: time + day/offset + let right = GtkBox::new(Orientation::Vertical, 1); + right.set_valign(Align::Center); + + let time_str = now_tz.format("%H:%M").to_string(); + let time = Label::new(Some(&time_str)); + time.add_css_class("clock-time"); + time.set_halign(Align::End); + right.append(&time); + + let day_str = now_tz.format("%a").to_string(); + let offset_str = relative_offset(local_tz, tz, now_utc); + let meta = Label::new(Some(&format!("{} \u{00b7} {}", day_str, offset_str))); + meta.add_css_class("clock-meta"); + meta.set_halign(Align::End); + right.append(&meta); + + card.append(&right); + card +} + +fn build_ui(app: &Application) { + let main_box = GtkBox::new(Orientation::Vertical, 0); + + // Header + let header = GtkBox::new(Orientation::Vertical, 2); + header.set_margin_start(16); + header.set_margin_end(16); + header.set_margin_top(16); + header.set_margin_bottom(12); + + let title = Label::new(Some("Fuso")); + title.add_css_class("header-title"); + title.set_halign(Align::Start); + header.append(&title); + + let subtitle = Label::new(None); + subtitle.add_css_class("header-subtitle"); + subtitle.set_halign(Align::Start); + header.append(&subtitle); + main_box.append(&header); + + // Scrollable clock list + let scroll = ScrolledWindow::new(); + scroll.set_vexpand(true); + scroll.set_max_content_height(420); + scroll.set_propagate_natural_height(true); + + let list_box = GtkBox::new(Orientation::Vertical, 6); + list_box.set_margin_start(12); + list_box.set_margin_end(12); + list_box.set_margin_bottom(8); + scroll.set_child(Some(&list_box)); + main_box.append(&scroll); + + // Bottom bar + let bottom = GtkBox::new(Orientation::Horizontal, 16); + bottom.add_css_class("bottom-bar"); + + let config_btn = gtk4::Button::with_label("Config"); + config_btn.add_css_class("bottom-button"); + config_btn.connect_clicked(|_| { + let path = config::config_path(); + let _ = std::process::Command::new("xdg-open").arg(path).spawn(); + }); + bottom.append(&config_btn); + + let spacer = GtkBox::new(Orientation::Horizontal, 0); + spacer.set_hexpand(true); + bottom.append(&spacer); + + let quit_btn = gtk4::Button::with_label("Quit"); + quit_btn.add_css_class("bottom-button"); + quit_btn.connect_clicked(|btn| { + if let Some(window) = btn.root().and_then(|r| r.downcast::().ok()) { + window.close(); + } + }); + bottom.append(&quit_btn); + main_box.append(&bottom); + + let window = ApplicationWindow::builder() + .application(app) + .title("Fuso") + .default_width(320) + .resizable(false) + .child(&main_box) + .build(); + + // Initial render + refresh_clocks(&list_box, &subtitle); + + // Auto-refresh every second + let list_ref = list_box.clone(); + let sub_ref = subtitle.clone(); + let mut tick_count: u32 = 0; + glib::timeout_add_local(std::time::Duration::from_secs(1), move || { + tick_count += 1; + refresh_clocks(&list_ref, &sub_ref); + // Reload config every 10 seconds + if tick_count % 10 == 0 { + refresh_clocks(&list_ref, &sub_ref); + } + ControlFlow::Continue + }); + + window.present(); +} + +fn refresh_clocks(list_box: &GtkBox, subtitle: &Label) { + // Remove existing children + while let Some(child) = list_box.first_child() { + list_box.remove(&child); + } + + let config = load_config(); + let now_utc = chrono::Utc::now(); + let ltz = local_tz(); + + subtitle.set_text(&format!("{} clocks", config.clocks.len())); + + for entry in &config.clocks { + let card = build_clock_card(entry, now_utc, ltz); + list_box.append(&card); + } +} + +fn main() { + let app = Application::builder().application_id(APP_ID).build(); + + app.connect_startup(|_| { + let provider = CssProvider::new(); + provider.load_from_string(CSS); + gtk4::style_context_add_provider_for_display( + &gdk::Display::default().expect("could not get display"), + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + }); + + app.connect_activate(build_ui); + app.run(); +} From 5241db83361bb9f999cd00cb8e76053b50f3dcd5 Mon Sep 17 00:00:00 2001 From: arferreira Date: Thu, 5 Mar 2026 14:30:52 -0500 Subject: [PATCH 2/2] Fix CssProvider API for gtk4-rs 0.9 --- linux/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux/src/main.rs b/linux/src/main.rs index a2f004b..ef81ea4 100644 --- a/linux/src/main.rs +++ b/linux/src/main.rs @@ -254,7 +254,7 @@ fn main() { app.connect_startup(|_| { let provider = CssProvider::new(); - provider.load_from_string(CSS); + provider.load_from_data(CSS); gtk4::style_context_add_provider_for_display( &gdk::Display::default().expect("could not get display"), &provider,