dwata icon indicating copy to clipboard operation
dwata copied to clipboard

Email Client Desktop App using Slint

Open brainless opened this issue 3 months ago • 0 comments

Email Client Desktop App using Slint

Overview

Create a desktop email client application using Rust and the Slint GUI framework. This app will display email data from a SQLite3 database (generated separately in #173).

Depends on: #173 (Fake Data Generator)


Project Structure

This should be implemented as a separate binary crate in the Cargo workspace:

Binary name: dwata-app-slint

Workspace structure:

dwata/
├── Cargo.toml                 # Workspace manifest
├── dwata-db/                  # Shared library (from #173)
├── fake-data-generator/       # Binary from #173
├── dwata-app-egui/            # Binary from #174
├── dwata-app-iced/            # Binary from #175
└── dwata-app-slint/           # THIS BINARY (Slint implementation)
    ├── Cargo.toml
    ├── build.rs               # Slint build script
    ├── ui/                    # Slint markup files
    │   ├── app-window.slint
    │   ├── navigation.slint
    │   ├── email-list.slint
    │   ├── email-detail.slint
    │   ├── contacts.slint
    │   └── files.slint
    └── src/
        └── main.rs

Cargo.toml for dwata-app-slint:

[package]
name = "dwata-app-slint"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "dwata-app-slint"
path = "src/main.rs"

[dependencies]
dwata-db = { path = "../dwata-db" }  # Shared database library
slint = "1.13.1"
dirs = "5.0"
chrono = "0.4"

[build-dependencies]
slint-build = "1.13.1"

build.rs:

fn main() {
    slint_build::compile("ui/app-window.slint").expect("Slint build failed");
}

Usage:

# Build and run the Slint email client
cargo run --bin dwata-app-slint

# Or build in release mode
cargo build --release --bin dwata-app-slint
./target/release/dwata-app-slint

Slint Framework Overview

Key Resources (Local Paths)

  • Slint Repository: /home/nocodo/Projects/slint (checked out to v1.13.1)
  • Slint Rust Template: /home/nocodo/Projects/slint-rust-template
  • Examples to Reference:
    • Todo App: /home/nocodo/Projects/slint/examples/todo/
    • Gallery (widgets showcase): /home/nocodo/Projects/slint/examples/gallery/
    • Maps (async data loading): /home/nocodo/Projects/slint/examples/maps/

Slint Architecture Principles

Declarative UI Definition:

  • UI is defined in .slint files using Slint's markup language
  • Rust code interacts with the UI through generated bindings
  • Properties and callbacks bridge between Slint UI and Rust logic

Key Features for This Project:

  1. ListView Widget: Virtual scrolling list (only visible items instantiated)
  2. ModelRc/VecModel: For managing dynamic lists of data
  3. Callbacks: UI events trigger Rust functions
  4. Properties: Two-way data binding between UI and Rust
  5. Threading: Event loop runs in main thread, use invoke_from_event_loop() for cross-thread communication

Reference Example - Todo app pattern:

// From /home/nocodo/Projects/slint/examples/todo/rust/lib.rs
slint::include_modules!();

let todo_model = Rc::new(slint::VecModel::<TodoItem>::from(vec![...]));
let main_window = MainWindow::new().unwrap();

main_window.on_todo_added({
    let todo_model = todo_model.clone();
    move |text| todo_model.push(TodoItem { checked: false, title: text })
});

main_window.set_todo_model(todo_model.clone().into());
main_window.run()?;

Using the Shared dwata-db Library

IMPORTANT: This application should use the shared dwata-db library crate for ALL database operations. Do NOT create your own db/ module or duplicate any database code.

What dwata-db Provides

The dwata-db library includes:

  1. Database Models - All Rust structs:

    use dwata_db::{Account, Email, Contact, Folder, Label, Attachment};
    
  2. EmailRepository - All database query functions:

    use dwata_db::EmailRepository;
    
    let repo = EmailRepository::new(db_path)?;
    
    // Available methods:
    repo.get_accounts()?;
    repo.get_emails_by_folder(folder_id)?;
    repo.get_email_by_id(email_id)?;
    repo.search_emails_by_subject(query)?;
    repo.get_contacts(account_id)?;
    repo.get_labels(account_id)?;
    repo.get_folders(account_id)?;
    repo.get_attachments(email_id)?;
    repo.get_all_attachments()?;
    repo.mark_as_read(email_id)?;
    repo.toggle_star(email_id)?;
    

Benefits

  • No code duplication - Database logic defined once
  • Type safety - Shared types across all binaries
  • Consistent behavior - Same queries used by all apps
  • Easy maintenance - Changes in one place

Database Connection

Database Location

The app will read from the SQLite database at:

  • Linux/macOS: ~/.config/dwata/db.sqlite3
  • Windows: %APPDATA%\dwata\db.sqlite3

Example path: /home/username/.config/dwata/db.sqlite3

Database Requirements

  • The app does NOT create the database - it must already exist (created by the generator from #173)
  • On startup: Check if database file exists at expected location
  • If database not found: Display error message and exit gracefully
    • Error message should show expected database path
    • Suggest running the fake data generator first

Example Implementation

use dwata_db::EmailRepository;
use std::path::PathBuf;

fn get_db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
    let config_dir = dirs::config_dir()
        .ok_or("Could not determine config directory")?;
    
    let db_path = config_dir.join("dwata").join("db.sqlite3");
    
    if !db_path.exists() {
        return Err(format!(
            "Database not found at: {}\nPlease run the fake data generator first.",
            db_path.display()
        ).into());
    }
    
    Ok(db_path)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let db_path = get_db_path()?;
    let repository = EmailRepository::new(db_path)?;
    
    // Create Slint UI and pass repository
    // ...
    
    Ok(())
}

Architecture

Threading Model with Slint

Important Constraints:

  • Slint event loop MUST run in the main thread
  • All UI components must be created in the same thread
  • Use slint::invoke_from_event_loop() for cross-thread communication

Threading Strategy

Since database operations can block, we need to handle them asynchronously:

Option 1: Background Thread with invoke_from_event_loop:

use std::sync::{Arc, Mutex};
use std::thread;

let repo = Arc::new(Mutex::new(EmailRepository::new(db_path)?));
let ui_handle = ui.as_weak();

ui.on_load_emails({
    let repo = repo.clone();
    let ui_handle = ui_handle.clone();
    
    move |folder_id| {
        let repo = repo.clone();
        let ui_handle = ui_handle.clone();
        
        thread::spawn(move || {
            let emails = repo.lock().unwrap().get_emails_by_folder(folder_id).unwrap();
            let email_models = emails.into_iter().map(|e| /* convert to Slint model */).collect();
            
            slint::invoke_from_event_loop(move || {
                let ui = ui_handle.unwrap();
                ui.set_emails(Rc::new(VecModel::from(email_models)).into());
            }).unwrap();
        });
    }
});

Option 2: Use slint::spawn_local with async (if using async runtime):

ui.on_load_emails({
    let repo = repo.clone();
    move |folder_id| {
        let repo = repo.clone();
        slint::spawn_local(async move {
            let emails = tokio::task::spawn_blocking(move || {
                repo.lock().unwrap().get_emails_by_folder(folder_id)
            }).await.unwrap().unwrap();
            
            // Update UI directly (already in event loop)
            ui.set_emails(/* ... */);
        }).unwrap();
    }
});

User Interface Layout

UI Layout Strategy (Same as #174)

The UI layout should match the specification in issue #174:

  • 3-Column Layout:

    • Column 1: Navigation Panel (left, ~200-250px fixed)
    • Column 2: List View (middle, flexible)
    • Column 3: Detail View (right, flexible)
  • Special Views:

    • Files view merges columns 2 and 3 into wide view
    • Search results displayed in list view format

Slint Implementation Approach

Example from gallery (/home/nocodo/Projects/slint/examples/gallery/gallery.slint):

export component App inherits Window {
    HorizontalLayout {
        side-bar := SideBar {
            title: "Dwata Email Client";
            model: ["Emails", "Contacts", "Files"];
        }
        
        if(side-bar.current-item == 0) : EmailsView {}
        if(side-bar.current-item == 1) : ContactsView {}
        if(side-bar.current-item == 2) : FilesView {}
    }
}

Component Structure

ui/app-window.slint (main component):

import { NavigationPanel } from "navigation.slint";
import { EmailListView } from "email-list.slint";
import { EmailDetailView } from "email-detail.slint";
import { ContactsView } from "contacts.slint";
import { FilesView } from "files.slint";

export struct EmailItem {
    id: int,
    subject: string,
    from_name: string,
    from_email: string,
    is_read: bool,
    is_starred: bool,
    received_at: string,
}

export component AppWindow inherits Window {
    title: "Dwata Email Client";
    preferred-width: 1200px;
    preferred-height: 800px;
    
    // Properties
    in-out property <[EmailItem]> emails;
    in-out property <int> current-view: 0; // 0=Emails, 1=Contacts, 2=Files
    in-out property <int> selected-email-id: -1;
    
    // Callbacks
    callback load-emails(int /* folder_id */);
    callback load-email-detail(int /* email_id */);
    callback toggle-star(int /* email_id */);
    callback mark-as-read(int /* email_id */);
    callback search-emails(string /* query */);
    
    HorizontalLayout {
        // Column 1: Navigation (~200px)
        nav := NavigationPanel {
            width: 200px;
            
            folder-selected => {
                root.load-emails(self.selected-folder-id);
            }
        }
        
        // Columns 2 & 3: Dynamic based on view
        if current-view == 0: HorizontalLayout {
            // Email List View
            email-list := EmailListView {
                width: 400px;
                emails: root.emails;
                
                email-selected(id) => {
                    root.selected-email-id = id;
                    root.load-email-detail(id);
                }
                
                star-toggled(id) => {
                    root.toggle-star(id);
                }
            }
            
            // Email Detail View
            EmailDetailView {
                email-id: root.selected-email-id;
            }
        }
        
        if current-view == 1: ContactsView {
            // Contacts spanning columns 2+3
        }
        
        if current-view == 2: FilesView {
            // Files spanning columns 2+3
        }
    }
}

ui/email-list.slint (using ListView):

import { ListView, VerticalBox } from "std-widgets.slint";

export component EmailListView {
    in property <[EmailItem]> emails;
    
    callback email-selected(int /* email_id */);
    callback star-toggled(int /* email_id */);
    
    VerticalBox {
        ListView {
            for email[idx] in root.emails : Rectangle {
                height: 60px;
                background: touch.has-hover ? #f0f0f0 : white;
                
                touch := TouchArea {
                    clicked => {
                        root.email-selected(email.id);
                    }
                }
                
                VerticalLayout {
                    padding: 8px;
                    
                    // First row: checkbox, star, subject
                    HorizontalLayout {
                        CheckBox { }
                        
                        star := Text {
                            text: email.is-starred ? "★" : "☆";
                            font-size: 16px;
                        }
                        
                        Text {
                            text: email.subject;
                            font-weight: email.is-read ? 400 : 700;
                            horizontal-stretch: 1;
                        }
                    }
                    
                    // Second row: sender and timestamp
                    HorizontalLayout {
                        Text {
                            text: email.from-name;
                            font-size: 12px;
                            color: #666;
                        }
                        
                        Text {
                            text: email.received-at;
                            font-size: 12px;
                            color: #666;
                            horizontal-alignment: right;
                        }
                    }
                }
            }
        }
    }
}

Virtual Scrolling

Good News: Slint's ListView component has built-in virtual scrolling!

  • Elements only instantiated when visible
  • Automatically handles scrolling performance
  • No manual virtual list implementation needed

Reference: /home/nocodo/Projects/slint/examples/todo/ui/todo.slint and gallery examples


State Management

Slint Property System

Properties in Slint files become getters/setters in Rust:

// In .slint file:
// property <[EmailItem]> emails;
// property <int> selected-email-id;

// In Rust:
ui.set_emails(email_model.into());
ui.set_selected_email_id(42);

let current_id = ui.get_selected_email_id();

Model Management

Use VecModel for dynamic lists:

use slint::{Model, VecModel};
use std::rc::Rc;

// Define Slint struct (in .slint file)
#[derive(Clone)]
struct EmailItem {
    id: i32,
    subject: slint::SharedString,
    from_name: slint::SharedString,
    // ... other fields
}

// In Rust:
let email_model = Rc::new(VecModel::<EmailItem>::default());

// Fetch from database
let emails = repo.get_emails_by_folder(folder_id)?;
for email in emails {
    email_model.push(EmailItem {
        id: email.id.unwrap() as i32,
        subject: email.subject.into(),
        from_name: email.from_name.unwrap_or_default().into(),
        // ... convert fields
    });
}

// Set to UI
ui.set_emails(email_model.into());

Data Conversion

Converting dwata_db Models to Slint Structs

Challenge: dwata_db::Email contains complex types (Vec, Option) that don't map directly to Slint.

Solution: Create intermediate Slint-compatible structs:

// In .slint file, define:
export struct EmailItem {
    id: int,
    subject: string,
    from_name: string,
    from_email: string,
    to_emails: string,  // JSON or comma-separated
    is_read: bool,
    is_starred: bool,
    received_at: string,  // Formatted timestamp
}

// In Rust, convert:
fn email_to_slint(email: &dwata_db::Email) -> EmailItem {
    use chrono::{DateTime, Utc};
    
    let received_at = DateTime::<Utc>::from_timestamp(email.received_at, 0)
        .map(|dt| format_timestamp(&dt))
        .unwrap_or_default();
    
    EmailItem {
        id: email.id.unwrap_or(0) as i32,
        subject: email.subject.clone().into(),
        from_name: email.from_name.clone().unwrap_or_default().into(),
        from_email: email.from_email.clone().into(),
        to_emails: serde_json::to_string(&email.to_emails).unwrap_or_default().into(),
        is_read: email.is_read,
        is_starred: email.is_starred,
        received_at: received_at.into(),
    }
}

fn format_timestamp(dt: &DateTime<Utc>) -> String {
    let now = Utc::now();
    let local = dt.with_timezone(&chrono::Local);
    
    if local.date_naive() == now.date_naive() {
        // Same day: show time
        local.format("%I:%M %p").to_string()
    } else if local.year() == now.year() {
        // Same year: show month/day
        local.format("%b %d").to_string()
    } else {
        // Different year: show full date
        local.format("%b %d, %Y").to_string()
    }
}

Features Checklist

Core Features

  • [ ] Database existence check on startup
  • [ ] Graceful error handling if database not found
  • [ ] Account switching (dropdown/selector in navigation)
  • [ ] Folder/label navigation
  • [ ] Email list with virtual scrolling (ListView)
  • [ ] Email detail view
  • [ ] Contact list and detail view
  • [ ] Files/attachments browser (merged columns)
  • [ ] Search by email subject
  • [ ] Responsive layout (handle window resize)

Email Actions

  • [ ] Mark email as read/unread
  • [ ] Star/unstar emails
  • [ ] Select and view email
  • [ ] View attachments metadata

UI/UX

  • [ ] Virtual scrolling maintains 60fps (ListView handles this)
  • [ ] Loading indicators during data fetch
  • [ ] Smooth transitions between views
  • [ ] Unread count badges
  • [ ] Smart timestamp formatting
  • [ ] Two-row email list items (subject + sender/timestamp)

Implementation Phases

Phase 1: Foundation

  • [ ] Project setup with Slint
  • [ ] Database path detection and existence check
  • [ ] Basic Slint UI window
  • [ ] Database connection with error handling
  • [ ] Thread communication setup (invoke_from_event_loop)

Phase 2: Basic UI Structure

  • [ ] Define Slint structs for Email, Contact, etc.
  • [ ] Create app-window.slint with 3-column layout skeleton
  • [ ] Navigation panel component
  • [ ] Email list component (basic ListView)
  • [ ] Email detail component
  • [ ] Wire up callbacks between Slint and Rust

Phase 3: Data Integration

  • [ ] Convert dwata_db models to Slint structs
  • [ ] Load emails from database on folder select
  • [ ] Display emails in ListView
  • [ ] Load and display email details
  • [ ] Implement timestamp formatting

Phase 4: Additional Views

  • [ ] Contact list and detail views
  • [ ] Files browser
  • [ ] Search functionality
  • [ ] View switching logic

Phase 5: Interactivity

  • [ ] Mark as read action
  • [ ] Star/unstar action
  • [ ] Account switching
  • [ ] Search input and results

Phase 6: Polish

  • [ ] Loading states and error handling
  • [ ] UI refinements (colors, spacing)
  • [ ] Performance optimization
  • [ ] Error messages and user feedback

Slint-Specific Considerations

Advantages of Slint

  • Built-in virtual scrolling: ListView handles performance automatically
  • Declarative UI: Clear separation between UI and logic
  • Type-safe bindings: Compile-time checks for properties/callbacks
  • Hot reload: Fast iteration during development (with slint-viewer)
  • Cross-platform: Desktop, embedded, mobile

Challenges to Address

  1. Async Operations: Slint event loop is synchronous

    • Solution: Use threads + invoke_from_event_loop()
  2. Complex Data Types: Slint structs must be simple

    • Solution: Create conversion layer between dwata_db and Slint types
  3. Multi-column Layout: No built-in 3-column widget

    • Solution: Use HorizontalLayout with fixed/flexible widths
  4. Dynamic Content: Loading indicators, view switching

    • Solution: Use conditional rendering (if expressions) in Slint

Performance Targets

  • Startup time: <2 seconds (including DB connection)
  • Email list rendering: 60fps while scrolling (ListView handles this)
  • Search response: <100ms for subject search
  • View switching: <50ms to switch between views
  • Memory usage: <200MB for app with full dataset
  • Frame rate: Maintain 60fps during normal interaction

Success Criteria

  • [ ] App checks for database and handles missing DB gracefully
  • [ ] All features from checklist implemented
  • [ ] Virtual scrolling performs smoothly with 2000+ emails (ListView)
  • [ ] UI remains responsive during all database operations
  • [ ] Search returns results quickly (<100ms)
  • [ ] App handles window resize gracefully
  • [ ] Clean separation between UI (Slint) and logic (Rust)
  • [ ] Code is well-structured for future modifications
  • [ ] Matches UI layout and functionality of egui (#174) and iced (#175) implementations

Technical Stack

  • Language: Rust (latest stable)
  • GUI Framework: Slint 1.13.1
  • Database: SQLite3 (via dwata-db shared library)
  • Threading: std::thread + slint::invoke_from_event_loop
  • Additional Crates:
    • slint (1.13.1) - GUI framework
    • slint-build (1.13.1) - Build-time compilation of .slint files
    • dirs - Platform-specific directory paths
    • chrono - Date/time handling for timestamps

Reference Examples in Local Slint Repository

Important Local Paths:

  • Slint repo: /home/nocodo/Projects/slint (v1.13.1)
  • Template: /home/nocodo/Projects/slint-rust-template

Key Examples to Study:

  1. Todo App: /home/nocodo/Projects/slint/examples/todo/

    • Shows VecModel usage
    • Callbacks and data updates
    • File: rust/lib.rs and ui/todo.slint
  2. Gallery: /home/nocodo/Projects/slint/examples/gallery/

    • Multi-page navigation
    • ListView and StandardListView
    • File: gallery.slint, main.rs
  3. Maps: /home/nocodo/Projects/slint/examples/maps/

    • Async data loading pattern
    • Background thread communication

Documentation

  • [ ] README with setup instructions
  • [ ] Instructions to run fake data generator first
  • [ ] Database location documentation
  • [ ] Slint UI component documentation
  • [ ] Code comments for complex logic (especially threading)
  • [ ] Architecture overview (Slint layer + Rust layer)
  • [ ] Screenshots of the UI
  • [ ] Performance benchmarks
  • [ ] Comparison notes vs egui and iced implementations

brainless avatar Oct 12 '25 09:10 brainless