diff --git a/core/Cargo.lock b/core/Cargo.lock index 645ea55..d002331 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -231,6 +237,21 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.0.95" @@ -289,7 +310,7 @@ dependencies = [ "bitflags 1.3.2", "strsim 0.8.0", "textwrap", - "unicode-width", + "unicode-width 0.1.14", "vec_map", ] @@ -339,6 +360,20 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -374,6 +409,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.9.3", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -725,6 +785,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -898,6 +964,11 @@ name = "hashbrown" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -1053,6 +1124,28 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "is-terminal" version = "0.4.16" @@ -1070,6 +1163,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.11" @@ -1240,6 +1342,23 @@ dependencies = [ "xpc-connection-sys", ] +[[package]] +name = "kptui" +version = "0.1.0" +dependencies = [ + "anyhow", + "block", + "crossterm", + "dbus", + "dbus-codegen", + "log", + "ratatui", + "time", + "unicode-width 0.2.0", + "xpc-connection", + "xpc-connection-sys", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1319,6 +1438,15 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "memchr" version = "2.7.2" @@ -1379,6 +1507,7 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ "hermit-abi 0.3.9", "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -1578,6 +1707,12 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -1638,7 +1773,7 @@ dependencies = [ "arrayvec", "termcolor", "typed-arena", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -1652,7 +1787,7 @@ dependencies = [ "is-terminal", "lazy_static", "term", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -1738,6 +1873,27 @@ dependencies = [ "getrandom 0.3.2", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.9.3", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -2001,6 +2157,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -2041,6 +2218,12 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.8.0" @@ -2053,6 +2236,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2108,7 +2313,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" dependencies = [ - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -2379,12 +2584,35 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/core/Cargo.toml b/core/Cargo.toml index f21f005..ba7fb01 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -4,6 +4,7 @@ members = [ "kordophone-db", "kordophoned", "kpcli", + "kptui", "utilities", ] resolver = "2" diff --git a/core/README.md b/core/README.md index 9c6198c..d58b431 100644 --- a/core/README.md +++ b/core/README.md @@ -10,6 +10,7 @@ Workspace members: - Linux: D‑Bus - macOS: XPC (see notes below) - `kpcli/` — Command‑line interface for interacting with the API, DB, and daemon. +- `kptui/` — Terminal UI client (Ratatui) for reading and replying to chats via the daemon. - `utilities/` — Small helper tools (e.g., testing utilities). ## Build @@ -65,4 +66,3 @@ cargo run -p kpcli -- --help - TLS/WebSocket: the `kordophone` crate includes `rustls` and installs a crypto provider at process start. - DB: `kordophone-db` includes Diesel migrations under `kordophone-db/migrations/`. - diff --git a/core/kptui/Cargo.toml b/core/kptui/Cargo.toml new file mode 100644 index 0000000..e15b685 --- /dev/null +++ b/core/kptui/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "kptui" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.93" +crossterm = "0.28.1" +log = "0.4.22" +ratatui = "0.29.0" +time = { version = "0.3.37", features = ["formatting"] } +unicode-width = "0.2.0" + +# D-Bus dependencies only on Linux +[target.'cfg(target_os = "linux")'.dependencies] +dbus = "0.9.7" + +# D-Bus codegen only on Linux +[target.'cfg(target_os = "linux")'.build-dependencies] +dbus-codegen = "0.10.0" + +# XPC (libxpc) interface only on macOS +[target.'cfg(target_os = "macos")'.dependencies] +block = "0.1.6" +xpc-connection = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection" } +xpc-connection-sys = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection-sys" } diff --git a/core/kptui/build.rs b/core/kptui/build.rs new file mode 100644 index 0000000..126eab7 --- /dev/null +++ b/core/kptui/build.rs @@ -0,0 +1,26 @@ +const KORDOPHONE_XML: &str = "../kordophoned/include/net.buzzert.kordophonecd.Server.xml"; + +#[cfg(not(target_os = "linux"))] +fn main() { + // No D-Bus codegen on non-Linux platforms. +} + +#[cfg(target_os = "linux")] +fn main() { + let out_dir = std::env::var("OUT_DIR").unwrap(); + let out_path = std::path::Path::new(&out_dir).join("kordophone-client.rs"); + + let opts = dbus_codegen::GenOpts { + connectiontype: dbus_codegen::ConnectionType::Blocking, + methodtype: None, + ..Default::default() + }; + + let xml = std::fs::read_to_string(KORDOPHONE_XML).expect("Error reading server dbus interface"); + let output = + dbus_codegen::generate(&xml, &opts).expect("Error generating client dbus interface"); + std::fs::write(out_path, output).expect("Error writing client dbus code"); + + println!("cargo:rerun-if-changed={}", KORDOPHONE_XML); +} + diff --git a/core/kptui/src/daemon/mod.rs b/core/kptui/src/daemon/mod.rs new file mode 100644 index 0000000..ea0e9cd --- /dev/null +++ b/core/kptui/src/daemon/mod.rs @@ -0,0 +1,581 @@ +use anyhow::Result; +use std::time::Duration; + +#[derive(Clone, Debug)] +pub struct ConversationSummary { + pub id: String, + pub title: String, + pub preview: String, + pub unread_count: u32, + pub date_unix: i64, +} + +#[derive(Clone, Debug)] +pub struct ChatMessage { + pub sender: String, + pub text: String, + pub date_unix: i64, +} + +pub enum Request { + RefreshConversations, + RefreshMessages { conversation_id: String }, + SendMessage { conversation_id: String, text: String }, + MarkRead { conversation_id: String }, + SyncConversation { conversation_id: String }, +} + +pub enum Event { + Conversations(Vec), + Messages { + conversation_id: String, + messages: Vec, + }, + MessageSent { + conversation_id: String, + outgoing_id: Option, + }, + MarkedRead, + ConversationSyncTriggered { conversation_id: String }, + ConversationsUpdated, + MessagesUpdated { conversation_id: String }, + UpdateStreamReconnected, + Error(String), +} + +pub fn spawn_worker( + request_rx: std::sync::mpsc::Receiver, + event_tx: std::sync::mpsc::Sender, +) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let mut client = match new_daemon_client() { + Ok(c) => c, + Err(e) => { + let _ = event_tx.send(Event::Error(format!("Failed to connect to daemon: {e}"))); + return; + } + }; + + if let Err(e) = client.install_signal_handlers(event_tx.clone()) { + let _ = event_tx.send(Event::Error(format!("Failed to install daemon signals: {e}"))); + } + + loop { + match request_rx.recv_timeout(Duration::from_millis(100)) { + Ok(req) => { + let res = match req { + Request::RefreshConversations => client + .get_conversations(200, 0) + .map(Event::Conversations), + Request::RefreshMessages { conversation_id } => client + .get_messages(conversation_id.clone(), None) + .map(|messages| Event::Messages { + conversation_id, + messages, + }), + Request::SendMessage { + conversation_id, + text, + } => client + .send_message(conversation_id.clone(), text) + .map(|outgoing_id| Event::MessageSent { + conversation_id, + outgoing_id, + }), + Request::MarkRead { conversation_id } => client + .mark_conversation_as_read(conversation_id.clone()) + .map(|_| Event::MarkedRead), + Request::SyncConversation { conversation_id } => client + .sync_conversation(conversation_id.clone()) + .map(|_| Event::ConversationSyncTriggered { conversation_id }), + }; + + match res { + Ok(evt) => { + let _ = event_tx.send(evt); + } + Err(e) => { + let _ = event_tx.send(Event::Error(format!("{e}"))); + } + } + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + } + + if let Err(e) = client.poll(Duration::from_millis(0)) { + let _ = event_tx.send(Event::Error(format!("Daemon polling error: {e}"))); + } + } + }) +} + +trait DaemonClient { + fn get_conversations(&mut self, limit: i32, offset: i32) -> Result>; + fn get_messages( + &mut self, + conversation_id: String, + last_message_id: Option, + ) -> Result>; + fn send_message( + &mut self, + conversation_id: String, + text: String, + ) -> Result>; + fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>; + fn sync_conversation(&mut self, conversation_id: String) -> Result<()>; + fn install_signal_handlers(&mut self, _event_tx: std::sync::mpsc::Sender) -> Result<()> { + Ok(()) + } + fn poll(&mut self, timeout: Duration) -> Result<()> { + std::thread::sleep(timeout); + Ok(()) + } +} + +fn new_daemon_client() -> Result> { + #[cfg(target_os = "linux")] + { + Ok(Box::new(linux::DBusClient::new()?)) + } + #[cfg(target_os = "macos")] + { + Ok(Box::new(macos::XpcClient::new()?)) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + anyhow::bail!("Unsupported platform") + } +} + +#[cfg(target_os = "linux")] +mod linux { + use super::{ChatMessage, ConversationSummary, DaemonClient, Event}; + use anyhow::Result; + use dbus::arg::{PropMap, RefArg}; + use dbus::blocking::{Connection, Proxy}; + use dbus::channel::Token; + use std::sync::mpsc::Sender; + use std::time::Duration; + + const DBUS_NAME: &str = "net.buzzert.kordophonecd"; + const DBUS_PATH: &str = "/net/buzzert/kordophonecd/daemon"; + + #[allow(unused)] + mod dbus_interface { + #![allow(unused)] + include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs")); + } + use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository; + + pub struct DBusClient { + conn: Connection, + signal_tokens: Vec, + } + + impl DBusClient { + pub fn new() -> Result { + Ok(Self { + conn: Connection::new_session()?, + signal_tokens: Vec::new(), + }) + } + + fn proxy(&self) -> Proxy<&Connection> { + self.conn + .with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000)) + } + } + + fn get_string(map: &PropMap, key: &str) -> String { + map.get(key) + .and_then(|v| v.0.as_str()) + .unwrap_or_default() + .to_string() + } + + fn get_i64(map: &PropMap, key: &str) -> i64 { + map.get(key).and_then(|v| v.0.as_i64()).unwrap_or(0) + } + + fn get_u32(map: &PropMap, key: &str) -> u32 { + get_i64(map, key).try_into().unwrap_or(0) + } + + fn get_vec_string(map: &PropMap, key: &str) -> Vec { + map.get(key) + .and_then(|v| v.0.as_iter()) + .map(|iter| { + iter.filter_map(|item| item.as_str().map(|s| s.to_string())) + .collect::>() + }) + .unwrap_or_default() + } + + impl DaemonClient for DBusClient { + fn get_conversations(&mut self, limit: i32, offset: i32) -> Result> { + let mut items = KordophoneRepository::get_conversations(&self.proxy(), limit, offset)?; + let mut conversations = items + .drain(..) + .map(|conv| { + let id = get_string(&conv, "guid"); + let display_name = get_string(&conv, "display_name"); + let participants = get_vec_string(&conv, "participants"); + let title = if !display_name.trim().is_empty() { + display_name + } else if participants.is_empty() { + "".to_string() + } else { + participants.join(", ") + }; + + ConversationSummary { + id, + title, + preview: get_string(&conv, "last_message_preview").replace('\n', " "), + unread_count: get_u32(&conv, "unread_count"), + date_unix: get_i64(&conv, "date"), + } + }) + .collect::>(); + + conversations.sort_by_key(|c| std::cmp::Reverse(c.date_unix)); + Ok(conversations) + } + + fn get_messages( + &mut self, + conversation_id: String, + last_message_id: Option, + ) -> Result> { + let messages = KordophoneRepository::get_messages( + &self.proxy(), + &conversation_id, + &last_message_id.unwrap_or_default(), + )?; + + Ok(messages + .into_iter() + .map(|msg| ChatMessage { + sender: get_string(&msg, "sender"), + text: get_string(&msg, "text"), + date_unix: get_i64(&msg, "date"), + }) + .collect()) + } + + fn send_message( + &mut self, + conversation_id: String, + text: String, + ) -> Result> { + let attachment_guids: Vec<&str> = vec![]; + let outgoing_id = KordophoneRepository::send_message( + &self.proxy(), + &conversation_id, + &text, + attachment_guids, + )?; + Ok(Some(outgoing_id)) + } + + fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> { + KordophoneRepository::mark_conversation_as_read(&self.proxy(), &conversation_id) + .map_err(|e| anyhow::anyhow!("Failed to mark conversation as read: {e}")) + } + + fn sync_conversation(&mut self, conversation_id: String) -> Result<()> { + KordophoneRepository::sync_conversation(&self.proxy(), &conversation_id) + .map_err(|e| anyhow::anyhow!("Failed to sync conversation: {e}")) + } + + fn install_signal_handlers(&mut self, event_tx: Sender) -> Result<()> { + let conversations_tx = event_tx.clone(); + let t1 = self + .proxy() + .match_signal( + move |_: dbus_interface::NetBuzzertKordophoneRepositoryConversationsUpdated, + _: &Connection, + _: &dbus::message::Message| { + let _ = conversations_tx.send(Event::ConversationsUpdated); + true + }, + ) + .map_err(|e| anyhow::anyhow!("Failed to match ConversationsUpdated: {e}"))?; + + let messages_tx = event_tx.clone(); + let t2 = self + .proxy() + .match_signal( + move |s: dbus_interface::NetBuzzertKordophoneRepositoryMessagesUpdated, + _: &Connection, + _: &dbus::message::Message| { + let _ = messages_tx.send(Event::MessagesUpdated { + conversation_id: s.conversation_id, + }); + true + }, + ) + .map_err(|e| anyhow::anyhow!("Failed to match MessagesUpdated: {e}"))?; + + let reconnected_tx = event_tx; + let t3 = self + .proxy() + .match_signal( + move |_: dbus_interface::NetBuzzertKordophoneRepositoryUpdateStreamReconnected, + _: &Connection, + _: &dbus::message::Message| { + let _ = reconnected_tx.send(Event::UpdateStreamReconnected); + true + }, + ) + .map_err(|e| anyhow::anyhow!("Failed to match UpdateStreamReconnected: {e}"))?; + + self.signal_tokens.extend([t1, t2, t3]); + Ok(()) + } + + fn poll(&mut self, timeout: Duration) -> Result<()> { + self.conn.process(timeout)?; + Ok(()) + } + } +} + +#[cfg(target_os = "macos")] +mod macos { + use super::{ChatMessage, ConversationSummary, DaemonClient}; + use anyhow::Result; + use std::collections::HashMap; + use std::ffi::{CStr, CString}; + + use xpc_connection::Message; + + const SERVICE_NAME: &str = "net.buzzert.kordophonecd\0"; + + struct XpcTransport { + connection: xpc_connection_sys::xpc_connection_t, + } + + impl XpcTransport { + fn connect(name: impl AsRef) -> Self { + use xpc_connection_sys::xpc_connection_create_mach_service; + use xpc_connection_sys::xpc_connection_resume; + + let name = name.as_ref(); + let connection = + unsafe { xpc_connection_create_mach_service(name.as_ptr(), std::ptr::null_mut(), 0) }; + + unsafe { xpc_connection_resume(connection) }; + + Self { connection } + } + + fn send_with_reply(&self, message: Message) -> Message { + use xpc_connection::message_to_xpc_object; + use xpc_connection::xpc_object_to_message; + use xpc_connection_sys::{xpc_connection_send_message_with_reply_sync, xpc_release}; + + unsafe { + let xobj = message_to_xpc_object(message); + let reply = xpc_connection_send_message_with_reply_sync(self.connection, xobj); + xpc_release(xobj); + let msg = xpc_object_to_message(reply); + if !reply.is_null() { + xpc_release(reply); + } + msg + } + } + } + + impl Drop for XpcTransport { + fn drop(&mut self) { + use xpc_connection_sys::xpc_object_t; + use xpc_connection_sys::xpc_release; + unsafe { xpc_release(self.connection as xpc_object_t) }; + } + } + + pub struct XpcClient { + transport: XpcTransport, + } + + impl XpcClient { + pub fn new() -> Result { + let mach_port_name = CString::new(SERVICE_NAME).unwrap(); + Ok(Self { + transport: XpcTransport::connect(&mach_port_name), + }) + } + + fn key(s: &str) -> CString { + CString::new(s).unwrap() + } + + fn request(name: &str, arguments: Option>) -> Message { + let mut root: HashMap = HashMap::new(); + root.insert(Self::key("name"), Message::String(Self::key(name))); + if let Some(args) = arguments { + root.insert(Self::key("arguments"), Message::Dictionary(args)); + } + Message::Dictionary(root) + } + + fn get_string(map: &HashMap, key: &str) -> Option { + match map.get(&Self::key(key)) { + Some(Message::String(s)) => Some(s.to_string_lossy().into_owned()), + _ => None, + } + } + + fn get_i64_from_str(map: &HashMap, key: &str) -> i64 { + Self::get_string(map, key) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) + } + } + + impl DaemonClient for XpcClient { + fn get_conversations(&mut self, limit: i32, offset: i32) -> Result> { + let mut args = HashMap::new(); + args.insert(Self::key("limit"), Message::String(Self::key(&limit.to_string()))); + args.insert(Self::key("offset"), Message::String(Self::key(&offset.to_string()))); + + let reply = self + .transport + .send_with_reply(Self::request("GetConversations", Some(args))); + + let Message::Dictionary(map) = reply else { + anyhow::bail!("Unexpected conversations response"); + }; + + let Some(Message::Array(items)) = map.get(&Self::key("conversations")) else { + anyhow::bail!("Missing conversations in response"); + }; + + let mut conversations = Vec::new(); + for item in items { + let Message::Dictionary(conv) = item else { continue }; + let id = Self::get_string(conv, "guid").unwrap_or_default(); + let display_name = Self::get_string(conv, "display_name").unwrap_or_default(); + let preview = Self::get_string(conv, "last_message_preview").unwrap_or_default(); + let unread_count = Self::get_i64_from_str(conv, "unread_count") as u32; + let date_unix = Self::get_i64_from_str(conv, "date"); + + let participants = match conv.get(&Self::key("participants")) { + Some(Message::Array(arr)) => arr + .iter() + .filter_map(|m| match m { + Message::String(s) => Some(s.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>(), + _ => Vec::new(), + }; + + let title = if !display_name.trim().is_empty() { + display_name + } else if participants.is_empty() { + "".to_string() + } else { + participants.join(", ") + }; + + conversations.push(ConversationSummary { + id, + title, + preview: preview.replace('\n', " "), + unread_count, + date_unix, + }); + } + + conversations.sort_by_key(|c| std::cmp::Reverse(c.date_unix)); + Ok(conversations) + } + + fn get_messages( + &mut self, + conversation_id: String, + last_message_id: Option, + ) -> Result> { + let mut args = HashMap::new(); + args.insert( + Self::key("conversation_id"), + Message::String(Self::key(&conversation_id)), + ); + if let Some(last) = last_message_id { + args.insert(Self::key("last_message_id"), Message::String(Self::key(&last))); + } + + let reply = self + .transport + .send_with_reply(Self::request("GetMessages", Some(args))); + let Message::Dictionary(map) = reply else { + anyhow::bail!("Unexpected messages response"); + }; + + let Some(Message::Array(items)) = map.get(&Self::key("messages")) else { + anyhow::bail!("Missing messages in response"); + }; + + let mut messages = Vec::new(); + for item in items { + let Message::Dictionary(msg) = item else { continue }; + messages.push(ChatMessage { + sender: Self::get_string(msg, "sender").unwrap_or_default(), + text: Self::get_string(msg, "text").unwrap_or_default(), + date_unix: Self::get_i64_from_str(msg, "date"), + }); + } + Ok(messages) + } + + fn send_message( + &mut self, + conversation_id: String, + text: String, + ) -> Result> { + let mut args = HashMap::new(); + args.insert( + Self::key("conversation_id"), + Message::String(Self::key(&conversation_id)), + ); + args.insert(Self::key("text"), Message::String(Self::key(&text))); + + let reply = self + .transport + .send_with_reply(Self::request("SendMessage", Some(args))); + let Message::Dictionary(map) = reply else { + anyhow::bail!("Unexpected send response"); + }; + + Ok(Self::get_string(&map, "uuid")) + } + + fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> { + let mut args = HashMap::new(); + args.insert( + Self::key("conversation_id"), + Message::String(Self::key(&conversation_id)), + ); + let _ = self + .transport + .send_with_reply(Self::request("MarkConversationAsRead", Some(args))); + Ok(()) + } + + fn sync_conversation(&mut self, conversation_id: String) -> Result<()> { + let mut args = HashMap::new(); + args.insert( + Self::key("conversation_id"), + Message::String(Self::key(&conversation_id)), + ); + let _ = self + .transport + .send_with_reply(Self::request("SyncConversation", Some(args))); + Ok(()) + } + } +} diff --git a/core/kptui/src/main.rs b/core/kptui/src/main.rs new file mode 100644 index 0000000..1bd8ac4 --- /dev/null +++ b/core/kptui/src/main.rs @@ -0,0 +1,702 @@ +mod daemon; + +use anyhow::Result; +use crossterm::event::{Event as CEvent, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use ratatui::prelude::*; +use ratatui::widgets::*; +use std::sync::mpsc; +use std::time::{Duration, Instant}; +use unicode_width::UnicodeWidthStr; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ViewMode { + List, + Chat, + Split, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Focus { + Navigation, + Input, +} + +struct AppState { + conversations: Vec, + selected_idx: usize, + messages: Vec, + active_conversation_id: Option, + active_conversation_title: String, + status: String, + input: String, + focus: Focus, + transcript_scroll: u16, + pinned_to_bottom: bool, + refresh_conversations_in_flight: bool, + refresh_messages_in_flight: bool, +} + +impl AppState { + fn new() -> Self { + Self { + conversations: Vec::new(), + selected_idx: 0, + messages: Vec::new(), + active_conversation_id: None, + active_conversation_title: String::new(), + status: String::new(), + input: String::new(), + focus: Focus::Navigation, + transcript_scroll: 0, + pinned_to_bottom: true, + refresh_conversations_in_flight: false, + refresh_messages_in_flight: false, + } + } + + fn select_next(&mut self) { + if self.conversations.is_empty() { + self.selected_idx = 0; + return; + } + self.selected_idx = (self.selected_idx + 1).min(self.conversations.len() - 1); + } + + fn select_prev(&mut self) { + if self.conversations.is_empty() { + self.selected_idx = 0; + return; + } + self.selected_idx = self.selected_idx.saturating_sub(1); + } + + fn open_selected_conversation(&mut self) { + if let Some(conv) = self.conversations.get(self.selected_idx) { + self.active_conversation_id = Some(conv.id.clone()); + self.active_conversation_title = conv.title.clone(); + self.messages.clear(); + self.transcript_scroll = 0; + self.pinned_to_bottom = true; + self.focus = Focus::Input; + self.status = "Loading…".to_string(); + } + } +} + +fn view_mode(width: u16, has_active_conversation: bool, requested: ViewMode) -> ViewMode { + let min_conversations = 24u16; + let min_chat = 44u16; + let min_total = min_conversations + 1 + min_chat; + if width >= min_total { + return ViewMode::Split; + } + if has_active_conversation { + requested + } else { + ViewMode::List + } +} + +fn ui(frame: &mut Frame, app: &AppState, requested_view: ViewMode) { + let area = frame.area(); + let mode = view_mode(area.width, app.active_conversation_id.is_some(), requested_view); + + let show_input = matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some(); + let chunks = if show_input { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(3), Constraint::Length(1)]) + .split(area) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(area) + }; + + let (main_area, input_area, status_area) = if show_input { + (chunks[0], Some(chunks[1]), chunks[2]) + } else { + (chunks[0], None, chunks[1]) + }; + + match mode { + ViewMode::Split => { + let left_width = (main_area.width / 3).clamp(24, 40); + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(left_width), Constraint::Min(1)]) + .split(main_area); + render_conversations(frame, app, cols[0], true); + render_transcript(frame, app, cols[1], true); + } + ViewMode::List => render_conversations(frame, app, main_area, false), + ViewMode::Chat => render_transcript(frame, app, main_area, false), + } + + if let Some(input_area) = input_area { + render_input(frame, app, input_area); + if app.focus == Focus::Input { + let mut x = input_area + .x + .saturating_add(1) + .saturating_add(app.input.len() as u16); + let max_x = input_area + .x + .saturating_add(input_area.width.saturating_sub(2)); + x = x.min(max_x); + let y = input_area.y + 1; + frame.set_cursor_position(Position { x, y }); + } + } + + render_status(frame, app, status_area, mode); +} + +fn render_conversations(frame: &mut Frame, app: &AppState, area: Rect, in_split: bool) { + let title = if in_split { + "Conversations (↑/↓, Enter)" + } else { + "Conversations (↑/↓, Enter to open)" + }; + + let items = app + .conversations + .iter() + .map(|c| { + let unread = if c.unread_count > 0 { + format!(" ({})", c.unread_count) + } else { + String::new() + }; + let header = Line::from(vec![ + Span::styled(c.title.clone(), Style::default().add_modifier(Modifier::BOLD)), + Span::raw(unread), + ]); + let preview = Line::from(Span::styled( + c.preview.clone(), + Style::default().fg(Color::DarkGray), + )); + ListItem::new(vec![header, preview]) + }) + .collect::>(); + + let mut state = ListState::default(); + state.select(if app.conversations.is_empty() { + None + } else { + Some(app.selected_idx) + }); + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(title)) + .highlight_style( + Style::default() + .bg(Color::Blue) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▸ "); + + frame.render_stateful_widget(list, area, &mut state); +} + +fn render_transcript(frame: &mut Frame, app: &AppState, area: Rect, in_split: bool) { + let title = if let Some(_) = app.active_conversation_id { + if in_split { + format!("{} (Esc: nav, Tab: focus)", app.active_conversation_title) + } else { + format!("{} (Esc: back)", app.active_conversation_title) + } + } else { + "Chat".to_string() + }; + + let mut lines: Vec = Vec::new(); + for message in &app.messages { + let ts = time::OffsetDateTime::from_unix_timestamp(message.date_unix) + .unwrap_or(time::OffsetDateTime::UNIX_EPOCH) + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()); + + lines.push(Line::from(vec![ + Span::styled(message.sender.clone(), Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::styled(ts, Style::default().fg(Color::DarkGray)), + ])); + + let mut rendered_any_text = false; + for text_line in message.text.lines() { + rendered_any_text = true; + lines.push(Line::from(Span::raw(text_line.to_string()))); + } + if !rendered_any_text { + lines.push(Line::from(Span::styled( + "", + Style::default().fg(Color::DarkGray), + ))); + } + lines.push(Line::from(Span::raw(""))); + } + + if lines.is_empty() { + lines.push(Line::from(Span::styled( + "No messages.", + Style::default().fg(Color::DarkGray), + ))); + } + + let paragraph = Paragraph::new(Text::from(lines)) + .block(Block::default().borders(Borders::ALL).title(title)) + .wrap(Wrap { trim: false }) + .scroll((app.transcript_scroll, 0)); + + frame.render_widget(paragraph, area); +} + +fn render_input(frame: &mut Frame, app: &AppState, area: Rect) { + let title = if app.focus == Focus::Input { + "Reply (Enter to send)" + } else { + "Reply (press i to type)" + }; + let input = Paragraph::new(app.input.as_str()) + .block(Block::default().borders(Borders::ALL).title(title)); + frame.render_widget(input, area); +} + +fn render_status(frame: &mut Frame, app: &AppState, area: Rect, mode: ViewMode) { + let mut parts = vec![ + format!( + "{} convs", + app.conversations.len() + ), + match mode { + ViewMode::Split => "split".to_string(), + ViewMode::List => "list".to_string(), + ViewMode::Chat => "chat".to_string(), + }, + ]; + if !app.status.trim().is_empty() { + parts.push(app.status.clone()); + } + let line = parts.join(" | "); + frame.render_widget(Paragraph::new(line).block(Block::default().borders(Borders::TOP)), area); +} + +fn main() -> Result<()> { + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + crossterm::execute!( + stdout, + crossterm::terminal::EnterAlternateScreen, + crossterm::event::EnableMouseCapture + )?; + let backend = ratatui::backend::CrosstermBackend::new(stdout); + let mut terminal = ratatui::Terminal::new(backend)?; + + let res = run_app(&mut terminal); + + disable_raw_mode()?; + crossterm::execute!( + terminal.backend_mut(), + crossterm::event::DisableMouseCapture, + crossterm::terminal::LeaveAlternateScreen + )?; + terminal.show_cursor()?; + + res +} + +fn run_app(terminal: &mut ratatui::Terminal>) -> Result<()> { + let (request_tx, request_rx) = mpsc::channel::(); + let (event_tx, event_rx) = mpsc::channel::(); + let _worker = daemon::spawn_worker(request_rx, event_tx); + + let tick_rate = Duration::from_millis(150); + let refresh_rate = Duration::from_secs(2); + let mut last_tick = Instant::now(); + let mut last_refresh = Instant::now() - refresh_rate; + + let mut requested_view = ViewMode::List; + let mut app = AppState::new(); + app.status = "Connecting…".to_string(); + request_tx.send(daemon::Request::RefreshConversations).ok(); + app.refresh_conversations_in_flight = true; + + loop { + let size = terminal.size()?; + + while let Ok(evt) = event_rx.try_recv() { + match evt { + daemon::Event::Conversations(convs) => { + app.refresh_conversations_in_flight = false; + app.status.clear(); + app.conversations = convs; + if app.selected_idx >= app.conversations.len() { + app.selected_idx = app.conversations.len().saturating_sub(1); + } + if app.active_conversation_id.is_none() && !app.conversations.is_empty() { + app.selected_idx = app.selected_idx.min(app.conversations.len() - 1); + } + } + daemon::Event::Messages { + conversation_id, + messages, + } => { + app.refresh_messages_in_flight = false; + if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) { + let was_pinned = app.pinned_to_bottom; + app.messages = messages; + app.pinned_to_bottom = was_pinned; + } + } + daemon::Event::MessageSent { + conversation_id, + outgoing_id, + } => { + if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) { + app.status = outgoing_id + .as_deref() + .map(|id| format!("Sent ({id})")) + .unwrap_or_else(|| "Sent".to_string()); + app.refresh_messages_in_flight = false; + request_tx + .send(daemon::Request::RefreshMessages { conversation_id }) + .ok(); + app.refresh_messages_in_flight = true; + } + } + daemon::Event::MarkedRead => {} + daemon::Event::ConversationSyncTriggered { conversation_id } => { + if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) { + app.status = "Syncing…".to_string(); + } + } + daemon::Event::ConversationsUpdated => { + if !app.refresh_conversations_in_flight { + request_tx.send(daemon::Request::RefreshConversations).ok(); + app.refresh_conversations_in_flight = true; + } + if let Some(cid) = app.active_conversation_id.clone() { + if !app.refresh_messages_in_flight { + request_tx + .send(daemon::Request::RefreshMessages { + conversation_id: cid, + }) + .ok(); + app.refresh_messages_in_flight = true; + } + } + } + daemon::Event::MessagesUpdated { conversation_id } => { + if !app.refresh_conversations_in_flight { + request_tx.send(daemon::Request::RefreshConversations).ok(); + app.refresh_conversations_in_flight = true; + } + if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) { + if !app.refresh_messages_in_flight { + request_tx + .send(daemon::Request::RefreshMessages { + conversation_id, + }) + .ok(); + app.refresh_messages_in_flight = true; + } + } + } + daemon::Event::UpdateStreamReconnected => { + if !app.refresh_conversations_in_flight { + request_tx.send(daemon::Request::RefreshConversations).ok(); + app.refresh_conversations_in_flight = true; + } + if let Some(cid) = app.active_conversation_id.clone() { + if !app.refresh_messages_in_flight { + request_tx + .send(daemon::Request::RefreshMessages { + conversation_id: cid, + }) + .ok(); + app.refresh_messages_in_flight = true; + } + } + } + daemon::Event::Error(e) => { + app.refresh_conversations_in_flight = false; + app.refresh_messages_in_flight = false; + app.status = e; + } + } + } + + apply_transcript_scroll_policy(&mut app, size, requested_view); + terminal.draw(|f| ui(f, &app, requested_view))?; + + let timeout = tick_rate.saturating_sub(last_tick.elapsed()); + if crossterm::event::poll(timeout)? { + if let CEvent::Key(key) = crossterm::event::read()? { + if key.kind != KeyEventKind::Press { + continue; + } + + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match (key.code, ctrl) { + (KeyCode::Char('c'), true) => return Ok(()), + _ => {} + } + + let screen_mode = view_mode( + size.width, + app.active_conversation_id.is_some(), + requested_view, + ); + + let max_scroll = max_transcript_scroll(&app, size, requested_view); + + match screen_mode { + ViewMode::List => match key.code { + KeyCode::Up => app.select_prev(), + KeyCode::Down => app.select_next(), + KeyCode::Enter => { + app.open_selected_conversation(); + if app.active_conversation_id.is_some() { + requested_view = ViewMode::Chat; + if let Some(cid) = app.active_conversation_id.clone() { + request_tx.send(daemon::Request::MarkRead { conversation_id: cid.clone() }).ok(); + request_tx.send(daemon::Request::SyncConversation { conversation_id: cid.clone() }).ok(); + request_tx.send(daemon::Request::RefreshMessages { conversation_id: cid }).ok(); + app.refresh_messages_in_flight = true; + } + } + } + _ => {} + }, + ViewMode::Chat => match key.code { + KeyCode::Esc => { + requested_view = ViewMode::List; + app.focus = Focus::Navigation; + } + KeyCode::Char('i') => app.focus = Focus::Input, + _ => { + handle_chat_keys(&mut app, &request_tx, key.code, max_scroll); + } + }, + ViewMode::Split => match key.code { + KeyCode::Tab => { + app.focus = match app.focus { + Focus::Navigation => Focus::Input, + Focus::Input => Focus::Navigation, + } + } + KeyCode::Esc => app.focus = Focus::Navigation, + KeyCode::Char('i') => app.focus = Focus::Input, + KeyCode::Up => { + if app.focus == Focus::Navigation { + app.select_prev() + } else { + scroll_up(&mut app, 1); + } + } + KeyCode::Down => { + if app.focus == Focus::Navigation { + app.select_next() + } else { + scroll_down(&mut app, 1, max_scroll); + } + } + KeyCode::Enter => { + if app.focus == Focus::Navigation { + app.open_selected_conversation(); + requested_view = ViewMode::Chat; + if let Some(cid) = app.active_conversation_id.clone() { + request_tx.send(daemon::Request::MarkRead { conversation_id: cid.clone() }).ok(); + request_tx.send(daemon::Request::SyncConversation { conversation_id: cid.clone() }).ok(); + request_tx.send(daemon::Request::RefreshMessages { conversation_id: cid }).ok(); + app.refresh_messages_in_flight = true; + } + } else { + handle_chat_keys(&mut app, &request_tx, key.code, max_scroll); + } + } + _ => handle_chat_keys(&mut app, &request_tx, key.code, max_scroll), + }, + } + } + } + + if last_refresh.elapsed() >= refresh_rate { + if !app.refresh_conversations_in_flight { + request_tx.send(daemon::Request::RefreshConversations).ok(); + app.refresh_conversations_in_flight = true; + } + + if let Some(cid) = app.active_conversation_id.clone() { + if !app.refresh_messages_in_flight { + request_tx + .send(daemon::Request::RefreshMessages { + conversation_id: cid, + }) + .ok(); + app.refresh_messages_in_flight = true; + } + } + + last_refresh = Instant::now(); + } + + if last_tick.elapsed() >= tick_rate { + last_tick = Instant::now(); + } + } +} + +fn handle_chat_keys( + app: &mut AppState, + request_tx: &mpsc::Sender, + code: KeyCode, + max_scroll: u16, +) { + match code { + KeyCode::PageUp => scroll_up(app, 10), + KeyCode::PageDown => scroll_down(app, 10, max_scroll), + _ => {} + } + + if app.focus != Focus::Input { + return; + } + + match code { + KeyCode::Enter => { + let text = app.input.trim().to_string(); + if text.is_empty() { + return; + } + let Some(conversation_id) = app.active_conversation_id.clone() else { + app.status = "No conversation selected".to_string(); + return; + }; + request_tx + .send(daemon::Request::SendMessage { + conversation_id, + text, + }) + .ok(); + app.refresh_messages_in_flight = true; + app.input.clear(); + } + KeyCode::Backspace => { + app.input.pop(); + } + KeyCode::Char(c) => { + if !c.is_control() { + app.input.push(c); + } + } + _ => {} + } +} + +fn scroll_up(app: &mut AppState, amount: u16) { + if amount > 0 { + app.pinned_to_bottom = false; + } + app.transcript_scroll = app.transcript_scroll.saturating_sub(amount); +} + +fn scroll_down(app: &mut AppState, amount: u16, max_scroll: u16) { + app.transcript_scroll = app.transcript_scroll.saturating_add(amount); + if app.transcript_scroll >= max_scroll { + app.transcript_scroll = max_scroll; + app.pinned_to_bottom = true; + } +} + +fn transcript_inner_width(size: Size, app: &AppState, requested_view: ViewMode) -> u16 { + let mode = view_mode(size.width, app.active_conversation_id.is_some(), requested_view); + let outer_width = match mode { + ViewMode::Split => { + let left_width = (size.width / 3).clamp(24, 40); + size.width.saturating_sub(left_width) + } + ViewMode::Chat => size.width, + ViewMode::List => 0, + }; + + outer_width.saturating_sub(2).max(1) +} + +fn visual_line_count(s: &str, inner_width: u16) -> u16 { + let w = s.width(); + if w == 0 { + return 1; + } + let iw = inner_width.max(1) as usize; + ((w + iw - 1) / iw).min(u16::MAX as usize) as u16 +} + +fn transcript_content_visual_lines( + messages: &[daemon::ChatMessage], + inner_width: u16, +) -> u16 { + if messages.is_empty() { + return 1; + } + + let mut total: u32 = 0; + for message in messages { + let ts = time::OffsetDateTime::from_unix_timestamp(message.date_unix) + .unwrap_or(time::OffsetDateTime::UNIX_EPOCH) + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()); + let header = format!("{} {}", message.sender, ts); + total += visual_line_count(&header, inner_width) as u32; + + let mut rendered_any_text = false; + for text_line in message.text.lines() { + rendered_any_text = true; + total += visual_line_count(text_line, inner_width) as u32; + } + if !rendered_any_text { + total += visual_line_count("", inner_width) as u32; + } + + total += 1; // spacer line + } + + total.min(u16::MAX as u32) as u16 +} + +fn transcript_viewport_height(size: Size, app: &AppState, requested_view: ViewMode) -> u16 { + let mode = view_mode(size.width, app.active_conversation_id.is_some(), requested_view); + let show_input = + matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some(); + + let transcript_height = if show_input { + size.height.saturating_sub(4) // input (3) + status (1) + } else { + size.height.saturating_sub(1) // status + }; + + match mode { + ViewMode::Chat | ViewMode::Split => transcript_height.saturating_sub(2), // borders + ViewMode::List => 0, + } +} + +fn max_transcript_scroll(app: &AppState, size: Size, requested_view: ViewMode) -> u16 { + let viewport_height = transcript_viewport_height(size, app, requested_view); + let inner_width = transcript_inner_width(size, app, requested_view); + let content = transcript_content_visual_lines(&app.messages, inner_width); + content.saturating_sub(viewport_height) +} + +fn apply_transcript_scroll_policy(app: &mut AppState, size: Size, requested_view: ViewMode) { + let max_scroll = max_transcript_scroll(app, size, requested_view); + if app.pinned_to_bottom { + app.transcript_scroll = max_scroll; + } else { + app.transcript_scroll = app.transcript_scroll.min(max_scroll); + } +}