Email Client Desktop App using Slint
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/
- Todo App:
Slint Architecture Principles
Declarative UI Definition:
- UI is defined in
.slintfiles 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:
- ListView Widget: Virtual scrolling list (only visible items instantiated)
- ModelRc/VecModel: For managing dynamic lists of data
- Callbacks: UI events trigger Rust functions
- Properties: Two-way data binding between UI and Rust
- 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:
-
Database Models - All Rust structs:
use dwata_db::{Account, Email, Contact, Folder, Label, Attachment}; -
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
-
Async Operations: Slint event loop is synchronous
- Solution: Use threads +
invoke_from_event_loop()
- Solution: Use threads +
-
Complex Data Types: Slint structs must be simple
- Solution: Create conversion layer between dwata_db and Slint types
-
Multi-column Layout: No built-in 3-column widget
- Solution: Use HorizontalLayout with fixed/flexible widths
-
Dynamic Content: Loading indicators, view switching
- Solution: Use conditional rendering (
ifexpressions) in Slint
- Solution: Use conditional rendering (
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 frameworkslint-build(1.13.1) - Build-time compilation of .slint filesdirs- Platform-specific directory pathschrono- 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:
-
Todo App:
/home/nocodo/Projects/slint/examples/todo/- Shows VecModel usage
- Callbacks and data updates
- File:
rust/lib.rsandui/todo.slint
-
Gallery:
/home/nocodo/Projects/slint/examples/gallery/- Multi-page navigation
- ListView and StandardListView
- File:
gallery.slint,main.rs
-
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