Skip to content
Merged
4 changes: 4 additions & 0 deletions docs/STATUS_LANGUAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@ attributes, so it spells the token's value.
its marker-coloured border.
- Error status rows and the ERROR box lead with the off-white warning sign
(`draw_warning_sign`).
- The Settings ERROR box says what was observed on its main line (off-white,
bold) and one next step under it (muted), as the web's box. A banner the
menu opened with brings its own step (`action=` beside `banner=`) or none;
the video failure's step stands only under the video failure.
- A fault's sentence is a top-right status row, whatever raised it (a video
failure, a missing controller, an output whose interface has no address, a
marker another station controls too).
Expand Down
7 changes: 4 additions & 3 deletions openfollow/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ def __init__(
self._settings_menu_active: bool = False
self._settings_menu_index: int = 0
self._settings_menu_banner: str = ""
self._settings_menu_banner_action: str = ""
# Newest pushed-settings warning pending when the Settings menu opened.
self._settings_pushed_upto: int | None = None
self._settings_key_pressed: bool = False
Expand Down Expand Up @@ -536,7 +537,7 @@ def _refresh_psn_source_advisory(self) -> str:
if resolved:
banner += f"Using auto-detected {resolved}."
else:
banner += "No usable interface – pick one to continue."
banner += "No usable interface."
self._psn_source_banner = banner
return banner

Expand Down Expand Up @@ -739,8 +740,8 @@ def _confirm_field_choice_picker(self) -> None:
def _process_field_choice_picker_input(self) -> None:
runtime_process_field_choice_picker_input(self)

def _enter_settings_menu(self, *, banner: str = "") -> None:
runtime_enter_settings_menu(self, banner=banner)
def _enter_settings_menu(self, *, banner: str = "", action: str = "") -> None:
runtime_enter_settings_menu(self, banner=banner, action=action)

def _exit_settings_menu(self) -> None:
runtime_exit_settings_menu(self)
Expand Down
68 changes: 48 additions & 20 deletions openfollow/runtime/app_modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from openfollow.configuration import config_file_problem, config_problem_words, is_simple_mode, save_config_fields
from openfollow.input.touch_remote import holds
from openfollow.runtime import paths_runtime
from openfollow.video.failure import SourceKind, VideoFailure, failure_sentence
from openfollow.video.failure import SourceKind, VideoFailure, failure_action, failure_sentence

if TYPE_CHECKING:
from openfollow.app import OpenFollowApp
Expand Down Expand Up @@ -461,21 +461,24 @@ def _settings_menu_action(app: OpenFollowApp, index: int) -> str | None:
return action


def enter_settings_menu(app: OpenFollowApp, *, banner: str = "") -> None:
def enter_settings_menu(app: OpenFollowApp, *, banner: str = "", action: str = "") -> None:
"""Open the Settings menu overlay.

``banner`` is stored on the app and rendered inside the menu's
red-bordered error box (word-wrapped, bold body) – used by the
startup auto-open and the runtime ``check_video_disconnect_banner``
so the operator sees *why* the menu auto-opened. The subtitle row
keeps its static "Open a sub-screen." text regardless.
so the operator sees *why* the menu auto-opened. ``action`` is the
banner's one next step, drawn under it (lighter); a banner never
carries the step in its own sentence. The subtitle row keeps its
static "Open a sub-screen." text regardless.
"""
if app._settings_menu_active:
return
from openfollow.runtime.pushed_settings_hud import newest_pending

app._settings_menu_active = True
app._settings_menu_banner = banner
app._settings_menu_banner_action = action
app._settings_pushed_upto = newest_pending(app)
# Below the removal, where the highlight always starts: a reflexive press
# (the menu also opens itself on a video failure) must not acknowledge a push.
Expand All @@ -485,6 +488,7 @@ def enter_settings_menu(app: OpenFollowApp, *, banner: str = "") -> None:
def exit_settings_menu(app: OpenFollowApp) -> None:
app._settings_menu_active = False
app._settings_menu_banner = ""
app._settings_menu_banner_action = ""
app._video_disconnect_menu_open = False


Expand Down Expand Up @@ -1179,41 +1183,53 @@ def confirm_source_type_selection(app: OpenFollowApp) -> None:
)
exit_source_type_selection(app)
app._enter_settings_menu(
banner=(f"Couldn't switch to {display}: {error}. Configure it in the web UI, then try again."),
banner=f"Couldn't switch to {display}: {error}.",
action="Configure it in the web UI, then try again.",
)
return
app._config.video_source_type = new_type
saved = _persist_config(app, "video_source_type")
logger.info("Video source type set to: %s (live)", new_type)
if not saved:
exit_source_type_selection(app)
app._enter_settings_menu(banner=_unsaved_banner(app, display, "switch again"))
_report_unsaved(app, display, "switch again")
return

