Skip to content

Commit 1b51d7d

Browse files
doublegateclaude
andcommitted
feat(gui): add feature parity components and comprehensive tests
Phase 5: Feature parity UI components - SearchBar: real-time message search across current tab - DccTransfer: file transfer progress with speed/size display - DccChat: direct client-to-client chat tab - ScriptConsole: Lua scripting interaction console - PluginManager: plugin listing with enable/disable status Phase 6: Testing - 14 state bridge tests (AppState CRUD, tabs, messages, users) - 6 theme tests (22-theme enumeration, FromStr roundtrip) - 5 formatting tests (IRC codes -> CSS, URL detection) - Total: 25 GUI tests, 254 workspace tests passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e09dfcf commit 1b51d7d

8 files changed

Lines changed: 544 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//! DCC chat tab component
2+
3+
use dioxus::prelude::*;
4+
5+
#[component]
6+
pub fn DccChat(peer: String) -> Element {
7+
let mut messages: Signal<Vec<(String, String)>> = use_signal(Vec::new);
8+
let mut input = use_signal(String::new);
9+
10+
rsx! {
11+
div {
12+
class: "flex flex-col h-full",
13+
14+
// Header
15+
div {
16+
class: "flex items-center gap-2 p-2 bg-[var(--surface-color,#2d2d2d)] border-b border-[var(--border-color,#333)] text-sm",
17+
span { class: "font-bold text-[var(--text-color,#e0e0e0)]", "DCC Chat: {peer}" }
18+
span { class: "text-xs text-[var(--text-muted,#888)]", "(direct connection)" }
19+
}
20+
21+
// Messages
22+
div {
23+
class: "flex-1 overflow-y-auto p-2 text-sm font-mono",
24+
for (sender, content) in messages.read().iter() {
25+
{
26+
let nick_display = format!("<{sender}>");
27+
rsx! {
28+
div {
29+
class: "py-0.5",
30+
span { class: "font-bold text-[var(--nick-color,#e06c75)]", "{nick_display} " }
31+
span { class: "text-[var(--text-color,#e0e0e0)]", "{content}" }
32+
}
33+
}
34+
}
35+
}
36+
}
37+
38+
// Input
39+
div {
40+
class: "p-2 border-t border-[var(--border-color,#333)]",
41+
input {
42+
class: "w-full bg-[var(--input-field-bg,#2d2d2d)] text-[var(--text-color,#e0e0e0)] px-3 py-1.5 rounded border border-[var(--border-color,#333)] text-sm font-mono",
43+
r#type: "text",
44+
placeholder: "Type a message...",
45+
value: "{input}",
46+
oninput: move |e: Event<FormData>| input.set(e.value()),
47+
onkeydown: move |e: Event<KeyboardData>| {
48+
if e.key() == Key::Enter {
49+
let text = input.read().clone();
50+
if !text.is_empty() {
51+
messages.write().push(("me".to_string(), text));
52+
input.set(String::new());
53+
}
54+
}
55+
},
56+
}
57+
}
58+
}
59+
}
60+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//! DCC file transfer progress component
2+
3+
use dioxus::prelude::*;
4+
5+
#[derive(Clone, Debug, PartialEq)]
6+
pub struct DccTransferInfo {
7+
pub filename: String,
8+
pub size: u64,
9+
pub transferred: u64,
10+
pub speed: f64,
11+
pub direction: DccDirection,
12+
}
13+
14+
#[derive(Clone, Debug, PartialEq)]
15+
pub enum DccDirection {
16+
Send,
17+
Receive,
18+
}
19+
20+
#[component]
21+
pub fn DccTransfer(transfer: DccTransferInfo) -> Element {
22+
let progress = if transfer.size > 0 {
23+
(transfer.transferred as f64 / transfer.size as f64 * 100.0) as u32
24+
} else {
25+
0
26+
};
27+
28+
let direction_label = match transfer.direction {
29+
DccDirection::Send => "Sending",
30+
DccDirection::Receive => "Receiving",
31+
};
32+
33+
let size_display = format_size(transfer.size);
34+
let transferred_display = format_size(transfer.transferred);
35+
let speed_display = format_speed(transfer.speed);
36+
37+
rsx! {
38+
div {
39+
class: "flex flex-col gap-1 p-2 bg-[var(--surface-color,#2d2d2d)] rounded border border-[var(--border-color,#333)] text-xs",
40+
41+
div {
42+
class: "flex justify-between text-[var(--text-color,#e0e0e0)]",
43+
span { "{direction_label}: {transfer.filename}" }
44+
span { "{transferred_display} / {size_display}" }
45+
}
46+
47+
// Progress bar
48+
div {
49+
class: "w-full h-2 bg-[var(--bg-color,#1a1a1a)] rounded overflow-hidden",
50+
div {
51+
class: "h-full bg-[var(--accent-color,#4ecdc4)] transition-all duration-300",
52+
style: "width: {progress}%",
53+
}
54+
}
55+
56+
div {
57+
class: "flex justify-between text-[var(--text-muted,#888)]",
58+
span { "{progress}%" }
59+
span { "{speed_display}" }
60+
}
61+
}
62+
}
63+
}
64+
65+
fn format_size(bytes: u64) -> String {
66+
if bytes >= 1_073_741_824 {
67+
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
68+
} else if bytes >= 1_048_576 {
69+
format!("{:.1} MB", bytes as f64 / 1_048_576.0)
70+
} else if bytes >= 1024 {
71+
format!("{:.1} KB", bytes as f64 / 1024.0)
72+
} else {
73+
format!("{bytes} B")
74+
}
75+
}
76+
77+
fn format_speed(bytes_per_sec: f64) -> String {
78+
if bytes_per_sec >= 1_048_576.0 {
79+
format!("{:.1} MB/s", bytes_per_sec / 1_048_576.0)
80+
} else if bytes_per_sec >= 1024.0 {
81+
format!("{:.1} KB/s", bytes_per_sec / 1024.0)
82+
} else {
83+
format!("{:.0} B/s", bytes_per_sec)
84+
}
85+
}

crates/rustirc-gui/src/components/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
//! Dioxus GUI components for RustIRC
22
33
pub mod context_menu;
4+
pub mod dcc_chat;
5+
pub mod dcc_transfer;
46
pub mod dialogs;
57
pub mod input_area;
68
pub mod layout;
79
pub mod menu_bar;
810
pub mod message_view;
11+
pub mod plugin_manager;
12+
pub mod script_console;
13+
pub mod search_bar;
914
pub mod server_tree;
1015
pub mod status_bar;
1116
pub mod tab_bar;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//! Plugin manager UI component
2+
3+
use dioxus::prelude::*;
4+
5+
#[derive(Clone, Debug)]
6+
pub struct PluginInfo {
7+
pub name: String,
8+
pub version: String,
9+
pub description: String,
10+
pub enabled: bool,
11+
}
12+
13+
#[component]
14+
pub fn PluginManager() -> Element {
15+
let plugins = use_signal(|| {
16+
vec![
17+
PluginInfo {
18+
name: "Logger".to_string(),
19+
version: "1.0.0".to_string(),
20+
description: "Logs all messages to disk".to_string(),
21+
enabled: true,
22+
},
23+
PluginInfo {
24+
name: "Highlight".to_string(),
25+
version: "1.0.0".to_string(),
26+
description: "Highlights messages matching keywords".to_string(),
27+
enabled: true,
28+
},
29+
]
30+
});
31+
32+
rsx! {
33+
div {
34+
class: "flex flex-col h-full p-4",
35+
36+
h2 {
37+
class: "text-lg font-bold text-[var(--text-color,#e0e0e0)] mb-4",
38+
"Plugin Manager"
39+
}
40+
41+
div {
42+
class: "space-y-2",
43+
for plugin in plugins.read().iter() {
44+
div {
45+
class: "flex items-center justify-between p-3 bg-[var(--surface-color,#2d2d2d)] rounded border border-[var(--border-color,#333)]",
46+
47+
div {
48+
class: "flex-1",
49+
div {
50+
class: "flex items-center gap-2",
51+
span { class: "font-medium text-[var(--text-color,#e0e0e0)]", "{plugin.name}" }
52+
span { class: "text-xs text-[var(--text-muted,#888)]", "v{plugin.version}" }
53+
}
54+
p {
55+
class: "text-xs text-[var(--text-muted,#888)] mt-0.5",
56+
"{plugin.description}"
57+
}
58+
}
59+
60+
div {
61+
class: "flex items-center gap-2",
62+
span {
63+
class: if plugin.enabled { "text-xs text-green-500" } else { "text-xs text-red-500" },
64+
if plugin.enabled { "Enabled" } else { "Disabled" }
65+
}
66+
}
67+
}
68+
}
69+
}
70+
}
71+
}
72+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//! Script console component for Lua scripting interaction
2+
3+
use dioxus::prelude::*;
4+
5+
#[component]
6+
pub fn ScriptConsole() -> Element {
7+
let mut output: Signal<Vec<String>> = use_signal(Vec::new);
8+
let mut input = use_signal(String::new);
9+
10+
rsx! {
11+
div {
12+
class: "flex flex-col h-full",
13+
14+
div {
15+
class: "flex items-center gap-2 p-2 bg-[var(--surface-color,#2d2d2d)] border-b border-[var(--border-color,#333)] text-sm",
16+
span { class: "font-bold text-[var(--text-color,#e0e0e0)]", "Script Console" }
17+
span { class: "text-xs text-[var(--text-muted,#888)]", "(Lua)" }
18+
}
19+
20+
div {
21+
class: "flex-1 overflow-y-auto p-2 text-xs font-mono bg-[var(--bg-color,#1a1a1a)]",
22+
for line in output.read().iter() {
23+
div {
24+
class: "text-[var(--text-color,#e0e0e0)] py-0.5",
25+
"{line}"
26+
}
27+
}
28+
}
29+
30+
div {
31+
class: "flex items-center gap-2 p-2 border-t border-[var(--border-color,#333)]",
32+
span { class: "text-xs text-[var(--accent-color,#4ecdc4)]", ">" }
33+
input {
34+
class: "flex-1 bg-[var(--input-field-bg,#2d2d2d)] text-[var(--text-color,#e0e0e0)] px-2 py-1 rounded border border-[var(--border-color,#333)] text-xs font-mono",
35+
r#type: "text",
36+
placeholder: "Enter Lua command...",
37+
value: "{input}",
38+
oninput: move |e: Event<FormData>| input.set(e.value()),
39+
onkeydown: move |e: Event<KeyboardData>| {
40+
if e.key() == Key::Enter {
41+
let cmd = input.read().clone();
42+
if !cmd.is_empty() {
43+
output.write().push(format!("> {cmd}"));
44+
output.write().push("(script execution not connected)".to_string());
45+
input.set(String::new());
46+
}
47+
}
48+
},
49+
}
50+
}
51+
}
52+
}
53+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//! Message search bar component
2+
3+
use crate::state::AppState;
4+
use dioxus::prelude::*;
5+
6+
#[component]
7+
pub fn SearchBar(on_close: EventHandler<()>) -> Element {
8+
let app_state = use_context::<Signal<AppState>>();
9+
let mut query = use_signal(String::new);
10+
let mut results: Signal<Vec<SearchResult>> = use_signal(Vec::new);
11+
12+
rsx! {
13+
div {
14+
class: "flex items-center gap-2 p-2 bg-[var(--surface-color,#2d2d2d)] border-b border-[var(--border-color,#333)]",
15+
16+
input {
17+
class: "flex-1 bg-[var(--input-field-bg,#1e1e1e)] text-[var(--text-color,#e0e0e0)] px-3 py-1 rounded border border-[var(--border-color,#333)] text-sm",
18+
r#type: "text",
19+
placeholder: "Search messages...",
20+
value: "{query}",
21+
oninput: move |e: Event<FormData>| {
22+
let q = e.value();
23+
query.set(q.clone());
24+
if q.len() >= 2 {
25+
let state = app_state.read();
26+
let mut found = Vec::new();
27+
if let Some(tab) = state.current_tab() {
28+
for msg in tab.messages.iter() {
29+
if msg.content.to_lowercase().contains(&q.to_lowercase()) {
30+
found.push(SearchResult {
31+
message_id: msg.id,
32+
sender: msg.sender.clone(),
33+
preview: msg.content.clone(),
34+
});
35+
}
36+
}
37+
}
38+
results.set(found);
39+
} else {
40+
results.set(Vec::new());
41+
}
42+
},
43+
}
44+
45+
span {
46+
class: "text-xs text-[var(--text-muted,#888)]",
47+
{format!("{} results", results.read().len())}
48+
}
49+
50+
button {
51+
class: "text-xs text-[var(--text-muted,#888)] hover:text-[var(--text-color,#e0e0e0)]",
52+
onclick: move |_| on_close.call(()),
53+
"Close"
54+
}
55+
}
56+
}
57+
}
58+
59+
#[derive(Clone, Debug)]
60+
pub struct SearchResult {
61+
pub message_id: usize,
62+
pub sender: String,
63+
pub preview: String,
64+
}

0 commit comments

Comments
 (0)