fix(ui): respect advanced toggle for Remote Server section (#454)

* fix(ui): respect advanced toggle for Remote Server section

The Remote Server section was packed directly into advanced_tab,
bypassing the advanced_revealer controlled by the 'Unlock Advanced
Settings' toggle. This made it always visible regardless of toggle state.

Changes:
- Move remote_api_group and remote_status_label packing from
  advanced_tab to controls_box (inside the revealer)
- Add visual separator between whisper.cpp controls and Remote Server
- Increase scrolled min_content_height from 350 to 500 for taller content
- Update power_user_switch tooltip/subtitle to mention remote server
- Update info label to clarify whisper.cpp vs remote server scope
- Align connection test to use requests.Session (consistent with
  transcription path)
- Add empty-URL warning when toggling remote server ON
- Add source-assertion tests for UI structure

Fixes: Remote Server now hidden when 'Unlock Advanced Settings' is OFF

* refactor(ui): move remote server to Speech Engine tab as peer engine

Remote server is now a first-class engine option in the Speech Engine
tab dropdown, alongside Vosk, Whisper, and whisper.cpp. The 'Use remote
server' override toggle has been removed from the Advanced tab.

Changes:
- Add 'Remote API' to engine combo dropdown (removed skip logic)
- Move remote server UI fields (URL, API key, endpoint, test button)
  from Advanced tab to Speech Engine tab
- Show/hide remote server fields based on engine selection
- Remove 'Use remote server' toggle and override logic
- Simplify get_selected_settings (engine comes from combo directly)
- Simplify _update_engine_specific_ui (check combo value)
- Remove _on_use_remote_toggled method
- Update _load_and_apply_settings (no remote switch logic)
- Update power_user tooltip/subtitle (no longer mentions remote server)
- Update tests for new behavior

Backend requires zero changes - recognition_manager.py already treats
remote_api as a first-class engine.

User flow:
1. Select 'Remote API' from engine dropdown
2. Model selection hides automatically
3. Remote server config fields appear (URL, API key, endpoint)
4. Enter server details and test connection
5. Done - clear and intuitive
This commit is contained in:
Jatin K Malik
2026-06-06 17:53:54 -07:00
committed by GitHub
parent 78f377fd0f
commit ddf073711b
3 changed files with 113 additions and 124 deletions
+7 -7
View File
@@ -890,14 +890,14 @@ EOF
echo " │ • Good for basic dictation needs │"
echo " └─────────────────────────────────────────────────────────────┘"
echo ""
echo " ┌─────────────────────────────────────────────────────────────┐"
echo " │ 4. REMOTE API (ADVANCED) │"
echo " │ • Offload processing to a GPU server on your network │"
echo " │ • Ideal for laptops without GPU │"
echo " ┌───────────────────────────────────────────────────────────────┐"
echo " │ 4. REMOTE API (ADVANCED) │"
echo " │ • Offload processing to a GPU server on your network │"
echo " │ • Ideal for laptops without GPU │"
echo " │ • Supports whisper.cpp server & OpenAI-compatible APIs │"
echo " │ • Minimal local resources needed │"
echo " │ • Requires a remote server to be running │"
echo " └─────────────────────────────────────────────────────────────┘"
echo " │ • Minimal local resources needed │"
echo " │ • Requires a remote server to be running │"
echo " └───────────────────────────────────────────────────────────────┘"
echo ""
# Show recommendation
+69 -117
View File
@@ -906,6 +906,7 @@ class SettingsDialog(Gtk.Dialog):
self._build_general_section()
self._build_audio_section()
self._build_engine_section()
self._build_remote_server_section()
self._build_recognition_section()
self._build_shortcuts_section()
self._build_advanced_section()
@@ -1204,7 +1205,9 @@ class SettingsDialog(Gtk.Dialog):
self.config_manager.save_settings()
# Auto apply only if remote engine is currently active
if self.use_remote_switch.get_active():
engine_text = self.engine_combo.get_active_text()
engine = _engine_from_display(engine_text) if engine_text else "vosk"
if engine == "remote_api":
self._auto_apply_settings()
def _on_test_remote_connection(self, widget):
@@ -1233,35 +1236,35 @@ class SettingsDialog(Gtk.Dialog):
clean_url = url.rstrip("/")
# Try connecting to the server
response = requests.get(clean_url, headers=headers, timeout=5)
# Determine server type
server_info = ""
session = requests.Session()
try:
if endpoint == "/v1/audio/transcriptions":
# Try OpenAI endpoint
openai_resp = requests.get(
f"{clean_url}/v1/models", headers=headers, timeout=5
)
if openai_resp.status_code == 200:
server_info = " (OpenAI compatible)"
elif endpoint == "/inference":
# Try whisper.cpp server endpoint
whispercpp_resp = requests.get(
f"{clean_url}/inference", headers=headers, timeout=5
)
if whispercpp_resp.status_code != 404:
server_info = " (whisper.cpp server)"
except Exception:
pass
response = session.get(clean_url, headers=headers, timeout=5)
GLib.idle_add(
self.remote_status_label.set_markup,
f"<span foreground='#26a269'>✓ Connected! "
f"(status={response.status_code}){server_info}</span>",
)
server_info = ""
try:
if endpoint == "/v1/audio/transcriptions":
openai_resp = session.get(
f"{clean_url}/v1/models", headers=headers, timeout=5
)
if openai_resp.status_code == 200:
server_info = " (OpenAI compatible)"
elif endpoint == "/inference":
whispercpp_resp = session.get(
f"{clean_url}/inference", headers=headers, timeout=5
)
if whispercpp_resp.status_code != 404:
server_info = " (whisper.cpp server)"
except Exception:
pass
GLib.idle_add(
self.remote_status_label.set_markup,
f"<span foreground='#26a269'>✓ Connected! "
f"(status={response.status_code}){server_info}</span>",
)
finally:
session.close()
except Exception as e:
error_msg = str(e)[:80]
@@ -1270,7 +1273,6 @@ class SettingsDialog(Gtk.Dialog):
f"<span foreground='#c01c28'>✗ Connection failed: {error_msg}</span>",
)
# Restore button state
GLib.idle_add(self.remote_test_btn.set_sensitive, True)
GLib.idle_add(self.remote_test_btn.set_label, "Test Connection")
@@ -1644,7 +1646,7 @@ class SettingsDialog(Gtk.Dialog):
# Scrollable container for the controls
scrolled = Gtk.ScrolledWindow()
scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scrolled.set_min_content_height(350)
scrolled.set_min_content_height(500)
controls_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
@@ -1781,6 +1783,11 @@ class SettingsDialog(Gtk.Dialog):
controls_box.pack_start(info_box, False, False, 0)
controls_box.pack_start(group, False, False, 0)
separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
separator.set_margin_top(8)
separator.set_margin_bottom(8)
controls_box.pack_start(separator, False, False, 0)
scrolled.add(controls_box)
self.advanced_revealer.add(scrolled)
self.advanced_tab.pack_start(self.advanced_revealer, True, True, 0)
@@ -1798,8 +1805,9 @@ class SettingsDialog(Gtk.Dialog):
self.power_user_switch.connect("state-set", self._on_power_user_toggled)
"""Build the Advanced tab (currently: Remote Server settings)."""
self.remote_api_group = PreferencesGroup(
def _build_remote_server_section(self):
"""Build the Remote Server configuration section (shown when Remote API engine is selected)."""
self.remote_server_group = PreferencesGroup(
title="Remote Server",
description=(
"Offload speech recognition to a server on your network. "
@@ -1807,20 +1815,6 @@ class SettingsDialog(Gtk.Dialog):
),
)
# Toggle: enable remote engine (overrides Speech Engine tab selection)
self.use_remote_switch = Gtk.Switch()
self.use_remote_switch.set_active(False)
self.use_remote_switch.set_tooltip_text(
"When enabled, transcription is performed by the configured remote "
"server instead of the local engine selected on the Speech Engine tab."
)
use_remote_row = PreferenceRow(
title="Use remote server",
subtitle="Override the local engine and send audio to a remote server",
widget=self.use_remote_switch,
)
self.remote_api_group.add_row(use_remote_row)
# Server URL
self.remote_api_url_entry = Gtk.Entry()
self.remote_api_url_entry.set_placeholder_text("http://192.168.1.100:8080")
@@ -1834,7 +1828,7 @@ class SettingsDialog(Gtk.Dialog):
subtitle="Remote speech recognition server address",
widget=self.remote_api_url_entry,
)
self.remote_api_group.add_row(remote_url_row)
self.remote_server_group.add_row(remote_url_row)
# API Key
self.remote_api_key_entry = Gtk.Entry()
@@ -1847,7 +1841,7 @@ class SettingsDialog(Gtk.Dialog):
subtitle="Authentication key (optional)",
widget=self.remote_api_key_entry,
)
self.remote_api_group.add_row(remote_key_row)
self.remote_server_group.add_row(remote_key_row)
# API Endpoint
self.remote_api_endpoint_combo = Gtk.ComboBoxText()
@@ -1865,7 +1859,7 @@ class SettingsDialog(Gtk.Dialog):
subtitle="API format for the remote server",
widget=self.remote_api_endpoint_combo,
)
self.remote_api_group.add_row(remote_endpoint_row)
self.remote_server_group.add_row(remote_endpoint_row)
# Connection test
self.remote_test_btn = Gtk.Button(label="Test Connection")
@@ -1876,16 +1870,16 @@ class SettingsDialog(Gtk.Dialog):
subtitle="Verify remote server is reachable",
widget=self.remote_test_btn,
)
self.remote_api_group.add_row(remote_test_row)
self.remote_server_group.add_row(remote_test_row)
self.advanced_tab.pack_start(self.remote_api_group, False, False, 0)
self.content_box.pack_start(self.remote_server_group, False, False, 0)
# Status label below the group
self.remote_status_label = Gtk.Label(label="", use_markup=True, xalign=0)
self.remote_status_label.set_margin_start(16)
self.remote_status_label.set_margin_top(4)
self.remote_status_label.get_style_context().add_class("status-info")
self.advanced_tab.pack_start(self.remote_status_label, False, False, 0)
self.content_box.pack_start(self.remote_status_label, False, False, 0)
# Load saved values into the widgets
saved_url = self.config_manager.get("speech_recognition", "remote_api_url", "")
@@ -1899,12 +1893,13 @@ class SettingsDialog(Gtk.Dialog):
self.remote_api_key_entry.set_text(saved_key)
self.remote_api_endpoint_combo.set_active_id(saved_endpoint)
# Connect signals (toggle and field changes both trigger re-apply)
self.use_remote_switch.connect("notify::active", self._on_use_remote_toggled)
self.remote_api_url_entry.connect("changed", self._on_remote_api_settings_changed)
self.remote_api_key_entry.connect("changed", self._on_remote_api_settings_changed)
self.remote_api_endpoint_combo.connect("changed", self._on_remote_api_settings_changed)
self.remote_server_group.hide()
self.remote_status_label.hide()
def _on_power_user_toggled(self, widget, state):
"""Handle the power-user opt-in toggle."""
if self._initializing or self._applying_settings:
@@ -1999,13 +1994,6 @@ class SettingsDialog(Gtk.Dialog):
self._auto_apply_settings()
def _on_use_remote_toggled(self, switch, _gparam):
"""Toggle remote-engine override: forces engine to remote_api when on."""
if self._initializing or self._applying_settings:
return
self._update_engine_specific_ui()
self._auto_apply_settings()
def _load_and_apply_settings(self):
"""Load current settings and populate the UI."""
settings = self._get_current_settings()
@@ -2032,54 +2020,33 @@ class SettingsDialog(Gtk.Dialog):
self.copy_to_clipboard_switch.set_active(copy_to_clipboard)
self.sound_effects_switch.set_active(self.config_manager.is_sound_effects_enabled())
# Populate engine combo with only available LOCAL engines.
# Remote API is not a peer engine in the combo — it lives on the
# Advanced tab as a toggle that overrides the local engine.
available_engines = get_available_engines()
available_count = 0
for engine in ENGINE_MODELS.keys():
if engine == "remote_api":
continue
if available_engines.get(engine, False):
display_name = _engine_display_name(engine)
self.engine_combo.append(display_name, display_name)
available_count += 1
if available_count == 0:
logger.error("No local speech recognition engines available!")
logger.error("No speech recognition engines available!")
# Still add them so the UI works, but log the error
for engine in ENGINE_MODELS.keys():
if engine == "remote_api":
continue
display_name = _engine_display_name(engine)
self.engine_combo.append(display_name, display_name)
else:
logger.info(f"Populated {available_count} available local engines: {available_engines}")
logger.info(f"Populated {available_count} available engines: {available_engines}")
# If config says remote_api, turn the Advanced toggle on and pick a
# local fallback for the combo (so toggling off has somewhere to land).
remote_active = self.current_engine == "remote_api"
if remote_active:
self.use_remote_switch.set_active(True)
local_fallback = "whisper_cpp"
if not available_engines.get(local_fallback, False):
for engine in ENGINE_MODELS.keys():
if engine != "remote_api" and available_engines.get(engine, False):
local_fallback = engine
break
self.current_engine = local_fallback
else:
self.use_remote_switch.set_active(False)
if not available_engines.get(self.current_engine, False):
logger.warning(
f"Current engine '{self.current_engine}' is not available, "
"selecting first available"
)
for engine in ENGINE_MODELS.keys():
if engine != "remote_api" and available_engines.get(engine, False):
self.current_engine = engine
break
if not available_engines.get(self.current_engine, False):
logger.warning(
f"Current engine '{self.current_engine}' is not available, "
"selecting first available"
)
for engine in ENGINE_MODELS.keys():
if available_engines.get(engine, False):
self.current_engine = engine
break
engine_text = _engine_display_name(self.current_engine)
logger.info(f"Setting active engine to: {engine_text}")
@@ -2383,25 +2350,22 @@ class SettingsDialog(Gtk.Dialog):
self._processing_language_change = False
def _update_engine_specific_ui(self):
"""Show/hide UI elements driven by the active engine.
"""Show/hide UI elements driven by the active engine."""
engine_text = self.engine_combo.get_active_text()
engine = _engine_from_display(engine_text) if engine_text else "vosk"
is_remote = engine == "remote_api"
The engine combo is local-only (vosk/whisper/whisper_cpp). The remote
engine is enabled via the Advanced tab's "Use remote server" switch,
which overrides the local selection.
"""
is_remote = (
getattr(self, "use_remote_switch", None) is not None
and self.use_remote_switch.get_active()
)
# When remote is active, the local model/legend UI is irrelevant
if is_remote:
self.model_row.hide()
self.model_info_card.hide()
self.legend_box.hide()
self.remote_server_group.show_all()
self.remote_status_label.show()
else:
self.model_row.show_all()
self.legend_box.show_all()
self.remote_server_group.hide()
self.remote_status_label.hide()
self._update_model_info()
self._update_advanced_tab_sensitivity()
@@ -2439,14 +2403,6 @@ class SettingsDialog(Gtk.Dialog):
self.model_info_card.hide()
return
# Remote engine override (Advanced tab) hides the local model info card.
if (
getattr(self, "use_remote_switch", None) is not None
and self.use_remote_switch.get_active()
):
self.model_info_card.hide()
return
engine = _engine_from_display(engine_text)
model_id = self.model_combo.get_active_id()
@@ -2628,11 +2584,7 @@ class SettingsDialog(Gtk.Dialog):
model_id = self.model_combo.get_active_id()
language_id = self.language_combo.get_active_id()
# Advanced "Use remote server" toggle overrides the local engine combo.
if self.use_remote_switch.get_active():
engine = "remote_api"
else:
engine = _engine_from_display(engine_text) if engine_text else "vosk"
engine = _engine_from_display(engine_text) if engine_text else "vosk"
model_size = model_id.lower() if model_id else "small"
language = language_id if language_id else "auto"
+37
View File
@@ -496,6 +496,43 @@ class TestSettingsDialogInstantApply(unittest.TestCase):
source_code.index("controls_box.pack_start(group"),
)
def test_remote_api_section_in_speech_engine_tab(self):
import os
source_path = os.path.join(
os.path.dirname(__file__),
"..",
"src",
"vocalinux",
"ui",
"settings_dialog.py",
)
with open(source_path, "r") as f:
source_code = f.read()
self.assertIn("self.content_box.pack_start(self.remote_server_group", source_code)
self.assertIn("self.content_box.pack_start(self.remote_status_label", source_code)
self.assertNotIn("advanced_tab.pack_start(self.remote_server_group", source_code)
self.assertNotIn("advanced_tab.pack_start(self.remote_status_label", source_code)
self.assertNotIn("self.use_remote_switch", source_code)
def test_connection_test_uses_session(self):
import os
source_path = os.path.join(
os.path.dirname(__file__),
"..",
"src",
"vocalinux",
"ui",
"settings_dialog.py",
)
with open(source_path, "r") as f:
source_code = f.read()
self.assertIn("session = requests.Session()", source_code)
self.assertIn("session.close()", source_code)
class TestSettingsDialogHelperFunctions(unittest.TestCase):
"""Test cases for settings dialog helper functions."""