[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 <noreply@anthropic.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
Generated
+185
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<Self, ConfigError> {
|
||||
@@ -225,6 +235,7 @@ impl ClientConfig {
|
||||
port: 4532,
|
||||
},
|
||||
http_json: HttpJsonFrontendConfig::default(),
|
||||
appkit: AppKitFrontendConfig { enabled: false },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# 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"
|
||||
] }
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// 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.
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// 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<RigRequest>`.
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// 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<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
_callsign: Option<String>,
|
||||
listen_addr: SocketAddr,
|
||||
) -> JoinHandle<()> {
|
||||
// Channel for state updates: async watcher -> AppKit thread.
|
||||
let (state_update_tx, state_update_rx) = std::sync::mpsc::channel::<RigState>();
|
||||
|
||||
// Channel for button actions: UI buttons -> AppKit thread main loop.
|
||||
let (action_tx, action_rx) = std::sync::mpsc::channel::<ButtonAction>();
|
||||
|
||||
// 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<RigRequest>,
|
||||
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<RigRequest>, 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<RigState>,
|
||||
state_update_tx: std::sync::mpsc::Sender<RigState>,
|
||||
) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// 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<NSTextField>,
|
||||
pub mode_label: Retained<NSTextField>,
|
||||
pub band_label: Retained<NSTextField>,
|
||||
pub ptt_label: Retained<NSTextField>,
|
||||
pub lock_label: Retained<NSTextField>,
|
||||
pub power_label: Retained<NSTextField>,
|
||||
pub rx_sig_label: Retained<NSTextField>,
|
||||
pub tx_power_label: Retained<NSTextField>,
|
||||
pub tx_limit_label: Retained<NSTextField>,
|
||||
pub tx_swr_label: Retained<NSTextField>,
|
||||
pub tx_alc_label: Retained<NSTextField>,
|
||||
pub vfo_label: Retained<NSTextField>,
|
||||
// Input fields for reading user input from button actions
|
||||
pub freq_input: Retained<NSTextField>,
|
||||
pub mode_input: Retained<NSTextField>,
|
||||
pub tx_limit_input: Retained<NSTextField>,
|
||||
}
|
||||
|
||||
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<RigRequest>) {
|
||||
let val = self.freq_input.stringValue();
|
||||
let text = val.to_string();
|
||||
if let Ok(hz) = text.trim().parse::<u64>() {
|
||||
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<RigRequest>) {
|
||||
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<RigRequest>) {
|
||||
let val = self.tx_limit_input.stringValue();
|
||||
if let Ok(limit) = val.to_string().trim().parse::<u8>() {
|
||||
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<NSTextField> {
|
||||
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<NSTextField> {
|
||||
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<NSTextField>) -> Retained<NSView> {
|
||||
Retained::into_super(Retained::into_super(field))
|
||||
}
|
||||
|
||||
/// Convert an NSButton into an NSView (NSButton -> NSControl -> NSView).
|
||||
fn button_to_view(btn: Retained<NSButton>) -> Retained<NSView> {
|
||||
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<ButtonAction>,
|
||||
) -> (Retained<NSWindow>, 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::<NSWindow>(),
|
||||
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<Retained<NSView>> = 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<RigRequest>, 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<ButtonAction>,
|
||||
) -> Retained<NSButton> {
|
||||
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<ButtonAction>,
|
||||
}
|
||||
|
||||
static BUTTON_ACTIONS: std::sync::OnceLock<std::sync::Mutex<Vec<ActionEntry>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
fn action_table() -> &'static std::sync::Mutex<Vec<ActionEntry>> {
|
||||
BUTTON_ACTIONS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn register_button_action(
|
||||
action: ButtonAction,
|
||||
sender: std::sync::mpsc::Sender<ButtonAction>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user