exit_source_type_selection(app)
_route_after_video_source_change(app, new_type)


def _why_unsaved(app: OpenFollowApp, again: str) -> str:
"""Why a save did not reach the settings file, and the one step before the operator tries *again*."""
def _unsaved_reason(app: OpenFollowApp, again: str) -> tuple[str, str]:
"""What is wrong with the settings file (``""`` if it reads), and the step before the operator tries *again*."""
problem = config_file_problem(app._config_path)
if problem is None:
return f"Check the station's storage, then {again}."
return "", f"Check the station's storage, then {again}."
said, step = config_problem_words(problem)
return f"The settings file {said}. {step}, then {again}."
return f"The settings file {said}.", f"{step}, then {again}."


def _why_unsaved(app: OpenFollowApp, again: str) -> str:
"""Why a save did not reach the settings file, and the one step before the operator tries *again*."""
return " ".join(part for part in _unsaved_reason(app, again) if part)


def _unsaved_banner(app: OpenFollowApp, what: str, again: str) -> str:
def _enter_unsaved_menu(app: OpenFollowApp, lead: str, again: str) -> None:
"""Open Settings on a save that did not reach the settings file, *lead* naming what."""
seen, step = _unsaved_reason(app, again)
app._enter_settings_menu(banner=f"{lead} {seen}".rstrip(), action=step)


def _report_unsaved(app: OpenFollowApp, what: str, again: str) -> None:
# A repaired file is reloaded at once and brings its own value back.
return f"{what} is live but could not be saved. {_why_unsaved(app, again)}"
_enter_unsaved_menu(app, f"{what} is live but could not be saved.", again)


def save_source_pick(app: OpenFollowApp, field: str, value: str) -> None:
"""Save a source picked on screen; its input is connecting to it already, so the reload swaps nothing."""
setattr(app._config, field, value)
if not _persist_config(app, field):
what = f"The {_input_display_name(app._config.video_source_type)} source"
app._enter_settings_menu(banner=_unsaved_banner(app, what, "pick it again"))
_report_unsaved(app, what, "pick it again")


