mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git
Initial commit: Clear Email Client using clear-ui
.gitignore | 2 +
Cargo.toml | 20 ++
Makefile | 14 +
src/main.rs | 1119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 1155 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96ef6c0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..8384bbd
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "clear-email-interface"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+clear-ui = { path = "../clear-ui" }
+smithay-client-toolkit = "0.19.2"
+calloop = "0.13.0"
+calloop-wayland-source = "0.3.0"
+wayland-client = { version = "0.31", features = ["system"] }
+xkeysym = "0.2"
+raw-window-handle = "0.6"
+wgpu = "24"
+bytemuck = { version = "1", features = ["derive"] }
+pollster = "0.4"
+glyphon = "0.8"
+tokio = { version = "1", features = ["full"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..d14d1ab
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,14 @@
+.PHONY: build install run clean
+
+build:
+ cargo build --release
+
+install: build
+ mkdir -p ~/.local/bin
+ install -m 755 target/release/clear-email-interface ~/.local/bin/clear-email-interface
+
+run:
+ cargo run
+
+clean:
+ cargo clean
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..1767df4
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,1119 @@
+use wayland_client::QueueHandle;
+use glyphon::{FontSystem, Buffer, Metrics, Attrs};
+use clear_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use clear_ui::widget::{
+ MouseButton, ElementState, MouseScrollDelta, KeyEvent, TextItem, Widget,
+ TextBox, Button, TextLabel, Key, ScrollingList
+};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+enum Folder {
+ Inbox,
+ Sent,
+ Trash,
+}
+
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+struct Email {
+ id: usize,
+ from: String,
+ to: String,
+ subject: String,
+ body: String,
+ date: String,
+ read: bool,
+ folder: String, // "inbox", "sent", "trash"
+}
+
+#[derive(Debug, Clone)]
+enum AppMessage {
+ SwitchFolder(Folder),
+ SelectEmail(usize),
+ SearchChanged,
+ ComposeNew,
+ ComposeCancel,
+ ComposeSend,
+ Reply,
+ DeleteSelected,
+ ToggleUnread,
+}
+
+struct ClearEmailApp {
+ // Navigation / Sidebar
+ btn_compose: Button,
+ btn_inbox: Button,
+ btn_sent: Button,
+ btn_trash: Button,
+
+ // Search and List View
+ search_box: TextBox,
+ email_list: ScrollingList,
+ email_buttons: Vec<Button>,
+
+ // Details View
+ btn_reply: Button,
+ btn_delete: Button,
+ btn_unread: Button,
+ detail_body: TextBox,
+
+ // Compose Dialog
+ compose_to: TextBox,
+ compose_subject: TextBox,
+ compose_body: TextBox,
+ btn_compose_send: Button,
+ btn_compose_cancel: Button,
+
+ // Application state
+ emails: Vec<Email>,
+ current_folder: Folder,
+ selected_email_id: Option<usize>,
+ compose_open: bool,
+ status_message: Option<(String, f32)>, // (message, timer)
+
+ // UI state
+ width: u32,
+ height: u32,
+ scale_factor: f64,
+ text_items: Vec<TextItem>,
+ font_system: FontSystem,
+ needs_rebuild: bool,
+}
+
+fn get_config_path() -> std::path::PathBuf {
+ let p = std::path::PathBuf::from("/home/lsgalante/.config/ccec");
+ if !p.exists() {
+ let _ = std::fs::create_dir_all(&p);
+ }
+ p.join("emails.json")
+}
+
+fn load_emails() -> Vec<Email> {
+ let path = get_config_path();
+ if path.exists() {
+ if let Ok(content) = std::fs::read_to_string(&path) {
+ if let Ok(emails) = serde_json::from_str(&content) {
+ return emails;
+ }
+ }
+ }
+ get_default_mock_emails()
+}
+
+fn save_emails(emails: &[Email]) {
+ let path = get_config_path();
+ if let Ok(content) = serde_json::to_string_pretty(emails) {
+ let _ = std::fs::write(&path, content);
+ }
+}
+
+fn get_default_mock_emails() -> Vec<Email> {
+ vec![
+ Email {
+ id: 1,
+ from: "System Daemon <[email protected]>".to_string(),
+ to: "[email protected]".to_string(),
+ subject: "Welcome to Clear Mail".to_string(),
+ body: "Welcome to the Clear Computing Environment (CCE) email client!\n\nThis application is built entirely using the clear-ui Rust framework, running on top of Wayland and wgpu. It provides a premium, responsive dark slate interface.\n\nEnjoy the clean lines and micro-animations!\n\nBest,\nSystem Daemon".to_string(),
+ date: "12:15 PM".to_string(),
+ read: false,
+ folder: "inbox".to_string(),
+ },
+ Email {
+ id: 2,
+ from: "Design Team <[email protected]>".to_string(),
+ to: "[email protected]".to_string(),
+ subject: "Slate Dark Theme Palette Guidelines".to_string(),
+ body: "Hi Lucas,\n\nWe have finalized the core Slate Dark styling color values for CCE application builders:\n- Window Background: [0.05, 0.05, 0.07, 1.0] (deep slate blue)\n- Sidebar Background: [0.08, 0.08, 0.12, 1.0]\n- Selection Highlight: [0.20, 0.45, 0.85, 0.50] (high contrast premium blue)\n- Text Bright: [0.88, 0.88, 0.92, 1.0]\n- Text Muted: [0.51, 0.51, 0.54, 1.0]\n\nPlease align all interface panels and layouts to use these values.\n\nThanks,\nDesign Team".to_string(),
+ date: "Yesterday".to_string(),
+ read: false,
+ folder: "inbox".to_string(),
+ },
+ Email {
+ id: 3,
+ from: "Codeberg CI <[email protected]>".to_string(),
+ to: "[email protected]".to_string(),
+ subject: "Build Success: clear-email-interface (main)".to_string(),
+ body: "Repository: lsgalante/clear-email-interface\nBranch: main\nCommit: da8cf20fcb2c993c1c048ced4020\nStatus: SUCCESS\n\nAll unit tests passed. Binary compiled in 48.2s.\n\n---\nCodeberg Actions".to_string(),
+ date: "June 3".to_string(),
+ read: true,
+ folder: "inbox".to_string(),
+ },
+ Email {
+ id: 4,
+ from: "[email protected]".to_string(),
+ to: "Design Team <[email protected]>".to_string(),
+ subject: "Re: Slate Dark Theme Palette Guidelines".to_string(),
+ body: "Thanks for sending the palette! I am implementing the email client right now using these specifications. The three-pane layout feels very premium.\n\n- Lucas".to_string(),
+ date: "Yesterday".to_string(),
+ read: true,
+ folder: "sent".to_string(),
+ },
+ ]
+}
+
+impl ClearEmailApp {
+ fn rebuild_text_items(&mut self) {
+ self.text_items.clear();
+ let mut labels = Vec::new();
+ let font_system = &mut self.font_system;
+
+ let w_f32 = self.width as f32;
+ let h_f32 = self.height as f32;
+
+ // 1. Sidebar Buttons text labels
+ labels.extend(self.btn_compose.text_labels());
+ labels.extend(self.btn_inbox.text_labels());
+ labels.extend(self.btn_sent.text_labels());
+ labels.extend(self.btn_trash.text_labels());
+
+ // Sidebar Folder Badges
+ let inbox_unread = self.emails.iter().filter(|e| e.folder == "inbox" && !e.read).count();
+ if inbox_unread > 0 {
+ labels.push(TextLabel {
+ text: inbox_unread.to_string(),
+ x: 145.0,
+ y: 77.0,
+ font_size: 10.0,
+ color: [0xff, 0xff, 0xff],
+ });
+ }
+
+ // 2. Search box labels
+ self.search_box.prepare_text(font_system);
+ for (label, bounds) in self.search_box.text_labels_with_bounds() {
+ let metrics = Metrics::new(label.font_size, label.font_size * 1.4);
+ let mut buf = Buffer::new(font_system, metrics);
+ buf.set_text(font_system, &label.text, Attrs::new(), glyphon::Shaping::Advanced);
+ buf.shape_until_scroll(font_system, true);
+ self.text_items.push(TextItem {
+ buffer: buf,
+ x: label.x,
+ y: label.y,
+ color: glyphon::Color::rgb(label.color[0], label.color[1], label.color[2]),
+ bounds,
+ });
+ }
+
+ // 3. Email List Labels
+ let current_folder_str = match self.current_folder {
+ Folder::Inbox => "inbox",
+ Folder::Sent => "sent",
+ Folder::Trash => "trash",
+ };
+ let search_text = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
+ let search_lower = search_text.to_lowercase();
+ let filtered: Vec<&Email> = self.emails.iter()
+ .filter(|e| e.folder == current_folder_str)
+ .filter(|e| {
+ if search_lower.is_empty() {
+ true
+ } else {
+ e.from.to_lowercase().contains(&search_lower) ||
+ e.subject.to_lowercase().contains(&search_lower) ||
+ e.body.to_lowercase().contains(&search_lower)
+ }
+ })
+ .collect();
+
+ for (idx, email) in filtered.iter().enumerate() {
+ if let Some(draw_y) = self.email_list.get_item_draw_y(idx, 0.0) {
+ // Sender name
+ labels.push(TextLabel {
+ text: if email.from.len() > 24 { format!("{}...", &email.from[..21]) } else { email.from.clone() },
+ x: 210.0,
+ y: draw_y + 6.0,
+ font_size: 11.0,
+ color: if !email.read { [0xff, 0xff, 0xff] } else { [0xb0, 0xb0, 0xb8] },
+ });
+
+ // Date
+ labels.push(TextLabel {
+ text: email.date.clone(),
+ x: 440.0,
+ y: draw_y + 7.0,
+ font_size: 9.0,
+ color: [0x70, 0x70, 0x75],
+ });
+
+ // Subject
+ labels.push(TextLabel {
+ text: if email.subject.len() > 32 { format!("{}...", &email.subject[..29]) } else { email.subject.clone() },
+ x: 210.0,
+ y: draw_y + 20.0,
+ font_size: 10.0,
+ color: if !email.read { [0x3a, 0x9a, 0xff] } else { [0x83, 0x83, 0x8a] },
+ });
+
+ // Snippet
+ let snippet_raw = email.body.replace('\n', " ");
+ let snippet = if snippet_raw.len() > 40 { format!("{}...", &snippet_raw[..37]) } else { snippet_raw };
+ labels.push(TextLabel {
+ text: snippet,
+ x: 210.0,
+ y: draw_y + 34.0,
+ font_size: 9.0,
+ color: [0x60, 0x60, 0x65],
+ });
+ }
+ }
+
+ // 4. Detail View Content
+ if let Some(selected_id) = self.selected_email_id {
+ if let Some(email) = self.emails.iter().find(|e| e.id == selected_id) {
+ labels.extend(self.btn_reply.text_labels());
+ labels.extend(self.btn_delete.text_labels());
+ labels.extend(self.btn_unread.text_labels());
+
+ // Subject Header
+ labels.push(TextLabel {
+ text: email.subject.clone(),
+ x: 515.0,
+ y: 60.0,
+ font_size: 15.0,
+ color: [0xff, 0xff, 0xff],
+ });
+
+ // Metadata
+ labels.push(TextLabel { text: format!("From: {}", email.from), x: 515.0, y: 85.0, font_size: 11.0, color: [0xb0, 0xb0, 0xb8] });
+ labels.push(TextLabel { text: format!("To: {}", email.to), x: 515.0, y: 105.0, font_size: 11.0, color: [0x83, 0x83, 0x8a] });
+ labels.push(TextLabel { text: format!("Date: {}", email.date), x: 515.0, y: 125.0, font_size: 11.0, color: [0x83, 0x83, 0x8a] });
+
+ // Body rendering
+ self.detail_body.prepare_text(font_system);
+ for (label, bounds) in self.detail_body.text_labels_with_bounds() {
+ let metrics = Metrics::new(label.font_size, label.font_size * 1.4);
+ let mut buf = Buffer::new(font_system, metrics);
+ buf.set_text(font_system, &label.text, Attrs::new(), glyphon::Shaping::Advanced);
+ buf.shape_until_scroll(font_system, true);
+ self.text_items.push(TextItem {
+ buffer: buf,
+ x: label.x,
+ y: label.y,
+ color: glyphon::Color::rgb(label.color[0], label.color[1], label.color[2]),
+ bounds,
+ });
+ }
+ }
+ } else {
+ labels.push(TextLabel {
+ text: "Select an email to view its content".to_string(),
+ x: 620.0,
+ y: h_f32 / 2.0 - 10.0,
+ font_size: 13.0,
+ color: [0x60, 0x60, 0x65],
+ });
+ }
+
+ // 5. Status Banner Message
+ if let Some((ref msg, _)) = self.status_message {
+ labels.push(TextLabel {
+ text: msg.clone(),
+ x: 200.0,
+ y: h_f32 - 25.0,
+ font_size: 11.0,
+ color: [0x3a, 0xff, 0x80],
+ });
+ }
+
+ // 6. Compose Dialog Content
+ if self.compose_open {
+ let modal_x = ((w_f32 - 500.0) / 2.0).max(0.0);
+ let modal_y = ((h_f32 - 420.0) / 2.0).max(0.0);
+
+ labels.push(TextLabel {
+ text: "New Message".to_string(),
+ x: modal_x + 15.0,
+ y: modal_y + 16.0,
+ font_size: 13.0,
+ color: [0xff, 0xff, 0xff],
+ });
+
+ labels.push(TextLabel { text: "To:".to_string(), x: modal_x + 15.0, y: modal_y + 54.0, font_size: 11.0, color: [0x83, 0x83, 0x8a] });
+ labels.push(TextLabel { text: "Subject:".to_string(), x: modal_x + 15.0, y: modal_y + 94.0, font_size: 11.0, color: [0x83, 0x83, 0x8a] });
+
+ labels.extend(self.btn_compose_send.text_labels());
+ labels.extend(self.btn_compose_cancel.text_labels());
+
+ // Compose inputs text labels
+ self.compose_to.prepare_text(font_system);
+ for (label, bounds) in self.compose_to.text_labels_with_bounds() {
+ let metrics = Metrics::new(label.font_size, label.font_size * 1.4);
+ let mut buf = Buffer::new(font_system, metrics);
+ buf.set_text(font_system, &label.text, Attrs::new(), glyphon::Shaping::Advanced);
+ buf.shape_until_scroll(font_system, true);
+ self.text_items.push(TextItem {
+ buffer: buf,
+ x: label.x,
+ y: label.y,
+ color: glyphon::Color::rgb(label.color[0], label.color[1], label.color[2]),
+ bounds,
+ });
+ }
+
+ self.compose_subject.prepare_text(font_system);
+ for (label, bounds) in self.compose_subject.text_labels_with_bounds() {
+ let metrics = Metrics::new(label.font_size, label.font_size * 1.4);
+ let mut buf = Buffer::new(font_system, metrics);
+ buf.set_text(font_system, &label.text, Attrs::new(), glyphon::Shaping::Advanced);
+ buf.shape_until_scroll(font_system, true);
+ self.text_items.push(TextItem {
+ buffer: buf,
+ x: label.x,
+ y: label.y,
+ color: glyphon::Color::rgb(label.color[0], label.color[1], label.color[2]),
+ bounds,
+ });
+ }
+
+ self.compose_body.prepare_text(font_system);
+ for (label, bounds) in self.compose_body.text_labels_with_bounds() {
+ let metrics = Metrics::new(label.font_size, label.font_size * 1.4);
+ let mut buf = Buffer::new(font_system, metrics);
+ buf.set_text(font_system, &label.text, Attrs::new(), glyphon::Shaping::Advanced);
+ buf.shape_until_scroll(font_system, true);
+ self.text_items.push(TextItem {
+ buffer: buf,
+ x: label.x,
+ y: label.y,
+ color: glyphon::Color::rgb(label.color[0], label.color[1], label.color[2]),
+ bounds,
+ });
+ }
+ }
+
+ // Shape and append static text items
+ for label in labels {
+ let metrics = Metrics::new(label.font_size, label.font_size * 1.4);
+ let mut buf = Buffer::new(font_system, metrics);
+ buf.set_text(font_system, &label.text, Attrs::new(), glyphon::Shaping::Advanced);
+ buf.shape_until_scroll(font_system, true);
+ self.text_items.push(TextItem {
+ buffer: buf,
+ x: label.x,
+ y: label.y,
+ color: glyphon::Color::rgb(label.color[0], label.color[1], label.color[2]),
+ bounds: None,
+ });
+ }
+ }
+}
+
+impl Application for ClearEmailApp {
+ type Message = AppMessage;
+
+ fn new(_qh: &QueueHandle<EngineState<Self>>, _sender: calloop::channel::Sender<Self::Message>) -> Self {
+ let btn_compose = Button::new(15.0, 20.0, 150.0, 32.0).with_label("+ Compose");
+ let btn_inbox = Button::new(15.0, 70.0, 150.0, 28.0).with_label("📥 Inbox");
+ let btn_sent = Button::new(15.0, 105.0, 150.0, 28.0).with_label("📤 Sent");
+ let btn_trash = Button::new(15.0, 140.0, 150.0, 28.0).with_label("🗑️ Trash");
+
+ let mut search_box = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true);
+ search_box.font_size = 11.0;
+
+ let email_list = ScrollingList::new(54.0, 4.0);
+
+ let btn_reply = Button::new(515.0, 8.0, 70.0, 26.0).with_label("Reply");
+ let btn_delete = Button::new_reset(595.0, 8.0, 80.0, 26.0).with_label("Delete");
+ let btn_unread = Button::new(685.0, 8.0, 110.0, 26.0).with_label("Mark Unread");
+
+ let mut detail_body = TextBox::new(String::new()).with_multiline(true).with_draw_bg_border(false);
+ detail_body.font_size = 12.0;
+ detail_body.font_family = "sans-serif".to_string();
+
+ let mut compose_to = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true);
+ compose_to.font_size = 12.0;
+ let mut compose_subject = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true);
+ compose_subject.font_size = 12.0;
+ let mut compose_body = TextBox::new(String::new()).with_multiline(true).with_draw_bg_border(true);
+ compose_body.font_size = 12.0;
+ compose_body.font_family = "sans-serif".to_string();
+
+ let btn_compose_send = Button::new(0.0, 0.0, 75.0, 28.0).with_label("Send");
+ let btn_compose_cancel = Button::new_reset(0.0, 0.0, 75.0, 28.0).with_label("Cancel");
+
+ let emails = load_emails();
+
+ Self {
+ btn_compose,
+ btn_inbox,
+ btn_sent,
+ btn_trash,
+ search_box,
+ email_list,
+ email_buttons: Vec::new(),
+ btn_reply,
+ btn_delete,
+ btn_unread,
+ detail_body,
+ compose_to,
+ compose_subject,
+ compose_body,
+ btn_compose_send,
+ btn_compose_cancel,
+ emails,
+ current_folder: Folder::Inbox,
+ selected_email_id: None,
+ compose_open: false,
+ status_message: None,
+ width: 1000,
+ height: 600,
+ scale_factor: 1.0,
+ text_items: Vec::new(),
+ font_system: FontSystem::new(),
+ needs_rebuild: true,
+ }
+ }
+
+ fn settings(&self) -> WindowSettings {
+ WindowSettings {
+ title: "Clear Email Client".to_string(),
+ app_id: "clear-email-interface".to_string(),
+ width: 1000,
+ height: 600,
+ fullscreen: false,
+ min_size: Some((800, 500)),
+ }
+ }
+
+ fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
+ match msg {
+ AppMessage::SwitchFolder(f) => {
+ self.current_folder = f;
+ self.selected_email_id = None;
+ self.email_list.set_scroll_y(0.0);
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ AppMessage::SelectEmail(id) => {
+ self.selected_email_id = Some(id);
+ if let Some(email) = self.emails.iter_mut().find(|e| e.id == id) {
+ if !email.read {
+ email.read = true;
+ save_emails(&self.emails);
+ }
+ }
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ AppMessage::SearchChanged => {
+ self.selected_email_id = None;
+ self.email_list.set_scroll_y(0.0);
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ AppMessage::ComposeNew => {
+ self.compose_to.text = String::new();
+ self.compose_to.edit_buffer = String::new();
+ self.compose_subject.text = String::new();
+ self.compose_subject.edit_buffer = String::new();
+ self.compose_body.text = String::new();
+ self.compose_body.edit_buffer = String::new();
+ self.compose_open = true;
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ AppMessage::ComposeCancel => {
+ self.compose_open = false;
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ AppMessage::ComposeSend => {
+ let to = if self.compose_to.editing { &self.compose_to.edit_buffer } else { &self.compose_to.text };
+ let subject = if self.compose_subject.editing { &self.compose_subject.edit_buffer } else { &self.compose_subject.text };
+ let body = if self.compose_body.editing { &self.compose_body.edit_buffer } else { &self.compose_body.text };
+
+ if !to.trim().is_empty() {
+ let new_id = self.emails.iter().map(|e| e.id).max().unwrap_or(0) + 1;
+ let new_email = Email {
+ id: new_id,
+ from: "[email protected]".to_string(),
+ to: to.clone(),
+ subject: if subject.trim().is_empty() { "(No Subject)".to_string() } else { subject.clone() },
+ body: body.clone(),
+ date: "Just now".to_string(),
+ read: true,
+ folder: "sent".to_string(),
+ };
+ self.emails.push(new_email);
+ save_emails(&self.emails);
+ self.compose_open = false;
+ self.status_message = Some(("Email Sent Successfully".to_string(), 4.0));
+ } else {
+ self.status_message = Some(("Recipient is required".to_string(), 4.0));
+ }
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ AppMessage::Reply => {
+ if let Some(id) = self.selected_email_id {
+ if let Some(email) = self.emails.iter().find(|e| e.id == id) {
+ self.compose_to.text = email.from.clone();
+ self.compose_to.edit_buffer = email.from.clone();
+ self.compose_subject.text = if email.subject.starts_with("Re:") {
+ email.subject.clone()
+ } else {
+ format!("Re: {}", email.subject)
+ };
+ self.compose_subject.edit_buffer = self.compose_subject.text.clone();
+
+ let reply_intro = format!("\n\nOn {}, {} wrote:\n> {}", email.date, email.from, email.body.replace('\n', "\n> "));
+ self.compose_body.text = reply_intro.clone();
+ self.compose_body.edit_buffer = reply_intro;
+ self.compose_body.cursor_idx = 0;
+ self.compose_open = true;
+ }
+ }
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ AppMessage::DeleteSelected => {
+ if let Some(id) = self.selected_email_id {
+ let mut permanently_deleted = false;
+ if let Some(email) = self.emails.iter_mut().find(|e| e.id == id) {
+ if email.folder == "trash" {
+ permanently_deleted = true;
+ } else {
+ email.folder = "trash".to_string();
+ }
+ }
+ if permanently_deleted {
+ self.emails.retain(|e| e.id != id);
+ }
+ save_emails(&self.emails);
+ self.selected_email_id = None;
+ self.status_message = Some((
+ if permanently_deleted { "Email Deleted Permanently" } else { "Moved to Trash" }.to_string(),
+ 4.0
+ ));
+ }
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ AppMessage::ToggleUnread => {
+ if let Some(id) = self.selected_email_id {
+ if let Some(email) = self.emails.iter_mut().find(|e| e.id == id) {
+ email.read = !email.read;
+ }
+ save_emails(&self.emails);
+ }
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ }
+ }
+
+ fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
+ if let Some((_, ref mut timer)) = self.status_message {
+ *timer -= dt;
+ if *timer <= 0.0 {
+ self.status_message = None;
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ }
+ }
+
+ fn view(&mut self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, size: LogicalSize, scale: f64) {
+ let size_changed = self.width != size.width as u32 || self.height != size.height as u32 || self.scale_factor != scale;
+ if self.needs_rebuild || size_changed {
+ self.width = size.width as u32;
+ self.height = size.height as u32;
+ self.scale_factor = scale;
+ }
+
+ let w_f32 = self.width as f32;
+ let h_f32 = self.height as f32;
+
+ let current_folder_str = match self.current_folder {
+ Folder::Inbox => "inbox",
+ Folder::Sent => "sent",
+ Folder::Trash => "trash",
+ };
+
+ if self.needs_rebuild || size_changed {
+ // Sidebar buttons layout
+ self.btn_compose.set_rect(15.0, 20.0, 150.0, 32.0);
+ self.btn_inbox.set_rect(15.0, 70.0, 150.0, 28.0);
+ self.btn_sent.set_rect(15.0, 105.0, 150.0, 28.0);
+ self.btn_trash.set_rect(15.0, 140.0, 150.0, 28.0);
+
+ // Selection states
+ self.btn_inbox.selected = self.current_folder == Folder::Inbox;
+ self.btn_sent.selected = self.current_folder == Folder::Sent;
+ self.btn_trash.selected = self.current_folder == Folder::Trash;
+
+ // Search box and Scrolling list
+ self.search_box.set_rect(190.0, 15.0, 300.0, 26.0);
+
+ // Get filtered emails count for bounds setup
+ let filtered_count = {
+ let search_text = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
+ let search_lower = search_text.to_lowercase();
+ self.emails.iter()
+ .filter(|e| e.folder == current_folder_str)
+ .filter(|e| {
+ if search_lower.is_empty() {
+ true
+ } else {
+ e.from.to_lowercase().contains(&search_lower) ||
+ e.subject.to_lowercase().contains(&search_lower) ||
+ e.body.to_lowercase().contains(&search_lower)
+ }
+ })
+ .count()
+ };
+
+ self.email_list.set_rect(190.0, 55.0, 300.0, h_f32 - 70.0);
+ self.email_list.update_bounds(filtered_count, 55.0, h_f32 - 70.0);
+
+ if self.email_buttons.len() != filtered_count {
+ self.email_buttons = (0..filtered_count)
+ .map(|_| Button::new_list_row(0.0, 0.0, 0.0, 0.0))
+ .collect();
+ }
+
+ // Get selected email state to avoid borrowing self while mutating
+ let selected_email_state = if let Some(selected_id) = self.selected_email_id {
+ self.emails.iter().find(|e| e.id == selected_id).map(|e| (e.read, e.body.clone()))
+ } else {
+ None
+ };
+
+ // Get filtered email IDs and selection states
+ let filtered_email_ids: Vec<(usize, bool)> = {
+ let search_text = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
+ let search_lower = search_text.to_lowercase();
+ self.emails.iter()
+ .filter(|e| e.folder == current_folder_str)
+ .filter(|e| {
+ if search_lower.is_empty() {
+ true
+ } else {
+ e.from.to_lowercase().contains(&search_lower) ||
+ e.subject.to_lowercase().contains(&search_lower) ||
+ e.body.to_lowercase().contains(&search_lower)
+ }
+ })
+ .map(|e| (e.id, Some(e.id) == self.selected_email_id))
+ .collect()
+ };
+
+ for (idx, &(_email_id, is_selected)) in filtered_email_ids.iter().enumerate() {
+ self.email_buttons[idx].selected = is_selected;
+ if let Some(draw_y) = self.email_list.get_item_draw_y(idx, 0.0) {
+ self.email_buttons[idx].set_rect(190.0, draw_y, 300.0, 54.0);
+ } else {
+ self.email_buttons[idx].set_rect(-9999.0, -9999.0, 0.0, 0.0);
+ }
+ }
+
+ // Detail View
+ if let Some((read, body)) = selected_email_state {
+ self.btn_reply.set_rect(515.0, 8.0, 70.0, 26.0);
+ self.btn_delete.set_rect(595.0, 8.0, 80.0, 26.0);
+ self.btn_unread.set_rect(685.0, 8.0, 110.0, 26.0);
+ if let Some(base) = self.btn_unread.base_mut() {
+ base.label = Some((if read { "Mark Unread" } else { "Mark Read" }).to_string());
+ }
+
+ let detail_w = (w_f32 - 530.0).max(100.0);
+ self.detail_body.set_rect(515.0, 170.0, detail_w, (h_f32 - 190.0).max(100.0));
+ self.detail_body.text = body;
+ }
+
+ // Compose inputs layout
+ if self.compose_open {
+ let modal_x = ((w_f32 - 500.0) / 2.0).max(0.0);
+ let modal_y = ((h_f32 - 420.0) / 2.0).max(0.0);
+
+ self.compose_to.set_rect(modal_x + 80.0, modal_y + 50.0, 400.0, 26.0);
+ self.compose_subject.set_rect(modal_x + 80.0, modal_y + 90.0, 400.0, 26.0);
+ self.compose_body.set_rect(modal_x + 15.0, modal_y + 130.0, 470.0, 230.0);
+
+ self.btn_compose_send.set_rect(modal_x + 320.0, modal_y + 375.0, 75.0, 28.0);
+ self.btn_compose_cancel.set_rect(modal_x + 410.0, modal_y + 375.0, 75.0, 28.0);
+ }
+
+ self.rebuild_text_items();
+ self.needs_rebuild = false;
+ }
+
+ // Now compute `filtered` only for rendering (immutable borrow of self)
+ let search_text = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
+ let search_lower = search_text.to_lowercase();
+ let filtered: Vec<&Email> = self.emails.iter()
+ .filter(|e| e.folder == current_folder_str)
+ .filter(|e| {
+ if search_lower.is_empty() {
+ true
+ } else {
+ e.from.to_lowercase().contains(&search_lower) ||
+ e.subject.to_lowercase().contains(&search_lower) ||
+ e.body.to_lowercase().contains(&search_lower)
+ }
+ })
+ .collect();
+
+ // 1. General window background (deep slate blue)
+ quads.push((0.0, 0.0, w_f32, h_f32, [0.05, 0.05, 0.07, 1.0]));
+
+ // 2. Sidebar background panel
+ quads.push((0.0, 0.0, 180.0, h_f32, [0.08, 0.08, 0.12, 1.0]));
+ quads.push((180.0, 0.0, 1.0, h_f32, [0.18, 0.18, 0.22, 1.0])); // sidebar separator
+
+ // Compose Button and Folders Graphics
+ quads.extend(self.btn_compose.extra_quads());
+ quads.extend(self.btn_inbox.extra_quads());
+ quads.extend(self.btn_sent.extra_quads());
+ quads.extend(self.btn_trash.extra_quads());
+
+ // Draw badge pill for inbox unread
+ let inbox_unread = self.emails.iter().filter(|e| e.folder == "inbox" && !e.read).count();
+ if inbox_unread > 0 {
+ quads.push((135.0, 76.0, 22.0, 16.0, [0.20, 0.45, 0.85, 0.8]));
+ }
+
+ // 3. Email List Panel Separator
+ quads.push((500.0, 0.0, 1.0, h_f32, [0.18, 0.18, 0.22, 1.0]));
+
+ // Search box and List
+ quads.extend(self.search_box.extra_quads());
+ quads.extend(self.email_list.extra_quads());
+
+ // Visible Email List Item Buttons
+ for (idx, _) in filtered.iter().enumerate() {
+ if self.email_list.get_item_draw_y(idx, 0.0).is_some() {
+ quads.extend(self.email_buttons[idx].extra_quads());
+
+ // Blue dot/unread indicator for this row
+ if !filtered[idx].read {
+ if let Some(draw_y) = self.email_list.get_item_draw_y(idx, 0.0) {
+ quads.push((198.0, draw_y + 12.0, 6.0, 6.0, [0.20, 0.45, 0.85, 1.0]));
+ }
+ }
+ }
+ }
+
+ // 4. Detail View Area
+ if let Some(selected_id) = self.selected_email_id {
+ if self.emails.iter().any(|e| e.id == selected_id) {
+ // Top action toolbar background
+ quads.push((501.0, 0.0, w_f32 - 501.0, 42.0, [0.08, 0.08, 0.12, 1.0]));
+ quads.push((501.0, 42.0, w_f32 - 501.0, 1.0, [0.18, 0.18, 0.22, 1.0]));
+
+ quads.extend(self.btn_reply.extra_quads());
+ quads.extend(self.btn_delete.extra_quads());
+ quads.extend(self.btn_unread.extra_quads());
+
+ // Detail body textbox graphics
+ quads.extend(self.detail_body.extra_quads());
+ }
+ }
+
+ // 5. Compose Dialog Overlay
+ if self.compose_open {
+ let modal_x = ((w_f32 - 500.0) / 2.0).max(0.0);
+ let modal_y = ((h_f32 - 420.0) / 2.0).max(0.0);
+
+ // Semitransparent modal backdrop
+ quads.push((0.0, 0.0, w_f32, h_f32, [0.0, 0.0, 0.0, 0.6]));
+
+ // Modal dialog container
+ quads.push((modal_x, modal_y, 500.0, 420.0, [0.08, 0.08, 0.12, 1.0]));
+ quads.push((modal_x, modal_y, 500.0, 1.0, [0.25, 0.35, 0.50, 0.40]));
+ quads.push((modal_x, modal_y + 419.0, 500.0, 1.0, [0.25, 0.35, 0.50, 0.40]));
+ quads.push((modal_x, modal_y, 1.0, 420.0, [0.25, 0.35, 0.50, 0.40]));
+ quads.push((modal_x + 499.0, modal_y, 1.0, 420.0, [0.25, 0.35, 0.50, 0.40]));
+
+ quads.extend(self.compose_to.extra_quads());
+ quads.extend(self.compose_subject.extra_quads());
+ quads.extend(self.compose_body.extra_quads());
+ quads.extend(self.btn_compose_send.extra_quads());
+ quads.extend(self.btn_compose_cancel.extra_quads());
+ }
+ }
+
+ fn text_items(&self) -> &[TextItem] {
+ &self.text_items
+ }
+
+ fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
+ let mut changed = false;
+ let px = pos.x as f32;
+ let py = pos.y as f32;
+
+ if self.compose_open {
+ if self.compose_to.on_cursor_moved(px, py) { changed = true; }
+ if self.compose_subject.on_cursor_moved(px, py) { changed = true; }
+ if self.compose_body.on_cursor_moved(px, py) { changed = true; }
+ if self.btn_compose_send.on_cursor_moved(px, py) { changed = true; }
+ if self.btn_compose_cancel.on_cursor_moved(px, py) { changed = true; }
+ } else {
+ // Sidebar buttons
+ if self.btn_compose.on_cursor_moved(px, py) { changed = true; }
+ if self.btn_inbox.on_cursor_moved(px, py) { changed = true; }
+ if self.btn_sent.on_cursor_moved(px, py) { changed = true; }
+ if self.btn_trash.on_cursor_moved(px, py) { changed = true; }
+
+ // Search and lists
+ if self.search_box.on_cursor_moved(px, py) { changed = true; }
+ if self.email_list.on_cursor_moved(px, py) { changed = true; }
+
+ for btn in &mut self.email_buttons {
+ if btn.rect().0 > -9000.0 {
+ if btn.on_cursor_moved(px, py) { changed = true; }
+ }
+ }
+
+ // Detail view buttons
+ if self.selected_email_id.is_some() {
+ if self.btn_reply.on_cursor_moved(px, py) { changed = true; }
+ if self.btn_delete.on_cursor_moved(px, py) { changed = true; }
+ if self.btn_unread.on_cursor_moved(px, py) { changed = true; }
+ if self.detail_body.on_cursor_moved(px, py) { changed = true; }
+ }
+ }
+
+ if changed {
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ }
+
+ fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message> {
+ let mut changed = false;
+ let mut msg_out = None;
+ let px = pos.x as f32;
+ let py = pos.y as f32;
+
+ if self.compose_open {
+ if self.compose_to.mouse_input(button, state, px, py) { changed = true; }
+ if self.compose_subject.mouse_input(button, state, px, py) { changed = true; }
+ if self.compose_body.mouse_input(button, state, px, py) { changed = true; }
+
+ if self.btn_compose_send.mouse_input(button, state, px, py) {
+ changed = true;
+ if state == ElementState::Released && self.btn_compose_send.take_click() {
+ msg_out = Some(AppMessage::ComposeSend);
+ }
+ }
+ if self.btn_compose_cancel.mouse_input(button, state, px, py) {
+ changed = true;
+ if state == ElementState::Released && self.btn_compose_cancel.take_click() {
+ msg_out = Some(AppMessage::ComposeCancel);
+ }
+ }
+
+ // Click outside the modal clears focus or behaves neutrally
+ if !changed && state == ElementState::Pressed && button == MouseButton::Left {
+ let w_f32 = self.width as f32;
+ let h_f32 = self.height as f32;
+ let modal_x = ((w_f32 - 500.0) / 2.0).max(0.0);
+ let modal_y = ((h_f32 - 420.0) / 2.0).max(0.0);
+
+ if px < modal_x || px > modal_x + 500.0 || py < modal_y || py > modal_y + 420.0 {
+ self.compose_to.unfocus();
+ self.compose_subject.unfocus();
+ self.compose_body.unfocus();
+ changed = true;
+ }
+ }
+ } else {
+ // Sidebar buttons
+ if self.btn_compose.mouse_input(button, state, px, py) {
+ changed = true;
+ if state == ElementState::Released && self.btn_compose.take_click() {
+ msg_out = Some(AppMessage::ComposeNew);
+ }
+ }
+ if self.btn_inbox.mouse_input(button, state, px, py) {
+ changed = true;
+ if state == ElementState::Released && self.btn_inbox.take_click() {
+ msg_out = Some(AppMessage::SwitchFolder(Folder::Inbox));
+ }
+ }
+ if self.btn_sent.mouse_input(button, state, px, py) {
+ changed = true;
+ if state == ElementState::Released && self.btn_sent.take_click() {
+ msg_out = Some(AppMessage::SwitchFolder(Folder::Sent));
+ }
+ }
+ if self.btn_trash.mouse_input(button, state, px, py) {
+ changed = true;
+ if state == ElementState::Released && self.btn_trash.take_click() {
+ msg_out = Some(AppMessage::SwitchFolder(Folder::Trash));
+ }
+ }
+
+ // Search input
+ if self.search_box.mouse_input(button, state, px, py) {
+ changed = true;
+ if self.search_box.take_change() {
+ msg_out = Some(AppMessage::SearchChanged);
+ }
+ } else if state == ElementState::Pressed && button == MouseButton::Left {
+ self.search_box.unfocus();
+ changed = true;
+ }
+
+ // Scrolling list items
+ let current_folder_str = match self.current_folder {
+ Folder::Inbox => "inbox",
+ Folder::Sent => "sent",
+ Folder::Trash => "trash",
+ };
+ let search_text = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
+ let search_lower = search_text.to_lowercase();
+ let filtered: Vec<&Email> = self.emails.iter()
+ .filter(|e| e.folder == current_folder_str)
+ .filter(|e| {
+ if search_lower.is_empty() {
+ true
+ } else {
+ e.from.to_lowercase().contains(&search_lower) ||
+ e.subject.to_lowercase().contains(&search_lower) ||
+ e.body.to_lowercase().contains(&search_lower)
+ }
+ })
+ .collect();
+
+ if self.email_list.mouse_input(button, state, px, py) {
+ changed = true;
+ }
+
+ for (idx, email) in filtered.iter().enumerate() {
+ if idx < self.email_buttons.len() {
+ let btn = &mut self.email_buttons[idx];
+ if btn.rect().0 > -9000.0 {
+ if btn.mouse_input(button, state, px, py) {
+ changed = true;
+ if state == ElementState::Released && btn.take_click() {
+ msg_out = Some(AppMessage::SelectEmail(email.id));
+ }
+ }
+ }
+ }
+ }
+
+ // Detail View action buttons
+ if self.selected_email_id.is_some() {
+ if self.btn_reply.mouse_input(button, state, px, py) {
+ changed = true;
+ if state == ElementState::Released && self.btn_reply.take_click() {
+ msg_out = Some(AppMessage::Reply);
+ }
+ }
+ if self.btn_delete.mouse_input(button, state, px, py) {
+ changed = true;
+ if state == ElementState::Released && self.btn_delete.take_click() {
+ msg_out = Some(AppMessage::DeleteSelected);
+ }
+ }
+ if self.btn_unread.mouse_input(button, state, px, py) {
+ changed = true;
+ if state == ElementState::Released && self.btn_unread.take_click() {
+ msg_out = Some(AppMessage::ToggleUnread);
+ }
+ }
+ if self.detail_body.mouse_input(button, state, px, py) {
+ changed = true;
+ }
+ }
+ }
+
+ if changed {
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+
+ msg_out
+ }
+
+ fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
+ let mut changed = false;
+ let px = pos.x as f32;
+ let py = pos.y as f32;
+
+ if !self.compose_open {
+ if px >= 190.0 && px <= 490.0 {
+ if self.email_list.mouse_wheel(delta, px, py) {
+ changed = true;
+ }
+ }
+ }
+
+ if changed {
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ }
+
+ fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
+ let mut handled = false;
+ let mut msg_out = None;
+
+ if self.compose_open {
+ if self.compose_to.editing {
+ if self.compose_to.keyboard_input(event) { handled = true; }
+ } else if self.compose_subject.editing {
+ if self.compose_subject.keyboard_input(event) { handled = true; }
+ } else if self.compose_body.editing {
+ if self.compose_body.keyboard_input(event) { handled = true; }
+ }
+
+ // Escape closes compose dialog
+ if !handled && event.state == ElementState::Pressed && event.logical_key == Key::Named(clear_ui::widget::NamedKey::Escape) {
+ msg_out = Some(AppMessage::ComposeCancel);
+ handled = true;
+ }
+ } else {
+ // General keyboard shortcuts
+ if event.ctrl && event.state == ElementState::Pressed {
+ if let Key::Character(ref ch) = event.logical_key {
+ match ch.to_lowercase().as_str() {
+ "n" => {
+ msg_out = Some(AppMessage::ComposeNew);
+ handled = true;
+ }
+ "f" => {
+ self.search_box.focus();
+ handled = true;
+ }
+ _ => {}
+ }
+ }
+ }
+
+ if !handled && self.search_box.editing {
+ if self.search_box.keyboard_input(event) {
+ handled = true;
+ if self.search_box.take_change() {
+ msg_out = Some(AppMessage::SearchChanged);
+ }
+ }
+ }
+
+ // Escape unfocuses search
+ if !handled && event.state == ElementState::Pressed && event.logical_key == Key::Named(clear_ui::widget::NamedKey::Escape) {
+ if self.search_box.editing {
+ self.search_box.unfocus();
+ handled = true;
+ }
+ }
+ }
+
+ if handled {
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+
+ msg_out
+ }
+}
+
+fn main() {
+ let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
+ let _guard = rt.enter();
+
+ clear_ui::engine::run::<ClearEmailApp>();
+}