Skip to content

Email Client Desktop App using Slint #176

@brainless

Description

@brainless

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 Fake Data Generator for Email Client Database #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 (Email Client Desktop App using egui #174) and iced (Email Client Desktop App using 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions