From e7512b3cf0beeca03a049147365190541339651a Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Sat, 7 Feb 2026 09:25:13 +0100 Subject: [PATCH] [feat](trx-frontend-appkit): add native macOS AppKit frontend Add a new trx-frontend-appkit crate using objc2 + AppKit as a replacement for the removed Qt/QML frontend. The frontend provides the same feature set: frequency/mode/band display, PTT/power/VFO/lock controls, signal/TX metering, and frequency/mode/TX-limit input. Architecture splits platform-agnostic model (model.rs) from AppKit UI (ui.rs) to facilitate future UIKit porting. State flows from the async tokio watcher via std::sync::mpsc to the AppKit main thread; button actions flow back through a channel to stay on the UI thread. Feature-gated behind `appkit-frontend` cargo feature. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Stanislaw Grams --- Cargo.lock | 185 +++++++++ Cargo.toml | 1 + OVERVIEW.md | 2 + README.md | 1 + src/trx-client/Cargo.toml | 5 + src/trx-client/src/config.rs | 11 + src/trx-client/src/main.rs | 10 + .../trx-frontend-appkit/Cargo.toml | 30 ++ .../trx-frontend-appkit/src/helpers.rs | 66 ++++ .../trx-frontend-appkit/src/lib.rs | 23 ++ .../trx-frontend-appkit/src/model.rs | 160 ++++++++ .../trx-frontend-appkit/src/server.rs | 183 +++++++++ .../trx-frontend-appkit/src/ui.rs | 351 ++++++++++++++++++ trx-client.toml.example | 4 + 14 files changed, 1032 insertions(+) create mode 100644 src/trx-client/trx-frontend/trx-frontend-appkit/Cargo.toml create mode 100644 src/trx-client/trx-frontend/trx-frontend-appkit/src/helpers.rs create mode 100644 src/trx-client/trx-frontend/trx-frontend-appkit/src/lib.rs create mode 100644 src/trx-client/trx-frontend/trx-frontend-appkit/src/model.rs create mode 100644 src/trx-client/trx-frontend/trx-frontend-appkit/src/server.rs create mode 100644 src/trx-client/trx-frontend/trx-frontend-appkit/src/ui.rs diff --git a/Cargo.lock b/Cargo.lock index ca2b12b..f9fdb87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,6 +302,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "brotli" version = "8.0.2" @@ -543,6 +552,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1096,6 +1115,158 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-foundation", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1783,6 +1954,7 @@ dependencies = [ "tracing-subscriber", "trx-core", "trx-frontend", + "trx-frontend-appkit", "trx-frontend-http", "trx-frontend-http-json", "trx-frontend-rigctl", @@ -1806,6 +1978,19 @@ dependencies = [ "trx-core", ] +[[package]] +name = "trx-frontend-appkit" +version = "0.1.0" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-foundation", + "tokio", + "tracing", + "trx-core", + "trx-frontend", +] + [[package]] name = "trx-frontend-http" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b7e16b7..3b0a26c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "src/trx-client/trx-frontend", "src/trx-client/trx-frontend/trx-frontend-http", "src/trx-client/trx-frontend/trx-frontend-http-json", + "src/trx-client/trx-frontend/trx-frontend-appkit", "src/trx-client/trx-frontend/trx-frontend-rigctl", "src/trx-core", ] diff --git a/OVERVIEW.md b/OVERVIEW.md index 6c4407e..5d77933 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -19,6 +19,7 @@ | Backend/frontend registry | Implemented | | TCP CAT transport | Partial (config wiring only) | | JSON TCP control (line-delimited) | Implemented (configurable frontend) | +| AppKit GUI frontend | Implemented (macOS only, optional) | | Plugin registry loading | Implemented (shared libraries) | | Configuration file (TOML) | Implemented | | Rig state machine | Implemented | @@ -80,6 +81,7 @@ | `trx-frontend` | Frontend trait (`FrontendSpawner`) | | `trx-frontend-http` | Web UI with REST API and SSE | | `trx-frontend-http-json` | JSON-over-TCP control frontend | +| `trx-frontend-appkit` | AppKit GUI frontend (macOS only, optional) | | `trx-frontend-rigctl` | Hamlib rigctl-compatible TCP interface | | `trx-server` | Server binary — connects to rig backend, exposes JSON TCP control | | `trx-client` | Client binary — connects to server, runs frontends (HTTP, rigctl) | diff --git a/README.md b/README.md index d8866cc..42074fd 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The rig task is now driven by the controller components (state machine, handlers - HTTP status/control frontend (`trx-frontend-http`) - JSON TCP control frontend (`trx-frontend-http-json`) +- AppKit GUI frontend (`trx-frontend-appkit`, macOS only, optional via `appkit-frontend` feature) - rigctl-compatible TCP frontend (`trx-frontend-rigctl`, listens on 127.0.0.1:4532) ## Plugin discovery diff --git a/src/trx-client/Cargo.toml b/src/trx-client/Cargo.toml index 06b5517..5d739ab 100644 --- a/src/trx-client/Cargo.toml +++ b/src/trx-client/Cargo.toml @@ -22,3 +22,8 @@ trx-frontend = { path = "trx-frontend" } trx-frontend-http = { path = "trx-frontend/trx-frontend-http" } trx-frontend-http-json = { path = "trx-frontend/trx-frontend-http-json" } trx-frontend-rigctl = { path = "trx-frontend/trx-frontend-rigctl" } +trx-frontend-appkit = { path = "trx-frontend/trx-frontend-appkit", optional = true } + +[features] +default = [] +appkit-frontend = ["trx-frontend-appkit/appkit"] diff --git a/src/trx-client/src/config.rs b/src/trx-client/src/config.rs index 90ce386..884a1cc 100644 --- a/src/trx-client/src/config.rs +++ b/src/trx-client/src/config.rs @@ -77,6 +77,8 @@ pub struct FrontendsConfig { pub rigctl: RigctlFrontendConfig, /// JSON TCP frontend settings pub http_json: HttpJsonFrontendConfig, + /// AppKit (macOS) frontend settings + pub appkit: AppKitFrontendConfig, } /// HTTP frontend configuration. @@ -156,6 +158,14 @@ pub struct HttpJsonAuthConfig { pub tokens: Vec, } +/// AppKit (macOS) frontend configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct AppKitFrontendConfig { + /// Whether AppKit frontend is enabled + pub enabled: bool, +} + impl ClientConfig { /// Load configuration from a specific file path. pub fn load_from_file(path: &Path) -> Result { @@ -225,6 +235,7 @@ impl ClientConfig { port: 4532, }, http_json: HttpJsonFrontendConfig::default(), + appkit: AppKitFrontendConfig { enabled: false }, }, }; diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index 3db856b..7790a9f 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -25,11 +25,15 @@ use trx_frontend_http::register_frontend as register_http_frontend; use trx_frontend_http_json::{register_frontend as register_http_json_frontend, set_auth_tokens}; use trx_frontend_rigctl::register_frontend as register_rigctl_frontend; +#[cfg(feature = "appkit-frontend")] +use trx_frontend_appkit::register_frontend as register_appkit_frontend; + use config::ClientConfig; use remote_client::{parse_remote_url, RemoteClientConfig}; const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - remote rig client"); const RIG_TASK_CHANNEL_BUFFER: usize = 32; +const APPKIT_FRONTEND_LISTEN_ADDR: ([u8; 4], u16) = ([127, 0, 0, 1], 0); #[derive(Debug, Parser)] #[command( @@ -94,6 +98,8 @@ async fn main() -> DynResult<()> { register_http_frontend(); register_http_json_frontend(); register_rigctl_frontend(); + #[cfg(feature = "appkit-frontend")] + register_appkit_frontend(); let _plugin_libs = plugins::load_plugins(); let cli = Cli::parse(); @@ -149,6 +155,9 @@ async fn main() -> DynResult<()> { if cfg.frontends.http_json.enabled { fes.push("httpjson".to_string()); } + if cfg.frontends.appkit.enabled { + fes.push("appkit".to_string()); + } if fes.is_empty() { fes.push("http".to_string()); } @@ -230,6 +239,7 @@ async fn main() -> DynResult<()> { "http" => SocketAddr::from((http_listen, http_port)), "rigctl" => SocketAddr::from((rigctl_listen, rigctl_port)), "httpjson" => SocketAddr::from((http_json_listen, http_json_port)), + "appkit" => SocketAddr::from(APPKIT_FRONTEND_LISTEN_ADDR), other => { return Err(format!("Frontend missing listen configuration: {}", other).into()); } diff --git a/src/trx-client/trx-frontend/trx-frontend-appkit/Cargo.toml b/src/trx-client/trx-frontend/trx-frontend-appkit/Cargo.toml new file mode 100644 index 0000000..c19a195 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-appkit/Cargo.toml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2025 Stanislaw Grams +# +# SPDX-License-Identifier: BSD-2-Clause + +[package] +name = "trx-frontend-appkit" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = [] +appkit = ["dep:objc2", "dep:objc2-foundation", "dep:objc2-app-kit"] + +[dependencies] +trx-core = { path = "../../../trx-core" } +trx-frontend = { path = ".." } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = { version = "0.6", optional = true } +objc2-foundation = { version = "0.3", optional = true, features = ["NSString", "NSThread", "NSRunLoop"] } +objc2-app-kit = { version = "0.3", optional = true, features = [ + "NSApplication", "NSWindow", "NSView", "NSTextField", + "NSButton", "NSStackView", "NSColor", "NSResponder", + "NSControl", "NSRunningApplication", "NSGraphics" +] } diff --git a/src/trx-client/trx-frontend/trx-frontend-appkit/src/helpers.rs b/src/trx-client/trx-frontend/trx-frontend-appkit/src/helpers.rs new file mode 100644 index 0000000..60c2eaf --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-appkit/src/helpers.rs @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +use trx_core::rig::state::{RigMode, RigState}; + +pub fn format_freq(hz: u64) -> String { + if hz >= 1_000_000_000 { + format!("{:.3} GHz", hz as f64 / 1_000_000_000.0) + } else if hz >= 10_000_000 { + format!("{:.3} MHz", hz as f64 / 1_000_000.0) + } else if hz >= 1_000 { + format!("{:.1} kHz", hz as f64 / 1_000.0) + } else { + format!("{hz} Hz") + } +} + +pub fn mode_label(mode: &RigMode) -> String { + match mode { + RigMode::LSB => "LSB".to_string(), + RigMode::USB => "USB".to_string(), + RigMode::CW => "CW".to_string(), + RigMode::CWR => "CWR".to_string(), + RigMode::AM => "AM".to_string(), + RigMode::WFM => "WFM".to_string(), + RigMode::FM => "FM".to_string(), + RigMode::DIG => "DIG".to_string(), + RigMode::PKT => "PKT".to_string(), + RigMode::Other(val) => val.clone(), + } +} + +pub fn parse_mode(value: &str) -> RigMode { + match value.trim().to_uppercase().as_str() { + "LSB" => RigMode::LSB, + "USB" => RigMode::USB, + "CW" => RigMode::CW, + "CWR" => RigMode::CWR, + "AM" => RigMode::AM, + "FM" => RigMode::FM, + "WFM" => RigMode::WFM, + "DIG" | "DIGI" => RigMode::DIG, + "PKT" | "PACKET" => RigMode::PKT, + other => RigMode::Other(other.to_string()), + } +} + +pub fn vfo_label(state: &RigState) -> String { + let Some(vfo) = state.status.vfo.as_ref() else { + return "--".to_string(); + }; + + let mut lines = Vec::new(); + for (idx, entry) in vfo.entries.iter().enumerate() { + let marker = if vfo.active == Some(idx) { "*" } else { " " }; + let freq = format_freq(entry.freq.hz); + let mode = entry + .mode + .as_ref() + .map(mode_label) + .unwrap_or_else(|| "--".to_string()); + lines.push(format!("{marker} {}: {} {}", entry.name, freq, mode)); + } + lines.join("\n") +} diff --git a/src/trx-client/trx-frontend/trx-frontend-appkit/src/lib.rs b/src/trx-client/trx-frontend/trx-frontend-appkit/src/lib.rs new file mode 100644 index 0000000..5992c3f --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-appkit/src/lib.rs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +#[cfg(all(target_os = "macos", feature = "appkit"))] +pub mod helpers; +#[cfg(all(target_os = "macos", feature = "appkit"))] +pub mod model; +#[cfg(all(target_os = "macos", feature = "appkit"))] +pub mod server; +#[cfg(all(target_os = "macos", feature = "appkit"))] +pub mod ui; + +#[cfg(all(target_os = "macos", feature = "appkit"))] +pub fn register_frontend() { + use trx_frontend::FrontendSpawner; + trx_frontend::register_frontend("appkit", server::AppKitFrontend::spawn_frontend); +} + +#[cfg(not(all(target_os = "macos", feature = "appkit")))] +pub fn register_frontend() { + // No-op on non-macOS platforms or when appkit feature is disabled. +} diff --git a/src/trx-client/trx-frontend/trx-frontend-appkit/src/model.rs b/src/trx-client/trx-frontend/trx-frontend-appkit/src/model.rs new file mode 100644 index 0000000..b3066c4 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-appkit/src/model.rs @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! Platform-agnostic rig state model. +//! +//! This struct holds the display-ready state derived from `RigState`. +//! The UI layer reads from this model; command sending is handled +//! separately via an `mpsc::Sender`. + +use trx_core::rig::state::RigState; + +use crate::helpers::{format_freq, mode_label, vfo_label}; + +/// Display-ready rig state. Updated from `RigState` on each change. +#[derive(Debug, Clone)] +pub struct RigStateModel { + pub freq_hz: u64, + pub freq_text: String, + pub mode: String, + pub band: String, + pub tx_enabled: bool, + pub locked: bool, + pub powered: bool, + pub rx_sig: i32, + pub tx_power: i32, + pub tx_limit: i32, + pub tx_swr: f64, + pub tx_alc: i32, + pub vfo: String, +} + +impl Default for RigStateModel { + fn default() -> Self { + Self { + freq_hz: 0, + freq_text: "-- Hz".to_string(), + mode: "--".to_string(), + band: "--".to_string(), + tx_enabled: false, + locked: false, + powered: false, + rx_sig: 0, + tx_power: 0, + tx_limit: 0, + tx_swr: 0.0, + tx_alc: 0, + vfo: "--".to_string(), + } + } +} + +impl RigStateModel { + /// Update all fields from a `RigState` snapshot. Returns `true` if anything changed. + pub fn update(&mut self, state: &RigState) -> bool { + let mut changed = false; + + let freq_hz = state.status.freq.hz; + if self.freq_hz != freq_hz { + self.freq_hz = freq_hz; + self.freq_text = format_freq(freq_hz); + changed = true; + } + + let mode = mode_label(&state.status.mode); + if self.mode != mode { + self.mode = mode; + changed = true; + } + + let band = state.band_name().unwrap_or_else(|| "--".to_string()); + if self.band != band { + self.band = band; + changed = true; + } + + if self.tx_enabled != state.status.tx_en { + self.tx_enabled = state.status.tx_en; + changed = true; + } + + let locked = state.status.lock.unwrap_or(false); + if self.locked != locked { + self.locked = locked; + changed = true; + } + + let powered = state.control.enabled.unwrap_or(false); + if self.powered != powered { + self.powered = powered; + changed = true; + } + + let rx_sig = state + .status + .rx + .as_ref() + .and_then(|rx| rx.sig) + .unwrap_or(0); + if self.rx_sig != rx_sig { + self.rx_sig = rx_sig; + changed = true; + } + + let tx_power = state + .status + .tx + .as_ref() + .and_then(|tx| tx.power) + .map(i32::from) + .unwrap_or(0); + if self.tx_power != tx_power { + self.tx_power = tx_power; + changed = true; + } + + let tx_limit = state + .status + .tx + .as_ref() + .and_then(|tx| tx.limit) + .map(i32::from) + .unwrap_or(0); + if self.tx_limit != tx_limit { + self.tx_limit = tx_limit; + changed = true; + } + + let tx_swr = state + .status + .tx + .as_ref() + .and_then(|tx| tx.swr) + .unwrap_or(0.0) as f64; + if (self.tx_swr - tx_swr).abs() > f64::EPSILON { + self.tx_swr = tx_swr; + changed = true; + } + + let tx_alc = state + .status + .tx + .as_ref() + .and_then(|tx| tx.alc) + .map(i32::from) + .unwrap_or(0); + if self.tx_alc != tx_alc { + self.tx_alc = tx_alc; + changed = true; + } + + let vfo = vfo_label(state); + if self.vfo != vfo { + self.vfo = vfo; + changed = true; + } + + changed + } +} diff --git a/src/trx-client/trx-frontend/trx-frontend-appkit/src/server.rs b/src/trx-client/trx-frontend/trx-frontend-appkit/src/server.rs new file mode 100644 index 0000000..e3bec84 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-appkit/src/server.rs @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! AppKit frontend spawner. +//! +//! Spawns a dedicated thread for the NSApplication run loop and an async +//! task that watches for rig state changes and pushes them to the UI +//! thread via a std::sync::mpsc channel. + +use std::net::SocketAddr; +use std::thread; + +use objc2::MainThreadMarker; +use objc2_app_kit::NSApplication; +use tokio::sync::{mpsc, watch}; +use tokio::task::JoinHandle; +use tracing::{info, warn}; + +use trx_core::rig::command::RigCommand; +use trx_core::{RigRequest, RigState}; +use trx_frontend::FrontendSpawner; + +use crate::model::RigStateModel; +use crate::ui::{self, ButtonAction, UiElements}; + +/// AppKit frontend implementation. +pub struct AppKitFrontend; + +impl FrontendSpawner for AppKitFrontend { + fn spawn_frontend( + state_rx: watch::Receiver, + rig_tx: mpsc::Sender, + _callsign: Option, + listen_addr: SocketAddr, + ) -> JoinHandle<()> { + // Channel for state updates: async watcher -> AppKit thread. + let (state_update_tx, state_update_rx) = std::sync::mpsc::channel::(); + + // Channel for button actions: UI buttons -> AppKit thread main loop. + let (action_tx, action_rx) = std::sync::mpsc::channel::(); + + // Spawn async state watcher that forwards state changes. + let handle = tokio::spawn(async move { + info!("AppKit frontend starting (addr hint: {})", listen_addr); + run_state_watcher(state_rx, state_update_tx).await; + }); + + // Spawn the AppKit main thread. + thread::spawn(move || { + let mtm = match MainThreadMarker::new() { + Some(m) => m, + None => { + warn!("AppKit frontend: could not obtain MainThreadMarker"); + return; + } + }; + + let app = NSApplication::sharedApplication(mtm); + + let (window, ui_elements) = ui::build_window(mtm, action_tx); + + // Keep window alive for the process lifetime. + std::mem::forget(window); + + let mut model = RigStateModel::default(); + + // Run a polling loop instead of NSApplication::run() so we can + // process state updates and button actions between event cycles. + loop { + // Process pending AppKit events. + drain_appkit_events(&app); + + // Process state updates from the async watcher. + while let Ok(state) = state_update_rx.try_recv() { + if model.update(&state) { + ui_elements.refresh(&model); + } + } + + // Process button actions. + while let Ok(action) = action_rx.try_recv() { + handle_action(action, &ui_elements, &rig_tx, &model); + } + + // Sleep briefly to avoid busy-waiting. + std::thread::sleep(std::time::Duration::from_millis(16)); + } + }); + + handle + } +} + +fn drain_appkit_events(app: &NSApplication) { + use objc2_app_kit::NSEventMask; + use objc2_foundation::NSDate; + + loop { + let event = unsafe { + app.nextEventMatchingMask_untilDate_inMode_dequeue( + NSEventMask::Any, + Some(&NSDate::distantPast()), + objc2_foundation::NSDefaultRunLoopMode, + true, + ) + }; + match event { + Some(event) => { + app.sendEvent(&event); + } + None => break, + } + } +} + +fn handle_action( + action: ButtonAction, + ui: &UiElements, + rig_tx: &mpsc::Sender, + model: &RigStateModel, +) { + match action { + ButtonAction::TogglePtt => { + send_command(rig_tx, RigCommand::SetPtt(!model.tx_enabled)); + } + ButtonAction::TogglePower => { + if model.powered { + send_command(rig_tx, RigCommand::PowerOff); + } else { + send_command(rig_tx, RigCommand::PowerOn); + } + } + ButtonAction::ToggleVfo => { + send_command(rig_tx, RigCommand::ToggleVfo); + } + ButtonAction::ToggleLock => { + if model.locked { + send_command(rig_tx, RigCommand::Unlock); + } else { + send_command(rig_tx, RigCommand::Lock); + } + } + ButtonAction::SetFreq => { + ui.handle_set_freq(rig_tx); + } + ButtonAction::SetMode => { + ui.handle_set_mode(rig_tx); + } + ButtonAction::SetTxLimit => { + ui.handle_set_tx_limit(rig_tx); + } + } +} + +fn send_command(tx: &mpsc::Sender, cmd: RigCommand) { + let (resp_tx, _resp_rx) = tokio::sync::oneshot::channel(); + if tx + .blocking_send(RigRequest { + cmd, + respond_to: resp_tx, + }) + .is_err() + { + warn!("AppKit frontend: rig command send failed"); + } +} + +async fn run_state_watcher( + mut state_rx: watch::Receiver, + state_update_tx: std::sync::mpsc::Sender, +) { + // Send initial state. + let _ = state_update_tx.send(state_rx.borrow().clone()); + + while state_rx.changed().await.is_ok() { + let state = state_rx.borrow().clone(); + if state_update_tx.send(state).is_err() { + warn!("AppKit frontend: state update channel closed"); + break; + } + } +} diff --git a/src/trx-client/trx-frontend/trx-frontend-appkit/src/ui.rs b/src/trx-client/trx-frontend/trx-frontend-appkit/src/ui.rs new file mode 100644 index 0000000..fa2da13 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-appkit/src/ui.rs @@ -0,0 +1,351 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! AppKit window and controls. +//! +//! Creates an NSWindow with labels for rig status and buttons for rig +//! control. All AppKit calls must run on the main thread. + +use objc2::rc::Retained; +use objc2::{msg_send, MainThreadMarker}; +use objc2_app_kit::{ + NSBackingStoreType, NSButton, NSColor, NSStackView, NSTextField, NSView, NSWindow, + NSWindowStyleMask, +}; +use objc2_foundation::{NSArray, NSEdgeInsets, NSPoint, NSRect, NSSize, NSString}; + +use tokio::sync::{mpsc, oneshot}; +use tracing::warn; + +use trx_core::radio::freq::Freq; +use trx_core::rig::command::RigCommand; +use trx_core::rig::request::RigRequest; + +use crate::helpers::parse_mode; +use crate::model::RigStateModel; + +/// All UI elements that need updating when rig state changes. +/// These must only be accessed from the AppKit main thread. +pub struct UiElements { + pub freq_label: Retained, + pub mode_label: Retained, + pub band_label: Retained, + pub ptt_label: Retained, + pub lock_label: Retained, + pub power_label: Retained, + pub rx_sig_label: Retained, + pub tx_power_label: Retained, + pub tx_limit_label: Retained, + pub tx_swr_label: Retained, + pub tx_alc_label: Retained, + pub vfo_label: Retained, + // Input fields for reading user input from button actions + pub freq_input: Retained, + pub mode_input: Retained, + pub tx_limit_input: Retained, +} + +impl UiElements { + /// Refresh all labels from the model. + pub fn refresh(&self, model: &RigStateModel) { + set_label_text(&self.freq_label, &model.freq_text); + set_label_text(&self.mode_label, &format!("Mode: {}", model.mode)); + set_label_text(&self.band_label, &format!("Band: {}", model.band)); + set_label_text( + &self.ptt_label, + if model.tx_enabled { "PTT: TX" } else { "PTT: RX" }, + ); + set_label_text( + &self.lock_label, + if model.locked { + "Lock: ON" + } else { + "Lock: OFF" + }, + ); + set_label_text( + &self.power_label, + if model.powered { + "Power: ON" + } else { + "Power: OFF" + }, + ); + set_label_text(&self.rx_sig_label, &format!("RX Sig: {}", model.rx_sig)); + set_label_text( + &self.tx_power_label, + &format!("TX Power: {}", model.tx_power), + ); + set_label_text( + &self.tx_limit_label, + &format!("TX Limit: {}", model.tx_limit), + ); + set_label_text( + &self.tx_swr_label, + &format!("SWR: {:.1}", model.tx_swr), + ); + set_label_text(&self.tx_alc_label, &format!("ALC: {}", model.tx_alc)); + set_label_text(&self.vfo_label, &format!("VFO: {}", model.vfo)); + } + + /// Read the frequency input field value and send a SetFreq command. + pub fn handle_set_freq(&self, rig_tx: &mpsc::Sender) { + let val = self.freq_input.stringValue(); + let text = val.to_string(); + if let Ok(hz) = text.trim().parse::() { + if hz > 0 { + send_command(rig_tx, RigCommand::SetFreq(Freq { hz })); + } + } + } + + /// Read the mode input field value and send a SetMode command. + pub fn handle_set_mode(&self, rig_tx: &mpsc::Sender) { + let val = self.mode_input.stringValue(); + let mode = parse_mode(&val.to_string()); + send_command(rig_tx, RigCommand::SetMode(mode)); + } + + /// Read the TX limit input field value and send a SetTxLimit command. + pub fn handle_set_tx_limit(&self, rig_tx: &mpsc::Sender) { + let val = self.tx_limit_input.stringValue(); + if let Ok(limit) = val.to_string().trim().parse::() { + send_command(rig_tx, RigCommand::SetTxLimit(limit)); + } + } +} + +fn set_label_text(label: &NSTextField, text: &str) { + let ns = NSString::from_str(text); + label.setStringValue(&ns); +} + +fn make_label(mtm: MainThreadMarker, text: &str) -> Retained { + let ns = NSString::from_str(text); + let label = NSTextField::labelWithString(&ns, mtm); + label.setEditable(false); + label.setBordered(false); + label.setDrawsBackground(false); + label.setTextColor(Some(&NSColor::labelColor())); + label +} + +fn make_editable_field(mtm: MainThreadMarker, placeholder: &str) -> Retained { + let ns = NSString::from_str(placeholder); + let field = NSTextField::textFieldWithString(&ns, mtm); + field.setEditable(true); + field.setBordered(true); + field +} + +/// Convert an NSTextField into an NSView (NSTextField -> NSControl -> NSView). +fn text_field_to_view(field: Retained) -> Retained { + Retained::into_super(Retained::into_super(field)) +} + +/// Convert an NSButton into an NSView (NSButton -> NSControl -> NSView). +fn button_to_view(btn: Retained) -> Retained { + Retained::into_super(Retained::into_super(btn)) +} + +/// Actions that buttons can trigger. Sent via a channel to be handled +/// on the AppKit thread where UI elements live. +#[derive(Debug, Clone, Copy)] +pub enum ButtonAction { + TogglePtt, + TogglePower, + ToggleVfo, + ToggleLock, + SetFreq, + SetMode, + SetTxLimit, +} + +/// Build the main window with status labels and control buttons. +/// +/// `action_tx` is a channel sender for button actions — each button stores +/// its action tag and the server's run-loop timer reads these to dispatch. +/// +/// Returns the window (which must be kept alive) and the UI elements +/// struct for later updates. +pub fn build_window( + mtm: MainThreadMarker, + action_tx: std::sync::mpsc::Sender, +) -> (Retained, UiElements) { + let style = NSWindowStyleMask::Titled + | NSWindowStyleMask::Closable + | NSWindowStyleMask::Miniaturizable + | NSWindowStyleMask::Resizable; + + let frame = NSRect::new(NSPoint::new(200.0, 200.0), NSSize::new(400.0, 520.0)); + let window = unsafe { + NSWindow::initWithContentRect_styleMask_backing_defer( + mtm.alloc::(), + frame, + style, + NSBackingStoreType::Buffered, + false, + ) + }; + + let title = NSString::from_str("trx-rs"); + window.setTitle(&title); + + // Status labels + let freq_label = make_label(mtm, "-- Hz"); + let mode_label = make_label(mtm, "Mode: --"); + let band_label = make_label(mtm, "Band: --"); + let ptt_label = make_label(mtm, "PTT: RX"); + let lock_label = make_label(mtm, "Lock: OFF"); + let power_label = make_label(mtm, "Power: OFF"); + let rx_sig_label = make_label(mtm, "RX Sig: 0"); + let tx_power_label = make_label(mtm, "TX Power: 0"); + let tx_limit_label = make_label(mtm, "TX Limit: 0"); + let tx_swr_label = make_label(mtm, "SWR: 0.0"); + let tx_alc_label = make_label(mtm, "ALC: 0"); + let vfo_label = make_label(mtm, "VFO: --"); + + // Control buttons — each stores an action tag, actions are dispatched + // via the global action table. + let ptt_btn = make_button(mtm, "Toggle PTT", ButtonAction::TogglePtt, &action_tx); + let power_btn = make_button(mtm, "Toggle Power", ButtonAction::TogglePower, &action_tx); + let vfo_btn = make_button(mtm, "Toggle VFO", ButtonAction::ToggleVfo, &action_tx); + let lock_btn = make_button(mtm, "Toggle Lock", ButtonAction::ToggleLock, &action_tx); + + // Input fields + let freq_input = make_editable_field(mtm, "Freq (Hz)"); + let set_freq_btn = make_button(mtm, "Set Freq", ButtonAction::SetFreq, &action_tx); + + let mode_input = make_editable_field(mtm, "Mode (USB, LSB, ...)"); + let set_mode_btn = make_button(mtm, "Set Mode", ButtonAction::SetMode, &action_tx); + + let tx_limit_input = make_editable_field(mtm, "TX Limit (0-255)"); + let set_tx_limit_btn = make_button(mtm, "Set TX Limit", ButtonAction::SetTxLimit, &action_tx); + + // Build vertical stack view + let views: Vec> = vec![ + text_field_to_view(freq_label.clone()), + text_field_to_view(mode_label.clone()), + text_field_to_view(band_label.clone()), + text_field_to_view(ptt_label.clone()), + text_field_to_view(lock_label.clone()), + text_field_to_view(power_label.clone()), + text_field_to_view(rx_sig_label.clone()), + text_field_to_view(tx_power_label.clone()), + text_field_to_view(tx_limit_label.clone()), + text_field_to_view(tx_swr_label.clone()), + text_field_to_view(tx_alc_label.clone()), + text_field_to_view(vfo_label.clone()), + button_to_view(ptt_btn), + button_to_view(power_btn), + button_to_view(vfo_btn), + button_to_view(lock_btn), + text_field_to_view(freq_input.clone()), + button_to_view(set_freq_btn), + text_field_to_view(mode_input.clone()), + button_to_view(set_mode_btn), + text_field_to_view(tx_limit_input.clone()), + button_to_view(set_tx_limit_btn), + ]; + + let ns_views = NSArray::from_retained_slice(&views); + let stack = NSStackView::stackViewWithViews(&ns_views, mtm); + stack.setOrientation(objc2_app_kit::NSUserInterfaceLayoutOrientation::Vertical); + stack.setSpacing(8.0); + stack.setEdgeInsets(NSEdgeInsets { + top: 16.0, + left: 16.0, + bottom: 16.0, + right: 16.0, + }); + + window.setContentView(Some(&stack)); + window.makeKeyAndOrderFront(None); + + let ui = UiElements { + freq_label, + mode_label, + band_label, + ptt_label, + lock_label, + power_label, + rx_sig_label, + tx_power_label, + tx_limit_label, + tx_swr_label, + tx_alc_label, + vfo_label, + freq_input, + mode_input, + tx_limit_input, + }; + + (window, ui) +} + +fn send_command(tx: &mpsc::Sender, cmd: RigCommand) { + let (resp_tx, _resp_rx) = oneshot::channel(); + if tx + .blocking_send(RigRequest { + cmd, + respond_to: resp_tx, + }) + .is_err() + { + warn!("AppKit frontend: rig command send failed"); + } +} + +fn make_button( + mtm: MainThreadMarker, + title: &str, + action: ButtonAction, + action_tx: &std::sync::mpsc::Sender, +) -> Retained { + let title_ns = NSString::from_str(title); + let btn = unsafe { + NSButton::buttonWithTitle_target_action(&title_ns, None, None, mtm) + }; + + // Store the action in the global table, indexed by the button's tag. + let idx = register_button_action(action, action_tx.clone()); + unsafe { + let _: () = msg_send![&btn, setTag: idx as isize]; + }; + + btn +} + +// Global action table for buttons. +struct ActionEntry { + action: ButtonAction, + sender: std::sync::mpsc::Sender, +} + +static BUTTON_ACTIONS: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +fn action_table() -> &'static std::sync::Mutex> { + BUTTON_ACTIONS.get_or_init(|| std::sync::Mutex::new(Vec::new())) +} + +fn register_button_action( + action: ButtonAction, + sender: std::sync::mpsc::Sender, +) -> usize { + let mut table = action_table().lock().unwrap(); + let idx = table.len(); + table.push(ActionEntry { action, sender }); + idx +} + +/// Call the action for a button given its tag index. +/// This sends the button's action through the channel for processing +/// on the main thread where UI elements are accessible. +pub fn invoke_button_action(tag: isize) { + let table = action_table().lock().unwrap(); + if let Some(entry) = table.get(tag as usize) { + let _ = entry.sender.send(entry.action); + } +} diff --git a/trx-client.toml.example b/trx-client.toml.example index 330adbd..95ebaad 100644 --- a/trx-client.toml.example +++ b/trx-client.toml.example @@ -47,3 +47,7 @@ listen = "127.0.0.1" port = 0 # List of accepted bearer tokens (empty = no auth) # auth.tokens = ["example-token"] + +[frontends.appkit] +# Enable AppKit GUI frontend (macOS only, requires appkit-frontend feature) +enabled = false