def _input_display_name(source_type: str) -> str:
Expand All @@ -1236,7 +1252,7 @@ def _save_for_input_not_running(app: OpenFollowApp, field: str, value: str, labe
if _persist_config(app, field):
logger.info("Set %s to %r; its input is not running.", field, value)
return
app._enter_settings_menu(banner=f"{label} could not be saved. {_why_unsaved(app, again)}")
_enter_unsaved_menu(app, f"{label} could not be saved.", again)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1333,7 +1349,8 @@ def confirm_url_editor(app: OpenFollowApp) -> None:
target = _input_display_name(source_type)
exit_url_editor(app)
app._enter_settings_menu(
banner=(f"Couldn't switch to {target}: {error}. Configure {label} in the web UI, then try again."),
banner=f"Couldn't switch to {target}: {error}.",
action=f"Configure {label} in the web UI, then try again.",
)
return
setattr(app._config, field, new_value)
Expand All @@ -1343,7 +1360,7 @@ def confirm_url_editor(app: OpenFollowApp) -> None:
exit_url_editor(app)
if not saved:
what, again = (_input_display_name(source_type), "switch again") if switching else (label, "enter it again")
app._enter_settings_menu(banner=_unsaved_banner(app, what, again))
_report_unsaved(app, what, again)


def process_url_editor_input(app: OpenFollowApp) -> None:
Expand Down Expand Up @@ -1476,7 +1493,8 @@ def confirm_field_choice_picker(app: OpenFollowApp) -> None:
target = _input_display_name(source_type)
exit_field_choice_picker(app)
app._enter_settings_menu(
banner=(f"Couldn't switch to {target}: {error}. Configure {label} in the web UI, then try again."),
banner=f"Couldn't switch to {target}: {error}.",
action=f"Configure {label} in the web UI, then try again.",
)
return
logger.warning(
Expand All @@ -1488,7 +1506,8 @@ def confirm_field_choice_picker(app: OpenFollowApp) -> None:
)
exit_field_choice_picker(app)
app._enter_settings_menu(
banner=(f"Couldn't apply {label} = {value!r}: {error}. Try a different value, or edit in the web UI."),
banner=f"Couldn't apply {label} = {value!r}: {error}.",
action="Try a different value, or edit in the web UI.",
)
return
setattr(app._config, field_name, value)
Expand All @@ -1498,7 +1517,7 @@ def confirm_field_choice_picker(app: OpenFollowApp) -> None:
exit_field_choice_picker(app)
if not saved:
what, again = (_input_display_name(source_type), "switch again") if switching else (label, "pick it again")
app._enter_settings_menu(banner=_unsaved_banner(app, what, again))
_report_unsaved(app, what, again)


def process_field_choice_picker_input(app: OpenFollowApp) -> None:
Expand Down Expand Up @@ -1569,6 +1588,14 @@ def _video_disconnect_banner_text(app: OpenFollowApp) -> str:
return f"Video source ({source_type}) is not available{label}."


def _video_disconnect_action(app: OpenFollowApp) -> str:
"""The video failure's one next step, drawn under its banner."""
receiver = app._video_receiver
failure = getattr(getattr(receiver, "status_marker", None), "failure", VideoFailure.NONE)
kind = getattr(receiver, "source_kind", SourceKind.REMOTE)
return failure_action(failure, kind=kind, simple=is_simple_mode(app._config))


def check_video_disconnect_banner(app: OpenFollowApp) -> None:
"""Startup-only Settings-menu auto-open for a video source that
never comes up. Replaces the standalone "No Signal" overlay;
Expand Down Expand Up @@ -1614,6 +1641,7 @@ def check_video_disconnect_banner(app: OpenFollowApp) -> None:
# banner from current state so a stale source name can't linger.
if app._video_disconnect_menu_open and app._settings_menu_active:
app._settings_menu_banner = _video_disconnect_banner_text(app)
app._settings_menu_banner_action = _video_disconnect_action(app)
return

# Disconnected. Only the startup case (never connected) auto-opens;
Expand Down Expand Up @@ -1643,7 +1671,7 @@ def check_video_disconnect_banner(app: OpenFollowApp) -> None:
"Video source %r never connected after startup – opening Settings.",
app._config.video_source_type,
)
app._enter_settings_menu(banner=_video_disconnect_banner_text(app))
app._enter_settings_menu(banner=_video_disconnect_banner_text(app), action=_video_disconnect_action(app))


# Grace period between startup and the Settings-menu auto-open when
Expand Down
5 changes: 3 additions & 2 deletions openfollow/runtime/overlay_draw_hud.py
Original file line number Diff line number Diff line change
Expand Up @@ -715,14 +715,15 @@ def draw_settings_menu(renderer: Any, cr: Any, state: OverlayState, w: int, h: i
# structured message including source + GStreamer error). Opening Settings
# manually falls back to the same pair the web UI shows - the sentence
# naming what failed, then the element's own wording - because the raw
# message alone was half of what the browser was reporting.
# message alone was half of what the browser was reporting. A banner
# brings its own next step; the video's goes only under the video failure.
error_text = state.settings_menu_banner or state.video_failure_text or state.error_message
if error_text:
cursor_y = _draw_settings_error_box(
renderer,
cr,
error_text,
state.video_failure_action,
state.settings_menu_banner_action if state.settings_menu_banner else state.video_failure_action,
content_x,
cursor_y,
content_w,
Expand Down
1 change: 1 addition & 0 deletions openfollow/runtime/overlay_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ class OverlayState:
settings_items_submenu: list[bool] = field(default_factory=list)
settings_selected_index: int = 0
settings_menu_banner: str = ""
settings_menu_banner_action: str = ""
# About / license screen – read-only, no extra payload.
about_active: bool = False
# Drive picker + diagnostics export screen.
Expand Down
2 changes: 2 additions & 0 deletions openfollow/runtime/services_marker_visuals.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,13 +646,15 @@ def build_marker_visual_state(
state.settings_items_submenu = submenu
state.settings_selected_index = app._settings_menu_index
state.settings_menu_banner = app._settings_menu_banner
state.settings_menu_banner_action = app._settings_menu_banner_action
else:
state.settings_items = []
state.settings_items_enabled = []
state.settings_items_disabled_reasons = []
state.settings_items_submenu = []
state.settings_selected_index = 0
state.settings_menu_banner = ""
state.settings_menu_banner_action = ""

state.about_active = getattr(app, "_about_active", False)
_populate_media_overlay(app, state)
Expand Down
24 changes: 18 additions & 6 deletions tests/test_app_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ def test_stale_pin_with_multi_nic_opens_settings_with_banner(
monkeypatch.setattr(
app_module,
"runtime_enter_settings_menu",
lambda a, *, banner="": banners.append(banner),
lambda a, *, banner="", action="": banners.append(banner),
)
app.run()
assert len(banners) == 1
Expand Down Expand Up @@ -515,7 +515,7 @@ def test_stale_pin_with_single_nic_proceeds_silently(
monkeypatch.setattr(
app_module,
"runtime_enter_settings_menu",
lambda a, *, banner="": banners.append(banner),
lambda a, *, banner="", action="": banners.append(banner),
)
app.run()
assert banners == []
Expand All @@ -536,7 +536,7 @@ def test_empty_iface_skips_check(
monkeypatch.setattr(
app_module,
"runtime_enter_settings_menu",
lambda a, *, banner="": banners.append(banner),
lambda a, *, banner="", action="": banners.append(banner),
)
app.run()
assert banners == []
Expand Down Expand Up @@ -568,12 +568,12 @@ def test_offline_host_with_pin_logs_no_usable_ip_banner(
monkeypatch.setattr(
app_module,
"runtime_enter_settings_menu",
lambda a, *, banner="": banners.append(banner),
lambda a, *, banner="", action="": banners.append(banner),
)
app.run()
assert banners == [] # zero candidates → no Settings prompt
assert app._psn_source_status == "none"
assert "No usable interface" in app._psn_source_banner
assert app._psn_source_banner == "Pinned network interface 'eth0' is not available. No usable interface."

def test_iface_pin_resolves_and_skips_settings(
self,
Expand Down Expand Up @@ -608,7 +608,7 @@ def test_iface_pin_resolves_and_skips_settings(
monkeypatch.setattr(
app_module,
"runtime_enter_settings_menu",
lambda a, *, banner="": banners.append(banner),
lambda a, *, banner="", action="": banners.append(banner),
)
app.run()
assert banners == []
Expand Down Expand Up @@ -792,6 +792,18 @@ def test_no_arg_delegators(
# All helpers take ``app`` as first arg.
assert hits[0][0] is app

def test_enter_settings_menu_forwards_the_banner_and_its_step(
self,
patched_ctor,
monkeypatch: pytest.MonkeyPatch, # noqa: ANN001
) -> None:
hits: list[dict[str, str]] = []
monkeypatch.setattr(app_module, "runtime_enter_settings_menu", lambda _app, **kw: hits.append(kw))

app = OpenFollowApp(config_path=patched_ctor.cfg_path)
app._enter_settings_menu(banner="Seen.", action="Do this.")
assert hits == [{"banner": "Seen.", "action": "Do this."}]

@pytest.mark.parametrize("verdict", [True, False])
def test_run_frame_forwards_the_helpers_verdict(
self,
Expand Down
Loading
Loading