8.1 KiB
Slint UI Migration Plan — VNOX Client
Current Architecture (egui)
main.rs
└─ eframe::run_native() → VnoxApp::update() called every frame
├─ titlebar::show()
├─ sidebar::guild_bar()
├─ sidebar::show()
├─ members::show()
├─ CentralPanel:
│ ├─ Disconnected → connect::show()
│ └─ Connected → chat::show()
│ ├─ dm::dm_chat()
│ ├─ friends::show()
│ └─ text chat + voice panel
└─ settings::show_window()
State: UiState — single large struct (131+ lines) with conn, channels, messages, DMs, guilds, friends, audio settings, voice state. Mutated directly in place every frame.
UI: 80+ Rust files in ui/, all immediate-mode egui. Every widget redrawn every frame.
Target Architecture (Slint)
main.rs
└─ slint::MainWindow::new() → .run()
│
├─ .slint files (declarative UI)
│ ├─ main.slint — window layout
│ ├─ sidebar.slint — guild bar + channel list
│ ├─ chat.slint — message list + input bar
│ ├─ voice.slint — voice channel panel
│ ├─ connect.slint — connect/bookmarks screen
│ ├─ settings.slint — settings dialog
│ └─ theme.slint — color/font/spacing definitions
│
└─ Rust code (logic only)
├─ app.rs — VnoxApp, init, event wiring
├─ bridge.rs — UiState ↔ Slint property sync
├─ state/ — UiState (unchanged, slimmed)
└─ net/ audio/ — no changes
Key difference: Slint only re-renders changed properties. UI is declared once in .slint files. Rust code pushes state updates via in-out property setters.
Migration Phases
Phase 1 — Infrastructure (day 1)
-
Add
slintcrate toCargo.toml:slint = { version = "1", features = ["backend-winit", "compat-1-2"] } -
Create
ui/main.slint— empty window with a placeholder text -
Rewrite
main.rs:- Remove
eframe::run_native() - Create
slint::MainWindow - Spawn tokio runtime alongside slint event loop
- Remove
-
Keep all egui code in repo (deleted only at the end)
Result: App boots in Slint window, shows placeholder. All real UI still in egui waiting to be ported.
Phase 2 — Sidebar (day 2)
Sidebar is the most isolated component — no complex interaction, just a list of channels + guild bar + user bar.
// theme.slint
export global Theme {
in-out property <color> bg-surface: #1e1e2e;
in-out property <color> bg-base: #181825;
in-out property <color> accent: #cba6f7;
in-out property <color> text-primary: #cdd6f4;
in-out property <length> radius: 8px;
}
// sidebar.slint
component Sidebar { /* guild icons, channel list, user bar */ }
Rust bridge:
fn sync_sidebar(window: &MainWindow, state: &UiState) {
let channels: Vec<Channel> = state.channels.iter().map(|c| Channel { .. }).collect();
window.set_channels(ModelRc::from(VecModel::from(channels)));
window.on_channel_selected(|id| { /* net.send(JoinChannel { id }) */ });
}
Files to create: ui/theme.slint, ui/sidebar.slint
Files to remove (end of phase): ui/sidebar/mod.rs, ui/sidebar/channel_list/, ui/sidebar/sections/, ui/sidebar/guild_bar/
Phase 3 — Chat (days 3-4)
The complex one: text messages, reactions, editing, typing indicators, reply chain, DMs, friends list.
component ChatPanel {
in-out property <[Message]> messages;
in-out property <string> input-text;
signal send-message(string);
signal load-history(string);
VerticalLayout {
ListView { /* scrollable messages */ }
HorizontalLayout {
LineEdit { text <=> input-text; }
Button { text: "Send"; }
}
}
}
Sub-steps:
- Text chat panel (messages + input)
- Message context menu (react/edit/delete/reply/copy)
- DM panel
- Friends list + requests
- Voice channel panel (speaking indicators, members)
- Member list (right sidebar)
Files to create: ui/chat.slint, ui/voice.slint
Files to remove (end of phase): ui/chat/, ui/members/
Phase 4 — Connect Screen (day 4)
component ConnectScreen {
in-out property <[Bookmark]> bookmarks;
in-out property <string> address-input;
signal connect(string);
signal remove-bookmark(int);
}
Files to create: ui/connect.slint
Files to remove (end of phase): ui/connect/
Phase 5 — Settings + Polish (day 5)
Settings dialog with tabs: Audio, Network, Identity, Appearance.
Files to create: ui/settings.slint
Files to remove (end of phase): ui/settings/, ui/theme/, ui/state/ (partially)
Final cleanup: remove eframe from Cargo.toml, delete all ui/*mod.rs that re-export egui components.
Data Flow
┌─────────────────────────────────────────────────────────┐
│ slint::MainWindow │
│ ┌───────────────────┐ ┌────────────────────────────┐ │
│ │ .slint UI │ │ Rust Backend │ │
│ │ │ │ │ │
│ │ property <value> │←──→ window.set_value() │ │
│ │ signal event() │───→ window.on_event(cb) │ │
│ └───────────────────┘ └────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ UiState │ │
│ │ (Rust struct)│ │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ NetHandle │ │
│ │ (tokio task) │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
The bridge pattern:
NetHandlereceives events from gateway → updatesUiState- Every 16ms (60fps) or on event,
sync_ui()readsUiStateand callswindow.set_*()methods - User clicks in Slint →
signalfires → Rust callback →net.send(NetCommand::...)
Dependency changes
| Dependency | Action |
|---|---|
eframe |
Remove (end of Phase 5) |
egui |
Remove (end of Phase 5) |
slint |
Add (Phase 1) |
tokio |
Keep |
cpal |
Keep |
rodio |
Keep |
opus |
Keep |
What stays the same
net/— all networking code,NetHandle,NetCommand,NetEventaudio/— capture/playback pipeline, Opus encode/decode, RNNoiseidentity.rs,identity_vault.rs— keypair managementjitter/— jitter buffer (copied from voice-node)
Timeline estimate
| Phase | Scope | Time |
|---|---|---|
| 1 | Infrastructure (slint init, window, wiring) | 1 day |
| 2 | Sidebar (guild bar + channel list + user bar) | 1 day |
| 3 | Chat + DMs + Friends + Voice panel | 2 days |
| 4 | Connect screen + bookmarks | 0.5 day |
| 5 | Settings + theme + cleanup | 0.5 day |
| Total | ~5 days |