"""Frame-wide actions triggered by holding NEXT/BACK past Frame.hold_duration_ms, instead of the per-widget action a short press runs (see models.FrameButtonAction, app/widgets/*.py's ACTIONS). Not scoped to any one widget -- e.g. cycling through the owner's saved layouts -- so this is its own registry rather than living in a widget module. Each function's signature is (db, frame) -> None, the frame-level analogue of a widget ACTIONS entry's (db, frame, widget) -> None, and each is responsible for its own locking/commit internally (frame_locked/ widget_locked), same convention as app/widgets/*.py. routers/device.py's /frame/global-next and /frame/global-back look up which (if any) of these Frame.next_hold_action/back_hold_action points to and call it, same "unset/unknown -> silent no-op" posture as an unbound short-press button.""" from __future__ import annotations import logging from sqlalchemy import select from sqlalchemy.orm import Session from . import grid from .db import frame_locked from .models import Frame, PhotoWidgetConfig, SavedLayout, Widget from .routers.api_layouts import apply_layout_to_frame from .widgets import WIDGET_TYPES logger = logging.getLogger(__name__) def cycle_layout(db: Session, frame: Frame) -> None: """Applies the owner's next saved layout compatible with this frame's current grid size, in a stable order (by id), wrapping back to the first past the last one. A silent no-op if the frame is unclaimed or its owner has no compatible saved layouts -- same posture as every other action here when there's nothing to do.""" if frame.owner_user_id is None: return cols, rows = grid.grid_dims(frame.orientation) candidates = db.scalars( select(SavedLayout) .where(SavedLayout.user_id == frame.owner_user_id, SavedLayout.cols == cols, SavedLayout.rows == rows) .order_by(SavedLayout.id) ).all() if not candidates: return next_layout = candidates[0] if frame.last_cycled_layout_id is not None: for i, layout in enumerate(candidates): if layout.id == frame.last_cycled_layout_id: next_layout = candidates[(i + 1) % len(candidates)] break apply_layout_to_frame(db, frame, next_layout) with frame_locked(db, frame.id) as locked: locked.last_cycled_layout_id = next_layout.id def refresh_all_widgets(db: Session, frame: Frame) -> None: """Runs every widget's own check_now (calendar/weather/whiteboard), regardless of which button it's normally bound to -- a manual "sync everything now" global action. One widget's failure doesn't block the rest, same posture as routers/device.py's _run_button_actions.""" widgets = db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all() for widget in widgets: module = WIDGET_TYPES.get(widget.widget_type) check_now = module.ACTIONS.get("check_now") if module else None if check_now is None: continue try: check_now(db, frame, widget) except Exception: logger.exception( "refresh_all_widgets failed for widget %d (frame %d)", widget.id, frame.id ) def toggle_all_photo_locks(db: Session, frame: Frame) -> None: """Flips PhotoWidgetConfig.locked for every photo widget on the frame at once. Target state is the opposite of "everything's already locked" -- one hold freezes every photo widget unless they're all already frozen, in which case it unfreezes all of them. A no-op if the frame has no photo widgets.""" widget_ids = [ w.id for w in db.scalars( select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == "photos") ) ] if not widget_ids: return configs = db.scalars( select(PhotoWidgetConfig).where(PhotoWidgetConfig.widget_id.in_(widget_ids)) ).all() if not configs: return target = not all(c.locked for c in configs) with frame_locked(db, frame.id): for config in configs: config.locked = target GLOBAL_ACTIONS = { "cycle_layout": cycle_layout, "refresh_all_widgets": refresh_all_widgets, "toggle_all_photo_locks": toggle_all_photo_locks, } GLOBAL_ACTION_LABELS = { "cycle_layout": "Cycle saved layouts", "refresh_all_widgets": "Refresh all widgets now", "toggle_all_photo_locks": "Freeze/unfreeze all photo widgets", }