[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:
2026-02-07 09:25:13 +01:00
parent 2c128127e6
commit e7512b3cf0
14 changed files with 1032 additions and 0 deletions
+5
View File
@@ -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"]
+11
View File
@@ -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 },
},
};
+10
View File
@@ -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);
}
}