Compare commits

...
Author SHA1 Message Date
spoopyghosty0 0bfdfbd0e8 Themes for the features merged from main: Monitor tab, Sparkline, Portal's own info colour
- Monitor: its colour maps (levels, process groups, CPU clusters, power split) refill on a theme change; restyle()
  stops and drops the Monitor view like the other kept views
- Sparkline's default colour is read when it's built (it was captured at import)
- Portal themes: INFO is a cyan of its own (#5CD0E6); it equalled PC's blue, so the Monitor showed Steam and SteamVR
  processes, and CPU and memory, in one colour
- Translation template regenerated after the rebase
2026-10-07 20:13:29 -04:00
spoopyghosty0 19addc1570 ui_smoke: a warm-up screen while the web fonts load (the first shot measured text with a fallback font: wrapped and overlapping labels) 2026-10-07 20:01:10 -04:00
spoopyghosty0 1f35c77a69 Selected sidebar tab: white icon on the portal fade in dual themes (an orange icon clashed with it) 2026-10-07 20:00:45 -04:00
spoopyghosty0 5973b6c85c Wordmark with a portal between Frame and Port; glyphs match Material icons in menus
- Dual themes: "Frame" (accent), a small tilted portal (rim from the accent into SECONDARY around a dark hole,
  generated in the active theme's colours, also for installed themes: glyphs.portal_mark), "Port" (SECONDARY)
- Glyphs: stroke 2 (was 1.75, lighter than Material's icons); menu items tint them TEXT like Material's own icons
- ui_smoke: waits until the web server listens (a fixed 6 s failed with a second smoke run on the machine)
2026-10-07 20:00:45 -04:00
spoopyghosty0 d74dc1aa61 Portal (OLED) and Original themes, installable theme files, both portals in the Portal theme
- Built-ins: Portal (default), Portal (OLED) (true black) and Original (the former violet; "classic" still maps to it)
- Portal now uses both of the logo's portals: orange stays the main action and selection; the new SECONDARY token
  (blue) colours outlined buttons, progress bars and switch tracks; "dual" themes add the blue-to-orange sidebar
  edge, the two-colour wordmark and the selected tab's fade. Original looks exactly as before
- Theme files: <data>/themes/*.json (name, base, dual, any subset of the colour tokens), checked on install (dark
  background, readable text, known colours; T.ThemeError says why). Settings -> Appearance: "Install theme file...",
  "Copy this theme as a file" (a template), a bin icon removes an installed theme
- docs/THEMES.md + docs/themes/example-theme.json (kept valid by a test); ui_smoke --themes includes installed themes
2026-10-07 20:00:44 -04:00
spoopyghosty0 7b14207f81 Portal theme, FramePort logo and glyphs, live theme switching in Settings
- Logo (a monitor that goes through a tilted portal and comes out as the Steam Frame) in the sidebar and welcome
  screen; src/assets/icon.png is the bundles' app icon (flet build; PyInstaller fallback via --icon)
- 24 px line glyphs (ui/icons/glyphs, ui/glyphs.py) replace Material icons for FramePort's own concepts: Frame,
  launch test, PC, live view, screenshots, Type on Frame, verified recipe, rebuild; shown as tinted images through
  C.as_icon and the button/menu helpers (same size as the Material icons next to them)
- Colour themes, dark only: "portal" (default; orange accent, blue PC side, status colours chosen so warnings never
  read as the accent) and "classic" (the previous violet). Settings -> Appearance theme cards switch live:
  app.restyle() rebuilds the shell and drops cached views (setting ui.theme)
- Theme colours are read when building: default arguments no longer capture tokens, module-level colour maps refill
  through T.on_change; tests/test_theme.py checks both
- ui_smoke.py --themes renders the picker and every theme after a live switch
2026-10-07 20:00:30 -04:00
37 changed files with 942 additions and 147 deletions

No files matched your search

+13 -1
View File
@@ -10,7 +10,19 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
## Layout
- `src/frameport/` — Python package. `pipeline.py` is the API the CLI (`cli.py`) and GUI (`ui/`, Flet 1.0) share.
- `ui/` — `app.py` shell (sidebar with Frame connection + activity cards, routing, actions, 30 s connection poll),
`theme.py` (dark design tokens; change colours/spacing only there), `components.py` (pill, card, callout,
`theme.py` (design tokens; change colours/spacing only there. Dark-only colour themes (owner: no light mode):
built-ins `portal` (default), `portal_oled` (true black) and `original` (the old violet; alias `classic`). Portal =
the logo's dichotomy: orange ACCENT = main action + selection, blue SECONDARY = secondary (outlined) buttons,
progress, switch tracks, PC; `dual` themes add the blue→orange sidebar edge, two-colour wordmark (`C.wordmark`) and
selected-tab fade (`C.portal_gradient`). Installable theme files `<data>/themes/*.json` (docs/THEMES.md: name,
base, dual, any subset of `T.TOKENS`; refused if light/unreadable, `T.ThemeError`), Settings → Appearance: cards
switch live via `app.restyle()` (rebuilds the shell, drops cached views; setting `ui.theme`), "Install theme
file…", "Copy this theme as a file", bin icon removes. Read tokens as `T.NAME` when building — never as a default argument or module-level constant (they
keep the import-time theme; module maps refill through `T.on_change`, `tests/test_theme.py` checks defaults)),
`glyphs.py` + `icons/` (the logo `logo.svg`/`logo-solid.svg` = a monitor that comes out of a tilted portal as the
Frame, and 24 px line glyphs `fp:frame|port|patch|container|test|pc|sync|live|shot|keys|recipe|link`, copied into
the assets dir and shown as tinted `ft.Image`s: pass `G.FRAME` … wherever an icon goes, `C.as_icon` / the button
helpers handle both kinds; `src/assets/icon.png` = the bundles' app icon), `components.py` (pill, card, callout,
status_row, art_fill, confirm, `update()` = safe update: in Flet 1.0 reading `.page` of an unmounted control
raises), `jobs.py` (background FIFO job queue, one at a time, cancel via Reporter; no Flet), `views/`
(library: search/filters/tags/sort as pure tested helpers; game: hero + one-click install, patches under
+61
View File
@@ -0,0 +1,61 @@
# Themes
FramePort comes with three colour themes, all dark:
| Theme | Look |
|---|---|
| **Portal** (default) | The logo's two portals: orange for actions and the current selection, blue for secondary actions, progress and the PC side. |
| **Portal (OLED)** | Portal on true black, for OLED screens. |
| **Original** | FramePort's first look, with a violet accent. |
Switch in **Settings → Appearance**. The change applies at once.
## Your own theme
A theme is a small JSON file. To make one:
1. In **Settings → Appearance**, pick the theme closest to what you want and click **Copy this theme as a file**.
2. Paste it into a text editor and save it as `something.json`.
3. Change the name and the colours you want different. You can delete every colour you keep: missing colours come
from the `base` theme.
4. Back in **Settings → Appearance**, click **Install theme file…** and choose the file. FramePort checks it,
switches to it and keeps a copy in its data folder (`themes/`), so you can delete the original.
To remove an installed theme, click the bin icon on its card.
### Format
```json
{
"name": "Ember & Ice",
"base": "portal",
"dual": true,
"colors": {
"ACCENT": "#FF5A36",
"SECONDARY": "#5CE1E6"
}
}
```
| Field | Meaning |
|---|---|
| `name` | Shown on the theme's card (up to 40 characters). |
| `base` | The built-in theme that fills in every colour the file leaves out: `portal` (default), `portal_oled` or `original`. |
| `dual` | `true`: two-colour touches like Portal's (blue-to-orange sidebar edge, two-colour "FramePort", the selected tab's fade, blue secondary buttons). `false`: one accent, like Original. Default: the base's. |
| `colors` | Any of the colours below, as `#RRGGBB` or `#RGB`. Names may be upper or lower case. |
| Colour | Used for |
|---|---|
| `BG` | Window background |
| `SIDEBAR` | Sidebar and activity panel |
| `SURFACE`, `SURFACE_2`, `SURFACE_3` | Cards; raised and hovered parts; inputs and chips |
| `BORDER`, `BORDER_STRONG` | Outlines |
| `TEXT`, `TEXT_2`, `TEXT_3` | Main text; secondary text; captions and disabled text |
| `ACCENT`, `ACCENT_SOFT`, `ON_ACCENT` | Main buttons and the current selection; its tint; text on it |
| `SECONDARY`, `SECONDARY_SOFT` | Secondary buttons, progress, switch tracks; its tint |
| `OK`, `WARN`, `ERROR`, `INFO` | Status: works, needs attention, failed, information |
| `PC` | PC VR games and "on this PC" |
FramePort refuses a theme file it can't show well, and says why: a light window background (FramePort is dark only),
text that's hard to read on cards, an unknown colour name or a value that isn't a colour. An example is in
[`themes/example-theme.json`](themes/example-theme.json).
+13
View File
@@ -0,0 +1,13 @@
{
"name": "Ember & Ice",
"base": "portal",
"dual": true,
"colors": {
"ACCENT": "#FF5A36",
"ACCENT_SOFT": "#3A1710",
"SECONDARY": "#5CE1E6",
"SECONDARY_SOFT": "#0E3033",
"PC": "#5CE1E6",
"INFO": "#5CE1E6"
}
}
+3
View File
@@ -69,6 +69,9 @@ def main() -> int:
"--add-data", f"{DATA}{sep}frameport/_data",
# translations (data files PyInstaller doesn't pick up from the imports)
"--add-data", f"{ROOT / 'src/frameport/locales'}{sep}frameport/locales",
# the logo and the GUI's own glyphs (SVG)
"--add-data", f"{ROOT / 'src/frameport/ui/icons'}{sep}frameport/ui/icons",
"--icon", str(ROOT / "src/assets/icon.png"),
"--distpath", str(ROOT / "dist"), "--yes"]
if TARGET == "linux":
cmd.append("--onedir") # a folder: the bundle check sees the agent source, the archive the app
+44 -1
View File
@@ -382,6 +382,8 @@ def main() -> int:
ap.add_argument("--links", action="store_true", help="install links: the Add games menu, the paste dialog, the "
"confirmation for a pretend FrameDrop manifest, Settings → Install links and (with --game) the "
"VR / flat window choice")
ap.add_argument("--themes", action="store_true", help="Settings' theme picker, then switch every theme while "
"running (Library and --game in each)")
args = ap.parse_args()
if args.linux:
add_fake_linux_apps()
@@ -517,6 +519,34 @@ def main() -> int:
page.screenshot(path=str(args.out / "library-add-menu.png"))
page.mouse.click(120, 520) # outside the menu (Flutter's popup ignores Escape)
mouse["library"] = add_menu_open # tall pages: run with e.g. --viewport 1280x3200
if args.themes:
from frameport.ui import theme as T
from frameport.ui.app import themes_dir
T.load_user_themes(themes_dir()) # installed theme files are switched through too
def switch(name):
def run(a):
library.set_setting("ui.theme", name) # what the picker does
a.restyle(name)
return run
def appearance(a):
"""Settings' Appearance section on its own (it is near the end of the page)."""
from frameport.ui import components as C
from frameport.ui.views.settings import SettingsView
a.go("settings")
a.body.content = ft.Column([a.top_bar("Settings", "Appearance"),
C.section("Appearance", C.card(SettingsView(a).appearance()))])
a.page.update()
steps = [("appearance", appearance)]
for name in [n for n in T.THEMES if n != T.THEME] + [T.THEME]:
steps.append((f"{name}-switched", switch(name)))
steps.append((f"{name}-appearance", appearance))
steps.append((f"{name}-library", lambda a: a.navigate(0)))
if game:
steps.append((f"{name}-game", lambda a: a.open_game(game)))
if args.usb_setup:
def continue_usb(a):
dialog = [d for d in a.page._dialogs.controls if d.open and type(d).__name__ == "AlertDialog"][-1]
@@ -577,6 +607,10 @@ def main() -> int:
if args.only: # in the order given (e.g. frame-connected,monitor)
by_name = dict(steps)
steps = [(n, by_name[n]) for n in args.only.split(",") if n in by_name]
# a first screen while the web fonts load: shot before that, text measured with a fallback font wrapped or
# overlapped ("Q / uest", a check mark over its pill's text); its picture is 00-warmup.png. Settings, not the
# Library: opening the Library twice restarts its card loading and the next shot caught it empty
steps.insert(0, ("warmup", lambda a: a.go("settings")))
ready, done = threading.Event(), []
if args.fake_frame: # never reach a real Frame (start-up auto-connect, discovery, the 30 s poll)
@@ -600,9 +634,18 @@ def main() -> int:
threading.Thread(target=driver, args=(app, steps, ready, done), daemon=True).start()
def shooter():
# wait until the app's web server listens (a fixed 6 s was too short with another smoke run on the machine)
import socket
from playwright.sync_api import sync_playwright
time.sleep(6)
for _ in range(90):
try:
socket.create_connection(("127.0.0.1", PORT), timeout=1).close()
break
except OSError:
time.sleep(1)
time.sleep(2)
try:
with sync_playwright() as p:
browser = p.chromium.launch(args=["--use-gl=swiftshader", "--enable-unsafe-swiftshader"])
Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

+14
View File
@@ -235,6 +235,7 @@
"Controllers": "",
"Converts Quest games to OpenXR": "",
"Copied": "",
"Copied: save it as a .json file, change the colours and install it": "",
"Copy": "",
"Copy all": "",
"Copy failed; select the text instead": "",
@@ -245,6 +246,7 @@
"Copy OVRPort libraries": "",
"Copy path": "",
"Copy PID {pid}": "",
"Copy this theme as a file": "",
"Couldn't check this PC": "",
"Couldn't connect: {exc}": "",
"Couldn't list the screenshots: {exc}": "",
@@ -254,6 +256,7 @@
"Couldn't read the file list: {exc}": "",
"Couldn't read the Frame's folders: {exc}": "",
"Couldn't read {path}: {exc}": "",
"Couldn't remove the theme: {error}": "",
"Couldn't start the live view: {exc}": "",
"Couldn't use {choice}: {exc}": "",
"Counts the headset as worn, so games that only move the player while it's worn get head tracking and controls (e.g. BONELAB).": "",
@@ -455,6 +458,7 @@
"FramePort {version} is the latest version": "",
"FramePort {version} · click to install": "",
"FramePort {version} · {kind}": "",
"FramePort's original violet": "",
"FramePort's tools, library, artwork and the signing keys of your rebuilt games. Back it up: an update must be signed with the same key, or the game (and its saves) has to be reinstalled.": "",
"FramePort's translator between the game and the Steam Frame: fills in what the Frame lacks (controllers, room, passthrough). Needed for every converted game.": "",
"Frames in Developer Mode show up here.": "",
@@ -555,6 +559,7 @@
"Install SteamVR from Steam to play PC VR games": "",
"Install the alternate build (": "",
"Install the latest dev build…": "",
"Install theme file…": "",
"Install updates automatically (downloads in the background, installs when FramePort next starts)": "",
"Install {len} on this PC": "",
"Install {name}?": "",
@@ -563,6 +568,8 @@
"Install {title} on {value}": "",
"Installed": "",
"Installed games ({len})": "",
"Installed the theme \"{name}\"": "",
"Installed theme file · {file}": "",
"Installed {join}": "",
"Installed · launch test failed": "",
"Installed, but it didn't start properly: ": "",
@@ -788,6 +795,7 @@
"OR": "",
"Or paste text to type it in one go": "",
"Or turn on Developer Mode on the Frame (Settings → System → Developer): it then shows up under step 2. Open Settings → Developer → Pair new host on the Frame, click Connect there and approve FramePort. That way needs no connection into this computer.": "",
"Orange for actions, blue for the PC side: the logo's two portals": "",
"Other": "",
"other file": "",
"Override the guardian depth (0 = use guardian, min 1.5 m).": "",
@@ -845,6 +853,7 @@
"Pointer start": "",
"Pointer turn": "",
"Points Oculus games at Revive on the Frame.": "",
"Portal on true black, for OLED screens": "",
"Power": "",
"Power ICs": "",
"Prepare {title}": "",
@@ -903,12 +912,14 @@
"Remove from this PC's Steam library": "",
"Remove localized app names": "",
"Remove tag": "",
"Remove this theme": "",
"Remove Unreal's ForceQuit": "",
"Remove uses-library": "",
"Remove VrApi library": "",
"Remove XR_KHR_composition_layer_depth chains (diagnostic).": "",
"Remove {title} from the library?": "",
"Removed the converted copies ({size})": "",
"Removed the theme \"{name}\"": "",
"Removed {title}": "",
"Removed {title} and deleted its files on this PC ({size})": "",
"Removes a controller position adjustment, for games whose hands look misplaced.": "",
@@ -1127,6 +1138,7 @@
"Test": "",
"Test Proton on the Frame": "",
"Text and layout size": "",
"That theme file can't be used: {error}": "",
"The APK file is damaged or incomplete. Get the game again and rescan the folder.": "",
"The command fetches a small setup script from this app over your local network. It turns on Developer Mode (which includes SSH), lets this app's key in and installs Lepton if needed. No password needed.": "",
"The connection to the Frame is busy. Try again in a moment.": "",
@@ -1175,6 +1187,8 @@
"The time the Frame spends collecting these numbers": "",
"The upload to the Frame was damaged on the way. Try again (a cable or the Frame's hotspot is more reliable than a busy Wi-Fi).": "",
"The VR runtime games talk to. On the Frame that's SteamVR.": "",
"Theme": "",
"Theme file to install": "",
"These games can't run on the Steam Frame": "",
"These games check their Oculus license": "",
"These installs need about {need} on the Frame, and it has {free} free. Remove games you don't play (or use Free up space on the Steam Frame page), or install fewer at once.": "",
+84 -41
View File
@@ -23,17 +23,18 @@ from ..frame.connection import NOT_IN_LIBRARY, AgentFailed
from ..i18n import fmt_size, tr, tr_n
from ..recommend import catalog
from . import components as C
from . import glyphs as G
from . import jobs as jobs_module
from . import theme as T
from .components import install_state # noqa: F401 (re-exported: tests and older callers import it from here)
from .jobs import Job
NAV = [("library", tr("Library"), ft.Icons.GRID_VIEW_ROUNDED),
("frame", tr("Steam Frame"), ft.Icons.VIEW_IN_AR_ROUNDED),
("frame", tr("Steam Frame"), G.FRAME),
("files", tr("Files"), ft.Icons.FOLDER_OPEN_ROUNDED),
("screenshots", tr("Screenshots"), ft.Icons.PHOTO_LIBRARY_OUTLINED),
("live", tr("Live view"), ft.Icons.CAST_ROUNDED),
("keyboard", tr("Type on Frame"), ft.Icons.KEYBOARD_ROUNDED),
("screenshots", tr("Screenshots"), G.SHOT),
("live", tr("Live view"), G.LIVE),
("keyboard", tr("Type on Frame"), G.KEYS),
("monitor", tr("Monitor"), ft.Icons.MONITOR_HEART_OUTLINED),
("settings", tr("Settings"), ft.Icons.TUNE_ROUNDED)]
POLL_SECONDS = 30
@@ -81,7 +82,6 @@ def window_geometry(saved: dict | None, scale: float) -> dict:
class FramePortApp:
def __init__(self, page: ft.Page):
from .views.activity import ActivityPanel
from .views.library import load_filters
self.page = page
@@ -115,6 +115,10 @@ class FramePortApp:
self.updater = Updater(self) # new FramePort releases (sidebar card, Library bar, one-click update)
page.title = tr("FramePort")
for problem in T.load_user_themes(themes_dir()): # installed theme files (Settings → Appearance)
applog.log.warning("theme file skipped: %s", problem)
T.set_theme(T.theme_from_setting(library.setting("ui.theme")))
G.install(Path(assets_dir())) # the logo + FramePort's icons, served like artwork
T.apply(page)
page.padding = 0
page.window.min_width, page.window.min_height = 1000, 680
@@ -131,29 +135,7 @@ class FramePortApp:
page.run_task(ft.BrowserContextMenu().disable)
except Exception: # noqa: BLE001
pass
self.body = ft.Container(expand=True, padding=ft.Padding(T.S6, T.S5, T.S5, 0))
self.nav_col = ft.Column(spacing=T.px(2))
self.conn_card = ft.Container()
self.power_row = ft.Container(visible=False) # the Frame's power button (sleep / restart / shut down)
self.activity_card = ft.Container()
self.activity = ActivityPanel(self)
sidebar = ft.Container(ft.Column([
ft.Container(ft.Row([
ft.Container(ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=T.px(18), color=T.ON_ACCENT),
width=T.px(32), height=T.px(32), border_radius=T.px(9), bgcolor=T.ACCENT,
alignment=ft.Alignment.CENTER),
ft.Text(tr("FramePort"), size=T.px(17), weight=ft.FontWeight.W_800, color=T.TEXT)], spacing=T.S3),
padding=ft.Padding(T.S2, T.S2, 0, T.S5)),
self.nav_col,
ft.Container(expand=True),
self.updater.card,
self.activity_card,
self.conn_card,
self.power_row, # under the Frame card (owner's choice)
], spacing=T.S2), width=T.px(236), bgcolor=T.SIDEBAR, padding=T.S4,
border=ft.Border(right=ft.BorderSide(1, T.BORDER)))
page.add(ft.Row([sidebar, self.body, self.activity.root], expand=True, spacing=0,
vertical_alignment=ft.CrossAxisAlignment.STRETCH))
self._build_shell()
from .views.welcome import needed
self.go("welcome" if needed() else "library")
@@ -174,6 +156,56 @@ class FramePortApp:
self.run_bg(urlhandler.apply_setting) # framedrop:// + frameport:// → FramePort (Settings → General)
# ================================================================== shell
def _build_shell(self) -> None:
"""The window: sidebar (logo, navigation, update/activity/Frame cards), the view area and the activity panel.
Built again by restyle() with the new theme's colours."""
from .views.activity import ActivityPanel
self.body = ft.Container(expand=True, padding=ft.Padding(T.S6, T.S5, T.S5, 0))
self.nav_col = ft.Column(spacing=T.px(2))
self.conn_card = ft.Container()
self.power_row = ft.Container(visible=False) # the Frame's power button (sleep / restart / shut down)
self.activity_card = ft.Container()
self.activity = ActivityPanel(self)
sidebar = ft.Container(ft.Column([
ft.Container(ft.Row([
C.logo(T.px(34)),
C.wordmark(T.px(17))], spacing=T.S3),
padding=ft.Padding(T.S1, T.S2, 0, T.S5)),
self.nav_col,
ft.Container(expand=True),
self.updater.card,
self.activity_card,
self.conn_card,
self.power_row, # under the Frame card (owner's choice)
], spacing=T.S2), width=T.px(236), bgcolor=T.SIDEBAR, padding=T.S4,
border=None if T.DUAL else ft.Border(right=ft.BorderSide(1, T.BORDER)))
# dual themes: the sidebar's edge is the portal, blue at the top into orange at the bottom
edge = [ft.Container(width=2, gradient=C.portal_gradient(vertical=True, opacity=0.7))] if T.DUAL else []
self.page.controls = [ft.Row([sidebar, *edge, self.body, self.activity.root], expand=True, spacing=0,
vertical_alignment=ft.CrossAxisAlignment.STRETCH)]
def restyle(self, theme: str) -> None:
"""Switch the colour theme while running (Settings → Appearance): new tokens, the window rebuilt, the views
FramePort keeps between visits recreated (the files/screenshots views start at their first location again;
a running live view keeps its stream and only redraws)."""
T.set_theme(theme)
T.apply(self.page)
open_activity = self.activity.open
self.updater.build_card()
self._build_shell()
if hasattr(self, "_nav"):
del self._nav # _refresh_sidebar builds the sidebar's controls again
self.stop_monitor() # its stream runs only while the tab is shown (a theme switch happens in Settings)
self.library_view = self.files_view = self.screenshots_view = self.monitor_view = None
if self.keyboard_view is not None and self.keyboard_view.stopped:
self.keyboard_view = None
for view in (self.live_view, self.keyboard_view):
if view is not None:
view.root = None # rebuilt on its next visit
self.activity.set_open(open_activity)
self.render()
def _on_window_event(self, e) -> None:
"""Remember the window's size and position (once a resize/move ends; not while maximized or full screen) and
whether it is maximized."""
@@ -213,7 +245,7 @@ class FramePortApp:
reports progress (several times a second) swallowed clicks — the pressed control was gone on release."""
nav = {}
for key, label, icon in NAV:
ic = ft.Icon(icon, size=T.px(20), color=T.TEXT_2)
ic = C.as_icon(icon, T.px(20), T.TEXT_2)
tx = ft.Text(label, size=T.px(14), weight=ft.FontWeight.W_500, color=T.TEXT_2, expand=True)
badge = C.dot(T.OK, 7)
badge.visible = False
@@ -252,7 +284,7 @@ class FramePortApp:
vertical_alignment=ft.CrossAxisAlignment.CENTER), visible=False)
self._conn_extra = ft.Container(C.meta(""), visible=False, tooltip=C.tip(C.HELP["frame_summary"]))
self.conn_card.content = ft.Container(ft.Row([
ft.Stack([ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=T.px(22), color=T.TEXT_2),
ft.Stack([C.as_icon(G.FRAME, T.px(22), T.TEXT_2),
ft.Container(self._conn_dot, right=0, bottom=0)], width=T.px(24), height=T.px(24)),
ft.Column([ft.Row([ft.Container(self._conn_name, expand=True), self._conn_bat],
spacing=T.px(6), vertical_alignment=ft.CrossAxisAlignment.CENTER),
@@ -284,8 +316,12 @@ class FramePortApp:
self._build_sidebar_controls()
for key, (box, ic, tx, badge) in self._nav.items():
on = self.route[0] == key or (key == "library" and self.route[0] == "game")
box.bgcolor = T.ACCENT_SOFT if on else None
ic.color = T.ACCENT if on else T.TEXT_2
if T.DUAL: # the selected tab fades from the blue portal into the orange one
box.bgcolor, box.gradient = None, C.portal_gradient(opacity=0.22) if on else None
else:
box.bgcolor = T.ACCENT_SOFT if on else None
# dual themes: the fade marks the tab; an orange icon on it clashed (owner) -> white like the label
ic.color = (T.TEXT if T.DUAL else T.ACCENT) if on else T.TEXT_2
tx.color = T.TEXT if on else T.TEXT_2
tx.weight = ft.FontWeight.W_600 if on else ft.FontWeight.W_500
badge.visible = key == "frame" and self.frame_state == "connected"
@@ -632,7 +668,7 @@ class FramePortApp:
"outdated": tr("Update anyway")}.get(st, tr("Install anyway"))
return [(frame_label, ft.Icons.WARNING_AMBER_ROUNDED, lambda e: self.install_blocked(pkg), False,
tr("Marked \"Can't run\": ") + ((g.get("recipe") or {}).get("notes") or tr("a known blocker")))]
frame_opt = (frame_label, ft.Icons.VIEW_IN_AR_ROUNDED, lambda e: self.install(pkg, "frame"), False, None) \
frame_opt = (frame_label, G.FRAME, lambda e: self.install(pkg, "frame"), False, None) \
if connected else (tr("Connect your Frame"), ft.Icons.LINK_ROUNDED, lambda e: self.go("frame"), False,
tr("Set up the connection to your Steam Frame first"))
if g.get("kind") != "rift":
@@ -644,7 +680,7 @@ class FramePortApp:
pc_label = (tr("Update on this PC") if stale else tr("Reinstall on this PC") if on_pc
else tr("Install on this PC"))
pc_opt = (pc_label,
ft.Icons.COMPUTER_ROUNDED, lambda e: self.install(pkg, "pc"), not winhost.available(),
G.PC, lambda e: self.install(pkg, "pc"), not winhost.available(),
tr("The launch settings changed since it was installed: update the Steam shortcut") if stale else
None if winhost.available() else tr("Needs Windows (or WSL on Windows)"))
return [frame_opt, pc_opt] if connected or not winhost.available() else [pc_opt, frame_opt]
@@ -746,7 +782,7 @@ class FramePortApp:
opts = [o for o in self.install_options(g) if o[2] and not o[3]]
if not opts or opts[0][1] == ft.Icons.LINK_ROUNDED: # "Connect your Frame" isn't a quick action
return None, None
if opts[0][1] == ft.Icons.COMPUTER_ROUNDED: # installing on this PC comes first
if opts[0][1] == G.PC: # installing on this PC comes first
dep = self.pc_installs().get(pkg)
kind = "update" if dep and C.pc_outdated(g, dep) else "reinstall" if dep else "install"
else:
@@ -781,10 +817,10 @@ class FramePortApp:
C.install_state(g, self.frame_info) in ("installed", "outdated")
on_pc = rift and pkg in self.pc_installs()
if on_frame:
out.append((tr("Launch test on Frame"), ft.Icons.SCIENCE_OUTLINED,
out.append((tr("Launch test on Frame"), G.TEST,
lambda e: self.test_game(pkg, "frame")))
if on_pc:
out.append((tr("Launch test on this PC"), ft.Icons.SCIENCE_OUTLINED,
out.append((tr("Launch test on this PC"), G.TEST,
lambda e: self.test_game(pkg, "pc")))
if self.has_game_settings(g):
out.append((tr("Game settings…"), ft.Icons.TUNE_ROUNDED, lambda e: self.settings_dialog(pkg)))
@@ -792,7 +828,7 @@ class FramePortApp:
out.append((tr("Add videos and files…"), ft.Icons.VIDEO_LIBRARY_OUTLINED,
lambda e: self.go("files", pkg)))
if on_frame:
out.append((tr("Type on Frame"), ft.Icons.KEYBOARD_ROUNDED, lambda e: self.type_on_frame()))
out.append((tr("Type on Frame"), G.KEYS, lambda e: self.type_on_frame()))
if not rift and not linux:
out.append((tr("Analyze again"), ft.Icons.MANAGE_SEARCH_ROUNDED, lambda e: self.reanalyze(pkg)))
if on_frame:
@@ -811,7 +847,7 @@ class FramePortApp:
if linux and self.linux_programs(g):
out.append((tr("Change program…"), ft.Icons.TERMINAL_ROUNDED, lambda e: self.choose_exe(pkg)))
if self.frame_state == "connected":
out.append((tr("Screenshots"), ft.Icons.PHOTO_LIBRARY_OUTLINED, lambda e: self.go("screenshots", pkg)))
out.append((tr("Screenshots"), G.SHOT, lambda e: self.go("screenshots", pkg)))
out.append((tr("Find artwork…"), ft.Icons.IMAGE_SEARCH_ROUNDED, lambda e: self.find_artwork(pkg)))
out.append((tr("Use your own artwork…"), ft.Icons.UPLOAD_FILE_ROUNDED, lambda e: self.custom_artwork(pkg)))
if not job and self.frame_state == "connected" and \
@@ -820,12 +856,12 @@ class FramePortApp:
lambda e: self.update_steam_art(pkg)))
out.append((tr("Refresh store details"), ft.Icons.SYNC_ROUNDED, lambda e: self.refresh_details(pkg)))
if not job and not linux:
out.append((tr("Rebuild only (no install)") if not rift else tr("Check game files"), ft.Icons.BUILD_ROUNDED,
out.append((tr("Rebuild only (no install)") if not rift else tr("Check game files"), G.PORT,
lambda e: self.build_game(pkg)))
if not linux:
out += [(tr("Reset to suggested recipe"), ft.Icons.RESTART_ALT_ROUNDED,
lambda e: (pipeline.reset_recipe(pkg), self.toast(tr("Recipe reset")), self.refresh_view())),
(tr("Save as known-good recipe"), ft.Icons.VERIFIED_ROUNDED, lambda e: self.save_known_good(pkg)),
(tr("Save as known-good recipe"), G.RECIPE, lambda e: self.save_known_good(pkg)),
(tr("Share working config…"), ft.Icons.SHARE_ROUNDED, lambda e: self.share_config_dialog(pkg))]
out += [(tr("Collect logs"), ft.Icons.FOLDER_ZIP_OUTLINED, lambda e: self.collect_logs(pkg)),
(tr("Report a problem…"), ft.Icons.BUG_REPORT_OUTLINED, lambda e: self.report_problem_dialog(pkg)),
@@ -2156,6 +2192,13 @@ class FramePortApp:
return self.build_game(package)
def themes_dir() -> Path:
"""Where installed theme files live (<data>/themes, docs/THEMES.md)."""
from ..core.paths import user_data_dir
return user_data_dir() / "themes"
def assets_dir() -> str:
"""The GUI's assets folder is the user data dir, so artwork thumbnails load by URL (/artwork/<pkg>/…)."""
from ..core.paths import user_data_dir
+122 -41
View File
@@ -7,22 +7,33 @@ from types import SimpleNamespace
import flet as ft
from ..i18n import tr
from . import glyphs
from . import glyphs as G # G.NAME at call sites, like the other modules
from . import theme as T
from .help import HELP
STATUS_STYLE = {
"works": (tr("Works"), T.OK),
"issues": (tr("Works with issues"), T.WARN),
"unsupported": (tr("Can't run"), T.ERROR),
"unknown": (tr("Untested"), T.TEXT_3),
}
STATUS_STYLE: dict[str, tuple] = {}
# per-game state on a target: label, icon, color
INSTALL_STYLE = {
"installed": (tr("On Frame"), ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK),
"outdated": (tr("Update ready"), ft.Icons.UPDATE_ROUNDED, T.WARN),
"missing": (tr("Not installed"), ft.Icons.CLOUD_OFF_ROUNDED, T.TEXT_3),
"on_pc": (tr("On this PC"), ft.Icons.COMPUTER_ROUNDED, T.PC),
}
INSTALL_STYLE: dict[str, tuple] = {}
def _fill_styles() -> None:
"""(Re)fill the style maps with the active theme's colours, in place: callers keep a reference to the dicts."""
STATUS_STYLE.update({
"works": (tr("Works"), T.OK),
"issues": (tr("Works with issues"), T.WARN),
"unsupported": (tr("Can't run"), T.ERROR),
"unknown": (tr("Untested"), T.TEXT_3),
})
INSTALL_STYLE.update({
"installed": (tr("On Frame"), ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK),
"outdated": (tr("Update ready"), ft.Icons.UPDATE_ROUNDED, T.WARN),
"missing": (tr("Not installed"), ft.Icons.CLOUD_OFF_ROUNDED, T.TEXT_3),
"on_pc": (tr("On this PC"), G.PC, T.PC),
})
T.on_change(_fill_styles)
def _deployment(game: dict, frame_info: dict | None) -> dict | None:
@@ -63,8 +74,8 @@ def platform(g: dict) -> tuple[str, str, str]:
def platform_icon(g: dict) -> str:
"""The icon for a game's platform (card badge, artwork placeholder)."""
return ft.Icons.COMPUTER_ROUNDED if g.get("kind") == "rift" else \
ft.Icons.TERMINAL_ROUNDED if g.get("kind") == "linux" else ft.Icons.VIEW_IN_AR_ROUNDED
return G.PC if g.get("kind") == "rift" else \
ft.Icons.TERMINAL_ROUNDED if g.get("kind") == "linux" else G.FRAME
def missing_libraries(game: dict, frame_info: dict | None) -> list[str]:
@@ -162,6 +173,8 @@ def switch(label: str = "", wrap: bool = True, **kw) -> ft.Control:
Switch's own label is cut off when it's longer than the window); clicking it toggles the switch too.
wrap=False keeps the built-in label, for a short label in a Row next to other controls."""
kw.setdefault("active_color", T.ACCENT)
if T.DUAL: # orange thumb on a blue track
kw.setdefault("active_track_color", T.soft(T.SECONDARY, 0.5))
if not wrap or not label:
return ft.Switch(label=label or None, label_text_style=ft.TextStyle(color=T.TEXT, size=T.px(14)), **kw)
sw = ft.Switch(**kw)
@@ -177,21 +190,22 @@ def switch(label: str = "", wrap: bool = True, **kw) -> ft.Control:
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER, data="switch")
def body(text: str, color: str = T.TEXT_2, size: int | None = None, **kw) -> ft.Text:
size = size or T.T_BODY
def body(text: str, color: str | None = None, size: int | None = None, **kw) -> ft.Text:
size, color = size or T.T_BODY, color or T.TEXT_2
return ft.Text(text, size=size, color=color, **kw)
def meta(text: str, color: str = T.TEXT_3, **kw) -> ft.Text:
return ft.Text(text, size=T.T_META, color=color, **kw)
def meta(text: str, color: str | None = None, **kw) -> ft.Text:
return ft.Text(text, size=T.T_META, color=color or T.TEXT_3, **kw)
# ------------------------------------------------------------------------------------------ chips / badges
def pill(text: str, color: str = T.TEXT_2, icon: str | None = None, solid: bool = False,
def pill(text: str, color: str | None = None, icon: str | None = None, solid: bool = False,
tooltip: str | ft.Tooltip | None = None, overlay: bool = False) -> ft.Container:
"""overlay: for use on top of artwork (dark translucent backing so it reads on any image)."""
color = color or T.TEXT_2
fg = T.ON_ACCENT if solid else color
items = ([ft.Icon(icon, size=T.px(13), color=fg)] if icon else []) + \
items = ([as_icon(icon, T.px(13), fg)] if icon else []) + \
[ft.Text(text, size=T.T_SMALL, color=fg, weight=ft.FontWeight.W_600)]
return ft.Container(ft.Row(items, spacing=T.px(4), tight=True), tooltip=tooltip,
bgcolor=color if solid else T.soft("#000000", 0.62) if overlay else T.soft(color, 0.12),
@@ -242,6 +256,60 @@ def with_help(control: ft.Control, key: str | None) -> ft.Control:
vertical_alignment=ft.CrossAxisAlignment.CENTER)
# ------------------------------------------------------------------------------------------ icons
def _asset_src(name: str) -> str:
from ..artwork.thumbs import asset_url
from ..core.paths import user_data_dir
return asset_url(glyphs.path(name, user_data_dir()))
def as_icon(name, size: float | None = None, color: str | None = None) -> ft.Control:
"""A Material icon (ft.Icons.…) or one of FramePort's glyphs (glyphs.FRAME …, tinted like an icon). Its colour can
be changed later through .color either way."""
size, color = size or T.px(20), color or T.TEXT_2
if glyphs.is_glyph(name):
return ft.Image(src=_asset_src(name), width=size, height=size, color=color,
color_blend_mode=ft.BlendMode.SRC_IN, fit=ft.BoxFit.CONTAIN)
return ft.Icon(name, size=size, color=color)
def logo(size: float, solid: bool = False) -> ft.Image:
"""FramePort's logo (the monitor that comes out of a portal as the Frame); solid = the flat version for small
sizes."""
return ft.Image(src=_asset_src("logo-solid" if solid else "logo"), width=size, height=size, fit=ft.BoxFit.CONTAIN)
def _btn_icon(name, color: str, disabled: bool = False, size: float = 18):
"""A button's or menu item's icon: Material icons stay as they are (the control colours them), glyphs become
tinted images at the size Flutter gives icons there (buttons 18, menu items 24; not scaled with the UI, like
the Material icons next to them)."""
return as_icon(name, size, T.TEXT_3 if disabled else color) if glyphs.is_glyph(name) else name
def wordmark(size: float) -> ft.Control:
""""FramePort": in dual themes "Frame" in the Frame's orange, a portal, then "Port" in the PC's blue."""
if not T.DUAL:
return ft.Text("FramePort", size=size, weight=ft.FontWeight.W_800, color=T.TEXT)
from ..artwork.thumbs import asset_url
from ..core.paths import user_data_dir
mark = glyphs.portal_mark(user_data_dir(), T.ACCENT, T.SECONDARY, T.BG)
word = lambda text, color: ft.Text(text, size=size, weight=ft.FontWeight.W_800, color=color) # noqa: E731
return ft.Row([word("Frame", T.ACCENT),
ft.Image(src=asset_url(mark), width=size * 0.8, height=size * 1.4, fit=ft.BoxFit.CONTAIN),
word("Port", T.SECONDARY)],
spacing=size * 0.08, tight=True, vertical_alignment=ft.CrossAxisAlignment.CENTER)
def portal_gradient(vertical: bool = False, opacity: float = 1.0) -> ft.LinearGradient:
"""Blue (the PC's portal) into orange (the Frame's), as in the logo."""
begin, end = (ft.Alignment.TOP_CENTER, ft.Alignment.BOTTOM_CENTER) if vertical else \
(ft.Alignment.CENTER_LEFT, ft.Alignment.CENTER_RIGHT)
return ft.LinearGradient(begin=begin, end=end,
colors=[T.soft(T.SECONDARY, opacity), T.soft(T.ACCENT, opacity)])
# ------------------------------------------------------------------------------------------ buttons
def _shape(radius=None):
radius = T.RADIUS_SM if radius is None else radius
@@ -252,7 +320,8 @@ def primary(label: str, icon: str | None = None, on_click: Callable | None = Non
tooltip: str | None = None, big: bool = False) -> ft.FilledButton:
padding = (ft.Padding(T.px(22), T.px(18), T.px(22), T.px(18)) if big
else ft.Padding(T.px(16), T.px(12), T.px(16), T.px(12)))
return ft.FilledButton(label, icon=icon, on_click=on_click, disabled=disabled, tooltip=tooltip,
return ft.FilledButton(label, icon=_btn_icon(icon, T.ON_ACCENT, disabled), on_click=on_click, disabled=disabled,
tooltip=tooltip,
style=ft.ButtonStyle(shape=_shape(), bgcolor={ft.ControlState.DEFAULT: T.ACCENT,
ft.ControlState.DISABLED: T.SURFACE_3},
color={ft.ControlState.DEFAULT: T.ON_ACCENT,
@@ -264,30 +333,38 @@ def primary(label: str, icon: str | None = None, on_click: Callable | None = Non
def secondary(label: str, icon: str | None = None, on_click: Callable | None = None, disabled: bool = False,
tooltip: str | None = None) -> ft.OutlinedButton:
return ft.OutlinedButton(label, icon=icon, on_click=on_click, disabled=disabled, tooltip=tooltip,
style=ft.ButtonStyle(shape=_shape(), side=ft.BorderSide(1, T.BORDER_STRONG),
# dual themes: secondary actions are the blue portal next to the orange primary one
edge, mark = (T.soft(T.SECONDARY, 0.55), T.SECONDARY) if T.DUAL else (T.BORDER_STRONG, T.TEXT)
return ft.OutlinedButton(label, icon=_btn_icon(icon, mark, disabled), on_click=on_click, disabled=disabled,
tooltip=tooltip,
style=ft.ButtonStyle(shape=_shape(), side=ft.BorderSide(1, edge),
color={ft.ControlState.DEFAULT: T.TEXT,
ft.ControlState.DISABLED: T.TEXT_3},
icon_color={ft.ControlState.DEFAULT: mark,
ft.ControlState.DISABLED: T.TEXT_3},
overlay_color=T.soft(T.SECONDARY, 0.08) if T.DUAL else None,
padding=ft.Padding(T.px(14), T.px(12), T.px(14), T.px(12))))
def ghost(label: str, icon: str | None = None, on_click: Callable | None = None, disabled: bool = False,
tooltip: str | None = None, color: str = T.TEXT_2) -> ft.TextButton:
return ft.TextButton(label, icon=icon, on_click=on_click, disabled=disabled, tooltip=tooltip,
style=ft.ButtonStyle(shape=_shape(), color={ft.ControlState.DEFAULT: color,
tooltip: str | None = None, color: str | None = None) -> ft.TextButton:
return ft.TextButton(label, icon=_btn_icon(icon, color or T.TEXT_2, disabled), on_click=on_click,
disabled=disabled, tooltip=tooltip,
style=ft.ButtonStyle(shape=_shape(), color={ft.ControlState.DEFAULT: color or T.TEXT_2,
ft.ControlState.DISABLED: T.TEXT_3}))
def icon_btn(icon: str, tooltip: str, on_click: Callable | None = None, disabled: bool = False,
color: str = T.TEXT_2) -> ft.IconButton:
return ft.IconButton(icon, tooltip=tooltip, on_click=on_click, disabled=disabled, icon_color=color,
color: str | None = None) -> ft.IconButton:
return ft.IconButton(_btn_icon(icon, color or T.TEXT_2, disabled), tooltip=tooltip, on_click=on_click,
disabled=disabled, icon_color=color or T.TEXT_2,
icon_size=T.px(20), style=ft.ButtonStyle(shape=_shape()))
# ------------------------------------------------------------------------------------------ containers
def card(content: ft.Control, padding: int | None = None, bgcolor: str = T.SURFACE, **kw) -> ft.Container:
def card(content: ft.Control, padding: int | None = None, bgcolor: str | None = None, **kw) -> ft.Container:
padding = T.S4 if padding is None else padding
return ft.Container(content, padding=padding, bgcolor=bgcolor, border_radius=T.RADIUS,
return ft.Container(content, padding=padding, bgcolor=bgcolor or T.SURFACE, border_radius=T.RADIUS,
border=ft.Border.all(1, T.BORDER), **kw)
@@ -303,9 +380,9 @@ def callout(text: str | ft.Control, kind: str = "info", icon: str | None = None)
color = {"info": T.INFO, "warn": T.WARN, "error": T.ERROR, "ok": T.OK, "pc": T.PC}[kind]
icon = icon or {"info": ft.Icons.INFO_OUTLINE_ROUNDED, "warn": ft.Icons.WARNING_AMBER_ROUNDED,
"error": ft.Icons.ERROR_OUTLINE_ROUNDED, "ok": ft.Icons.CHECK_CIRCLE_OUTLINE_ROUNDED,
"pc": ft.Icons.COMPUTER_ROUNDED}[kind]
"pc": G.PC}[kind]
content = body(text, T.TEXT) if isinstance(text, str) else text
return ft.Container(ft.Row([ft.Icon(icon, color=color, size=T.px(18)), ft.Container(content, expand=True)],
return ft.Container(ft.Row([as_icon(icon, T.px(18), color), ft.Container(content, expand=True)],
spacing=T.S3, vertical_alignment=ft.CrossAxisAlignment.START),
bgcolor=T.soft(color, 0.08), border=ft.Border.all(1, T.soft(color, 0.35)),
border_radius=T.RADIUS_SM, padding=ft.Padding(T.px(14), T.px(12), T.px(14), T.px(12)))
@@ -313,7 +390,7 @@ def callout(text: str | ft.Control, kind: str = "info", icon: str | None = None)
def empty_state(icon: str, heading: str, text: str, *actions: ft.Control) -> ft.Container:
return ft.Container(ft.Column([
ft.Container(ft.Icon(icon, size=T.px(40), color=T.ACCENT), width=T.px(84), height=T.px(84),
ft.Container(as_icon(icon, T.px(40), T.ACCENT), width=T.px(84), height=T.px(84),
border_radius=T.px(42), bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
ft.Container(height=T.S2),
title(heading, 22),
@@ -335,7 +412,7 @@ def status_row(ok: bool | None, heading: str, detail: str = "", action: ft.Contr
icon, color = {True: (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK), False: (ft.Icons.ERROR_ROUNDED, T.ERROR),
None: (ft.Icons.RADIO_BUTTON_UNCHECKED_ROUNDED, T.WARN)}[ok]
return ft.Container(ft.Row([
ft.Icon(icon, color=color, size=T.px(20)),
as_icon(icon, T.px(20), color),
ft.Column([with_help(body(heading, T.TEXT, weight=ft.FontWeight.W_500), help)]
+ ([meta(detail)] if detail else []),
spacing=1, expand=True),
@@ -351,12 +428,12 @@ def art_fill(src: str | None, radius: int | None = None, placeholder_icon: str =
if src:
return ft.Container(image=ft.DecorationImage(src=src, fit=ft.BoxFit.COVER), border_radius=radius,
bgcolor=T.SURFACE_2, **kw)
icon = ft.Icon(placeholder_icon, size=T.px(220) if hero else T.px(56),
color=T.soft(T.ACCENT, 0.22 if hero else 0.45))
icon = as_icon(placeholder_icon, T.px(220) if hero else T.px(56), T.soft(T.ACCENT, 0.22 if hero else 0.45))
return ft.Container(ft.Container(icon, padding=ft.Padding(0, 0, T.px(60), 0) if hero else 0), border_radius=radius,
alignment=ft.Alignment.CENTER_RIGHT if hero else ft.Alignment.CENTER,
gradient=ft.LinearGradient(begin=ft.Alignment.TOP_LEFT, end=ft.Alignment.BOTTOM_RIGHT,
colors=[T.ACCENT_SOFT, T.SURFACE, T.BG]), **kw)
colors=[T.SECONDARY_SOFT, T.SURFACE, T.ACCENT_SOFT] if T.DUAL
else [T.ACCENT_SOFT, T.SURFACE, T.BG]), **kw)
def bottom_fade(height: int | None = None, strength: float = 0.85) -> ft.Container:
@@ -380,8 +457,10 @@ def confirm(page: ft.Page, heading: str, text: str, ok_label: str, on_ok: Callab
shape=_shape(), bgcolor=T.ERROR if danger else T.ACCENT, color=T.ON_ACCENT))]))
def progress_bar(value: float | None = None, color: str = T.ACCENT) -> ft.ProgressBar:
return ft.ProgressBar(value=value, color=color, bgcolor=T.SURFACE_3, border_radius=T.px(4), bar_height=6)
def progress_bar(value: float | None = None, color: str | None = None) -> ft.ProgressBar:
"""Progress: blue in dual themes (something on its way through the portal), else the accent."""
return ft.ProgressBar(value=value, color=color or (T.SECONDARY if T.DUAL else T.ACCENT), bgcolor=T.SURFACE_3,
border_radius=T.px(4), bar_height=6)
def menu_items(actions: list[tuple | None]) -> list[ft.PopupMenuItem]:
@@ -393,7 +472,8 @@ def menu_items(actions: list[tuple | None]) -> list[ft.PopupMenuItem]:
out.append(ft.PopupMenuItem())
continue
label, icon, handler = a
out.append(ft.PopupMenuItem(content=ft.Text(label), icon=icon, on_click=handler))
out.append(ft.PopupMenuItem(content=ft.Text(label), icon=_btn_icon(icon, T.TEXT, size=24),
on_click=handler))
while out and out[-1].content is None:
out.pop()
return out
@@ -488,10 +568,11 @@ class Sparkline:
"""A small line chart with a soft area fill (Flet canvas, built into Flet: no charts extension). Created once;
set() replaces only the paths' elements, so a tick sends a small patch. Fills its width (on_resize)."""
def __init__(self, color: str = T.ACCENT, height: int = 36, slots: int = 120, lo: float = 0.0,
def __init__(self, color: str | None = None, height: int = 36, slots: int = 120, lo: float = 0.0,
hi: float | None = None, min_slots: int = 30, fit: float | None = None):
import flet.canvas as cv
color = color or T.ACCENT
self.cv, self.slots, self.min_slots, self.lo, self.hi = cv, slots, min_slots, lo, hi
self.fit = fit # zoom to the data: a scale of at least this span around it (temperatures, fan speed)
self.width, self.height = 0.0, T.px(height)
+70
View File
@@ -0,0 +1,70 @@
"""FramePort's own icons (no Flet): the logo and 24 px line glyphs for what Material has no icon for (the Frame, a
portal, launch tests …). They ship as SVG files in ui/icons and are copied into the GUI's assets folder (the user data
dir) so they load like artwork: by URL in source runs and the web view, by file path in packaged apps
(artwork.thumbs.asset_url). Glyphs are drawn in white and tinted by the GUI (components.icon)."""
from __future__ import annotations
from pathlib import Path
PREFIX = "fp:"
# glyph names usable wherever the GUI takes an icon (components.icon turns them into a tinted image)
FRAME = "fp:frame" # the Steam Frame headset
PORT = "fp:port" # portal + arrow: build / convert a game
PATCH = "fp:patch"
CONTAINER = "fp:container" # Lepton's per-game container
TEST = "fp:test" # launch test
PC = "fp:pc" # on this PC / PC VR
SYNC = "fp:sync" # update on Frame
LIVE = "fp:live" # live view
SHOT = "fp:shot" # screenshots
KEYS = "fp:keys" # type on Frame
RECIPE = "fp:recipe" # verified catalog recipe
LINK = "fp:link" # Frame connection
SOURCE = Path(__file__).with_name("icons")
FOLDER = "ui-icons" # inside the assets folder
def is_glyph(icon) -> bool:
return isinstance(icon, str) and icon.startswith(PREFIX)
def install(assets: Path) -> Path:
"""Copy the bundled SVGs into <assets>/ui-icons (only files that changed). Returns that folder."""
target = assets / FOLDER
for src in [*SOURCE.glob("*.svg"), *(SOURCE / "glyphs").glob("*.svg")]:
dest = target / src.name
data = src.read_bytes()
try:
if dest.read_bytes() == data:
continue
except OSError:
pass
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data)
return target
PORTAL_REV = 2
def portal_mark(assets: Path, near: str, far: str, hole: str) -> Path:
"""The small tilted portal between "Frame" and "Port" in the wordmark, in a theme's colours: the rim runs from
`near` (the side facing "Frame") to `far` (facing "Port"), around a dark hole. Written once per colour set."""
near, far, hole = (c.upper() for c in (near, far, hole))
dest = assets / FOLDER / f"portal{PORTAL_REV}-{near[1:]}-{far[1:]}-{hole[1:]}.svg" # REV: the drawing changed
if not dest.is_file():
rim = (f'<linearGradient id="r" gradientUnits="userSpaceOnUse" x1="2" y1="0" x2="14" y2="0">'
f'<stop offset=".3" stop-color="{near}"/><stop offset=".7" stop-color="{far}"/></linearGradient>')
ring = '<ellipse cx="8" cy="14" rx="4.4" ry="10.6" transform="rotate(14 8 14)"'
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(f'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="28" viewBox="0 0 16 28">'
f'<defs>{rim}</defs>'
f'{ring} fill="none" stroke="url(#r)" stroke-opacity=".35" stroke-width="5"/>'
f'{ring} fill="{hole}" stroke="url(#r)" stroke-width="2.6"/></svg>\n', encoding="utf-8")
return dest
def path(name: str, assets: Path) -> Path:
"""The installed file for a glyph ("fp:frame") or a logo file name ("logo")."""
return assets / FOLDER / f"{name.removeprefix(PREFIX)}.svg"
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 20 7.5v9L12 21l-8-4.5v-9z"/><path d="m4 7.5 8 4.5 8-4.5M12 12v9"/></svg>

After

Width:  |  Height:  |  Size: 264 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9.5A3.5 3.5 0 0 1 6.5 6h11A3.5 3.5 0 0 1 21 9.5v4a3.5 3.5 0 0 1-3.5 3.5h-2.2a2 2 0 0 1-1.7-.95l-.75-1.2a1 1 0 0 0-1.7 0l-.75 1.2A2 2 0 0 1 8.7 17H6.5A3.5 3.5 0 0 1 3 13.5z"/></svg>

After

Width:  |  Height:  |  Size: 369 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="6" width="19" height="12" rx="2.5"/><path d="M6.5 10h.01M10 10h.01M13.5 10h.01M17.5 10h.01M8 14.5h8"/></svg>

After

Width:  |  Height:  |  Size: 302 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 9a14 14 0 0 1 19 0M5.6 12.4a9.5 9.5 0 0 1 12.8 0M8.7 15.8a5 5 0 0 1 6.6 0"/><path d="M12 19.5h.01"/></svg>

After

Width:  |  Height:  |  Size: 297 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M8.2 8.2a5.4 5.4 0 0 0 0 7.6M15.8 8.2a5.4 5.4 0 0 1 0 7.6M5.3 5.3a9.5 9.5 0 0 0 0 13.4M18.7 5.3a9.5 9.5 0 0 1 0 13.4"/></svg>

After

Width:  |  Height:  |  Size: 342 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.6" y="8.2" width="18.8" height="7.6" rx="3.8" transform="rotate(-45 12 12)"/><path d="M10.6 10.6h.01M13.4 10.6h.01M10.6 13.4h.01M13.4 13.4h.01"/></svg>

After

Width:  |  Height:  |  Size: 339 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="12" rx="2.5"/><path d="M12 16v4M8 20h8"/></svg>

After

Width:  |  Height:  |  Size: 261 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="7" cy="12" rx="4" ry="9.2" transform="rotate(15 7 12)"/><path d="M13.5 12H21M18 9l3 3-3 3"/></svg>

After

Width:  |  Height:  |  Size: 288 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5.5A1.5 1.5 0 0 1 6.5 4H19v16H6.5A1.5 1.5 0 0 1 5 18.5z"/><path d="M5 17.5A1.5 1.5 0 0 1 6.5 16H19"/><path d="m9 9.5 2 2 3.5-3.5"/></svg>

After

Width:  |  Height:  |  Size: 326 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8.5V6.5A2.5 2.5 0 0 1 6.5 4h2M15.5 4h2A2.5 2.5 0 0 1 20 6.5v2M20 15.5v2a2.5 2.5 0 0 1-2.5 2.5h-2M8.5 20h-2A2.5 2.5 0 0 1 4 17.5v-2"/><circle cx="12" cy="12" r="3.2"/></svg>

After

Width:  |  Height:  |  Size: 361 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 10A8 8 0 0 0 5.6 7.2L4 9"/><path d="M4 4.5V9h4.5"/><path d="M4.5 14a8 8 0 0 0 13.9 2.8L20 15"/><path d="M20 19.5V15h-4.5"/></svg>

After

Width:  |  Height:  |  Size: 321 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5 8.9A2 2 0 0 0 6.75 21h10.5A2 2 0 0 0 19 17.9L14 9V3"/><path d="M7.4 15h9.2"/></svg>

After

Width:  |  Height:  |  Size: 286 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><defs><linearGradient id="fp0" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#3AA8FF" stop-opacity=".18"/><stop offset=".5" stop-color="#3AA8FF" stop-opacity="0"/><stop offset=".5" stop-color="#FF8A1F" stop-opacity="0"/><stop offset="1" stop-color="#FF8A1F" stop-opacity=".18"/></linearGradient><linearGradient id="fp1" gradientUnits="userSpaceOnUse" x1="26" y1="0" x2="38" y2="0"><stop offset=".3" stop-color="#3AA8FF"/><stop offset=".7" stop-color="#FF8A1F"/></linearGradient><clipPath id="fp2"><polygon points="-30,-30 49.14502116644019,-30 8.854978833559809,94 -30,94"/></clipPath><clipPath id="fp3"><polygon points="55.14502116644019,-30 94,-30 94,94 14.854978833559809,94"/></clipPath></defs><rect width="64" height="64" rx="15" fill="#101218"/><g clip-path="url(#fp2)"><g transform="translate(0 0)"><path d="M34 15H9a3 3 0 0 0-3 3v20a3 3 0 0 0 3 3h25z" fill="#ECEDF3" stroke="#ECEDF3" stroke-width="3" stroke-linejoin="round"/><rect x="10.5" y="19.5" width="24" height="17" rx="1" fill="#3AA8FF" fill-opacity="1"/><path d="M18 41v6M11 47h14" stroke="#ECEDF3" stroke-width="3" stroke-linecap="round"/></g></g><g clip-path="url(#fp3)"><path d="M56.5 31.5H58.5" stroke="#ECEDF3" stroke-width="3.4" stroke-linecap="round"/><path d="M22 21h27a8 8 0 0 1 8 8v5a8 8 0 0 1-8 8H48.65a3 3 0 0 1-2.6-1.5l-1-1.7a.9.9 0 0 0-1.6 0l-1 1.7a3 3 0 0 1-2.6 1.5H22z" fill="#ECEDF3" stroke="#ECEDF3" stroke-width="3" stroke-linejoin="round"/><ellipse cx="39.5" cy="30.5" rx="4.2" ry="3.8" fill="#FF8A1F"/><ellipse cx="49" cy="30.5" rx="4.2" ry="3.8" fill="#FF8A1F"/></g><g transform="rotate(18 32 32)"><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="#08090C" stroke="#08090C" stroke-width="5.8"/><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="none" fill-opacity="0.14" stroke="url(#fp1)" stroke-width="3.4"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><defs><linearGradient id="fp0" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#3AA8FF" stop-opacity=".18"/><stop offset=".5" stop-color="#3AA8FF" stop-opacity="0"/><stop offset=".5" stop-color="#FF8A1F" stop-opacity="0"/><stop offset="1" stop-color="#FF8A1F" stop-opacity=".18"/></linearGradient><linearGradient id="fp1" gradientUnits="userSpaceOnUse" x1="26" y1="0" x2="38" y2="0"><stop offset=".3" stop-color="#3AA8FF"/><stop offset=".7" stop-color="#FF8A1F"/></linearGradient><clipPath id="fp2"><polygon points="-30,-30 49.14502116644019,-30 8.854978833559809,94 -30,94"/></clipPath><clipPath id="fp3"><polygon points="55.14502116644019,-30 94,-30 94,94 14.854978833559809,94"/></clipPath></defs><rect width="64" height="64" rx="15" fill="#101218"/><rect width="64" height="64" rx="15" fill="url(#fp0)"/><g clip-path="url(#fp2)"><g transform="translate(0 0)"><path d="M34 15H9a3 3 0 0 0-3 3v20a3 3 0 0 0 3 3h25z" fill="#101218" stroke="#ECEDF3" stroke-width="3" stroke-linejoin="round"/><rect x="10.5" y="19.5" width="24" height="17" rx="1" fill="#3AA8FF" fill-opacity="0.35"/><path d="M18 41v6M11 47h14" stroke="#ECEDF3" stroke-width="3" stroke-linecap="round"/></g></g><g clip-path="url(#fp3)"><path d="M56.5 31.5H58.5" stroke="#ECEDF3" stroke-width="3.4" stroke-linecap="round"/><path d="M22 21h27a8 8 0 0 1 8 8v5a8 8 0 0 1-8 8H48.65a3 3 0 0 1-2.6-1.5l-1-1.7a.9.9 0 0 0-1.6 0l-1 1.7a3 3 0 0 1-2.6 1.5H22z" fill="#101218" stroke="#ECEDF3" stroke-width="3" stroke-linejoin="round"/><ellipse cx="39.5" cy="30.5" rx="4.2" ry="3.8" fill="#FF8A1F"/><ellipse cx="49" cy="30.5" rx="4.2" ry="3.8" fill="#FF8A1F"/></g><g transform="rotate(18 32 32)"><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="#08090C" stroke="#08090C" stroke-width="5.8"/><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="none" stroke="url(#fp1)" stroke-opacity=".25" stroke-width="6.46"/><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="url(#fp1)" fill-opacity="0.14" stroke="url(#fp1)" stroke-width="3.4"/></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+201 -24
View File
@@ -1,32 +1,209 @@
"""Design tokens for the FramePort GUI (dark only): one accent, a neutral surface ramp, semantic state colours, an
8-pt spacing scale and a small type scale. Every view takes its colours and sizes from here."""
"""Design tokens for the FramePort GUI (dark only, the owner's choice): colour themes (an accent, a neutral surface
ramp, semantic state colours), an 8-pt spacing scale and a small type scale. Every view takes its colours and sizes
from here, reading them as `T.NAME` when it builds (never as a default argument or a module-level constant: those keep
the colours of the theme that was active at import; module-level maps re-fill themselves through on_change()).
Themes: the built-ins below plus theme files the user installs (<data>/themes/*.json, docs/THEMES.md)."""
from __future__ import annotations
import json
import re
import shutil
from collections.abc import Callable
from pathlib import Path
import flet as ft
# surfaces (darkest → lightest)
BG = "#0E0F13" # window background
SIDEBAR = "#121319"
SURFACE = "#171920" # cards
SURFACE_2 = "#1E2029" # raised / hover
SURFACE_3 = "#262935" # inputs, chips
BORDER = "#2C2F3B"
BORDER_STRONG = "#3A3E4D"
# Colour tokens of a theme:
# surfaces: BG window, SIDEBAR, SURFACE cards, SURFACE_2 raised/hover, SURFACE_3 inputs/chips, BORDER(_STRONG)
# text: TEXT, TEXT_2 secondary, TEXT_3 meta/disabled
# ACCENT (+ ACCENT_SOFT tint, ON_ACCENT text on it): the main action and the current selection
# SECONDARY (+ SECONDARY_SOFT): secondary actions, progress, switch tracks (with "dual" also the two-colour touches)
# states OK/WARN/ERROR/INFO, PC = PC VR / "on this PC"
TOKENS = ("BG", "SIDEBAR", "SURFACE", "SURFACE_2", "SURFACE_3", "BORDER", "BORDER_STRONG", "TEXT", "TEXT_2", "TEXT_3",
"ACCENT", "ACCENT_SOFT", "ON_ACCENT", "SECONDARY", "SECONDARY_SOFT", "OK", "WARN", "ERROR", "INFO", "PC")
# text
TEXT = "#ECEDF3"
TEXT_2 = "#A9ADBD" # secondary
TEXT_3 = "#737889" # meta / disabled
# "portal" follows the logo: orange (the Frame's side) = actions and selection, blue (the PC's side) = secondary
# actions, progress and the PC; "dual" adds the blue→orange touches (sidebar edge, wordmark, selected tab).
# Status colours are picked so a warning never reads as the accent.
_PORTAL = {
"BG": "#0D0E12", "SIDEBAR": "#111217", "SURFACE": "#16181E", "SURFACE_2": "#1D1F27", "SURFACE_3": "#252832",
"BORDER": "#2A2D37", "BORDER_STRONG": "#393D49",
"TEXT": "#EDEEF2", "TEXT_2": "#A7ABB7", "TEXT_3": "#717583",
"ACCENT": "#FF8A1F", "ACCENT_SOFT": "#3A2512", "ON_ACCENT": "#160C03",
"SECONDARY": "#3AA8FF", "SECONDARY_SOFT": "#0F2A40",
# INFO is a cyan of its own: next to PC (the portal blue) it tells data series apart (Monitor: Steam vs
# SteamVR processes, CPU vs memory) and stays clear of OK's green
"OK": "#3DD68C", "WARN": "#F2C94C", "ERROR": "#FF6166", "INFO": "#5CD0E6", "PC": "#3AA8FF",
}
BUILTIN: dict[str, dict] = {
"portal": {"name": "Portal", "dual": True, "colors": _PORTAL},
"portal_oled": {"name": "Portal (OLED)", "dual": True, "colors": {
**_PORTAL, # true black behind everything; cards just lift off it
"BG": "#000000", "SIDEBAR": "#000000", "SURFACE": "#0A0A0D", "SURFACE_2": "#121317", "SURFACE_3": "#1A1B21",
"BORDER": "#1E1F26", "BORDER_STRONG": "#2B2D36", "ACCENT_SOFT": "#2B1A0A", "SECONDARY_SOFT": "#0A1F31"}},
"original": {"name": "Original", "dual": False, "colors": { # FramePort's colours before the Portal theme
"BG": "#0E0F13", "SIDEBAR": "#121319", "SURFACE": "#171920", "SURFACE_2": "#1E2029", "SURFACE_3": "#262935",
"BORDER": "#2C2F3B", "BORDER_STRONG": "#3A3E4D",
"TEXT": "#ECEDF3", "TEXT_2": "#A9ADBD", "TEXT_3": "#737889",
"ACCENT": "#8B7CFF", "ACCENT_SOFT": "#2A2550", "ON_ACCENT": "#0E0F13",
"SECONDARY": "#38BDF8", "SECONDARY_SOFT": "#12303F",
"OK": "#4ADE80", "WARN": "#FBBF24", "ERROR": "#F87171", "INFO": "#60A5FA", "PC": "#38BDF8"}},
}
ALIASES = {"classic": "original"} # an earlier name
USER_PREFIX = "user:"
THEMES: dict[str, dict] = dict(BUILTIN) # + installed theme files (load_user_themes)
DEFAULT_THEME = "portal"
THEME = DEFAULT_THEME
DUAL = True
_listeners: list[Callable[[], None]] = []
# accent + states
ACCENT = "#8B7CFF"
ACCENT_SOFT = "#2A2550"
ON_ACCENT = "#0E0F13"
OK = "#4ADE80"
WARN = "#FBBF24"
ERROR = "#F87171"
INFO = "#60A5FA"
PC = "#38BDF8" # PC VR / "on this PC"
# the active theme's tokens as module globals (set_theme() replaces them); declared for linters and readers
BG = SIDEBAR = SURFACE = SURFACE_2 = SURFACE_3 = BORDER = BORDER_STRONG = ""
TEXT = TEXT_2 = TEXT_3 = ACCENT = ACCENT_SOFT = ON_ACCENT = SECONDARY = SECONDARY_SOFT = ""
OK = WARN = ERROR = INFO = PC = ""
def theme_from_setting(value) -> str:
"""Library setting ui.theme → a known theme id (unknown/missing/removed → the default)."""
value = ALIASES.get(value, value)
return value if value in THEMES else DEFAULT_THEME
def on_change(callback: Callable[[], None]) -> None:
"""Call `callback` after every set_theme() (and once now): for module-level maps that hold colours."""
_listeners.append(callback)
callback()
def set_theme(name: str) -> None:
"""Switch the colour tokens. Views built afterwards use the new colours (app.restyle() rebuilds the open ones)."""
global THEME, DUAL
THEME = theme_from_setting(name)
DUAL = bool(THEMES[THEME]["dual"])
globals().update(THEMES[THEME]["colors"])
for callback in list(_listeners):
callback()
# ------------------------------------------------------------------------------------------ theme files
class ThemeError(ValueError):
"""A theme file FramePort can't use; the message says why, in plain words."""
_HEX = re.compile(r"#?([0-9a-fA-F]{6}|[0-9a-fA-F]{3})")
def _hex(value, key: str) -> str:
m = _HEX.fullmatch(str(value).strip()) if isinstance(value, str) else None
if not m:
raise ThemeError(f"{key}: \"{value}\" is not a colour like #FF8A1F")
h = m.group(1)
return "#" + (h if len(h) == 6 else "".join(c * 2 for c in h)).upper()
def _luminance(color: str) -> float:
def ch(v):
v /= 255
return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
r, g, b = (int(color[i:i + 2], 16) for i in (1, 3, 5))
return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b)
def contrast(a: str, b: str) -> float:
la, lb = sorted((_luminance(a), _luminance(b)), reverse=True)
return (la + 0.05) / (lb + 0.05)
def parse_theme(data: dict, fallback_name: str = "Custom theme") -> dict:
"""A theme file's content → a theme ({"name", "dual", "colors"}); raises ThemeError. Colours it leaves out come
from its "base" built-in (default Portal); keys may be any case."""
if not isinstance(data, dict):
raise ThemeError("the file isn't a theme (expected a JSON object with \"colors\")")
unknown = set(data) - {"name", "base", "dual", "colors", "description", "author", "version"}
if unknown:
raise ThemeError(f"unknown field(s): {', '.join(sorted(unknown))}")
base_id = ALIASES.get(data.get("base") or DEFAULT_THEME, data.get("base") or DEFAULT_THEME)
if base_id not in BUILTIN:
raise ThemeError(f"base \"{data.get('base')}\" isn't a built-in theme ({', '.join(BUILTIN)})")
base = BUILTIN[base_id]
raw = data.get("colors") or {}
if not isinstance(raw, dict):
raise ThemeError("\"colors\" must be an object of TOKEN: \"#RRGGBB\"")
colors = dict(base["colors"])
for key, value in raw.items():
token = str(key).upper()
if token not in TOKENS:
raise ThemeError(f"unknown colour \"{key}\" (known: {', '.join(TOKENS)})")
colors[token] = _hex(value, token)
# FramePort is dark only: a light window would leave Material's own parts (menus, inputs) unreadable
if _luminance(colors["BG"]) > 0.05:
raise ThemeError(f"BG {colors['BG']} is too light: FramePort themes are dark")
if contrast(colors["TEXT"], colors["SURFACE"]) < 4.5:
raise ThemeError(f"TEXT {colors['TEXT']} is hard to read on SURFACE {colors['SURFACE']}")
name = str(data.get("name") or fallback_name).strip()[:40] or fallback_name
dual = data.get("dual", base["dual"])
if not isinstance(dual, bool):
raise ThemeError("\"dual\" must be true or false")
return {"name": name, "dual": dual, "colors": colors}
def read_theme_file(path: Path) -> dict:
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError) as exc:
raise ThemeError(f"can't read the file ({exc})") from exc
except json.JSONDecodeError as exc:
raise ThemeError(f"not valid JSON (line {exc.lineno}: {exc.msg})") from exc
return parse_theme(data, Path(path).stem.replace("_", " ").replace("-", " ").title())
def _slug(text: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:40] or "theme"
def load_user_themes(folder: Path) -> list[str]:
"""(Re)load the installed theme files into THEMES. Returns problems with files that were skipped."""
for key in [k for k in THEMES if k.startswith(USER_PREFIX)]:
del THEMES[key]
problems = []
for f in sorted(Path(folder).glob("*.json")) if Path(folder).is_dir() else []:
try:
THEMES[USER_PREFIX + f.stem] = {**read_theme_file(f), "path": str(f)}
except ThemeError as exc:
problems.append(f"{f.name}: {exc}")
return problems
def install_theme(source: Path, folder: Path) -> str:
"""Check a theme file and copy it into the themes folder (named after the theme). Returns its theme id."""
theme = read_theme_file(source)
folder.mkdir(parents=True, exist_ok=True)
dest = folder / f"{_slug(theme['name'])}.json"
if Path(source).resolve() != dest.resolve():
shutil.copyfile(source, dest)
tid = USER_PREFIX + dest.stem
THEMES[tid] = {**theme, "path": str(dest)}
return tid
def remove_theme(theme_id: str) -> None:
"""Delete an installed theme file (built-ins can't be removed)."""
theme = THEMES.get(theme_id)
if not theme_id.startswith(USER_PREFIX) or not theme:
raise ThemeError("only installed themes can be removed")
Path(theme["path"]).unlink(missing_ok=True)
del THEMES[theme_id]
def theme_json(theme_id: str) -> str:
"""A theme as a theme file (a starting point for your own)."""
t = THEMES[theme_id]
base = theme_id if theme_id in BUILTIN else DEFAULT_THEME
return json.dumps({"name": f"My {t['name']}", "base": base, "dual": t["dual"], "colors": t["colors"]}, indent=2)
set_theme(DEFAULT_THEME)
# spacing / shape / type (at 100 %; set_scale() multiplies them)
_BASE = {"S1": 4, "S2": 8, "S3": 12, "S4": 16, "S5": 24, "S6": 32, "RADIUS": 12, "RADIUS_SM": 8,
@@ -91,7 +268,7 @@ def soft(color: str, opacity: float = 0.14) -> str:
def apply(page: ft.Page) -> None:
scheme = ft.ColorScheme(
primary=ACCENT, on_primary=ON_ACCENT, primary_container=ACCENT_SOFT, on_primary_container=TEXT,
secondary=PC, on_secondary=ON_ACCENT, surface=SURFACE, on_surface=TEXT, on_surface_variant=TEXT_2,
secondary=SECONDARY, on_secondary=ON_ACCENT, surface=SURFACE, on_surface=TEXT, on_surface_variant=TEXT_2,
surface_container_lowest=BG, surface_container_low=SIDEBAR, surface_container=SURFACE,
surface_container_high=SURFACE_2, surface_container_highest=SURFACE_3, outline=BORDER_STRONG,
outline_variant=BORDER, error=ERROR, on_error=ON_ACCENT,
+9 -3
View File
@@ -15,6 +15,7 @@ from ..core import applog, library
from ..errors import explain
from ..i18n import tr
from . import components as C
from . import glyphs as G
from . import theme as T
if TYPE_CHECKING:
@@ -29,12 +30,17 @@ class Updater:
self.found: updates.Update | None = None
self.restart = None # callable: what to do once the job queue is idle (install + quit)
self.preparing = False
self._version = C.meta("", color=T.ON_ACCENT)
self.build_card()
def build_card(self) -> None:
"""The sidebar's "Update available" card (again after a theme change; keeps what it shows)."""
old = getattr(self, "card", None)
self._version = C.meta(getattr(self, "_version", None) and self._version.value or "", color=T.ON_ACCENT)
self.card = ft.Container(
ft.Row([ft.Icon(ft.Icons.SYSTEM_UPDATE_ROUNDED, size=T.px(18), color=T.ON_ACCENT),
ft.Column([C.body(tr("Update available"), T.ON_ACCENT, weight=ft.FontWeight.W_600), self._version],
spacing=0, expand=True)], spacing=T.S2),
padding=T.S3, border_radius=T.RADIUS_SM, bgcolor=T.ACCENT, ink=True, visible=False,
padding=T.S3, border_radius=T.RADIUS_SM, bgcolor=T.ACCENT, ink=True, visible=bool(old and old.visible),
tooltip=tr("A new FramePort version is ready to install"), on_click=lambda e: self.show_dialog())
# ---------------------------------------------------------------- checking
@@ -164,7 +170,7 @@ class Updater:
width=T.px(560), height=T.px(min(440, 190 + 26 * len(notes.splitlines())))),
actions=[C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
C.ghost(tr("Build page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
C.primary(tr("Install dev build"), ft.Icons.SCIENCE_OUTLINED, go)]))
C.primary(tr("Install dev build"), G.TEST, go)]))
# ---------------------------------------------------------------- installing
def install(self) -> None:
+16 -7
View File
@@ -15,13 +15,22 @@ from ..jobs import Job
if TYPE_CHECKING:
from ..app import FramePortApp
CHECK_ICON = {True: (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK), False: (ft.Icons.CANCEL_ROUNDED, T.ERROR),
None: (ft.Icons.WARNING_AMBER_ROUNDED, T.WARN)}
STATE_STYLE = {"queued": (ft.Icons.SCHEDULE_ROUNDED, T.TEXT_3, tr("Waiting")),
"running": (ft.Icons.SYNC_ROUNDED, T.ACCENT, tr("Working")),
"done": (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK, tr("Done")),
"failed": (ft.Icons.ERROR_ROUNDED, T.ERROR, tr("Failed")),
"cancelled": (ft.Icons.DO_NOT_DISTURB_ON_OUTLINED, T.TEXT_3, tr("Cancelled"))}
CHECK_ICON: dict = {}
STATE_STYLE: dict[str, tuple] = {}
def _fill_styles() -> None:
"""(Re)fill the maps with the active theme's colours, in place."""
CHECK_ICON.update({True: (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK), False: (ft.Icons.CANCEL_ROUNDED, T.ERROR),
None: (ft.Icons.WARNING_AMBER_ROUNDED, T.WARN)})
STATE_STYLE.update({"queued": (ft.Icons.SCHEDULE_ROUNDED, T.TEXT_3, tr("Waiting")),
"running": (ft.Icons.SYNC_ROUNDED, T.ACCENT, tr("Working")),
"done": (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK, tr("Done")),
"failed": (ft.Icons.ERROR_ROUNDED, T.ERROR, tr("Failed")),
"cancelled": (ft.Icons.DO_NOT_DISTURB_ON_OUTLINED, T.TEXT_3, tr("Cancelled"))})
T.on_change(_fill_styles)
def _dur(job: Job) -> str:
+6 -6
View File
@@ -9,6 +9,7 @@ import flet as ft
from ...core import library
from ...i18n import tr
from .. import components as C
from .. import glyphs as G
from .. import theme as T
from ..battery import label as battery_label
from ..help import HELP
@@ -27,7 +28,7 @@ class FrameView:
t = app.target
free = (info.get("free_bytes") or 0) / 2**30
return C.card(ft.Row([
ft.Container(ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=T.px(34), color=T.ACCENT), width=T.px(72),
ft.Container(C.as_icon(G.FRAME, T.px(34), T.ACCENT), width=T.px(72),
height=T.px(72), border_radius=T.px(18), bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
ft.Column([
ft.Row([C.title(info.get("hostname") or t.label, 22), C.pill(tr("Connected"), T.OK, ft.Icons.CIRCLE)],
@@ -42,7 +43,7 @@ class FrameView:
], spacing=T.px(4), expand=True),
ft.Column([
C.secondary(tr("Refresh"), ft.Icons.REFRESH_ROUNDED, lambda e: app.refresh_frame()),
C.secondary(tr("Type on Frame"), ft.Icons.KEYBOARD_ROUNDED, lambda e: app.type_on_frame(),
C.secondary(tr("Type on Frame"), G.KEYS, lambda e: app.type_on_frame(),
tooltip=tr("Use this computer's keyboard on the Frame")),
C.ghost(tr("Switch Frame…"), ft.Icons.SWAP_HORIZ_ROUNDED, lambda e: app.disconnect()),
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.END),
@@ -67,7 +68,7 @@ class FrameView:
(tr("{display_name} can be installed (about 1 GiB; Steam restarts once)")
.format(display_name=sug['display_name']) if sug else
tr("Not offered by Steam on this Frame yet")),
C.secondary(tr("Test"), ft.Icons.SCIENCE_OUTLINED, lambda e: app.test_proton()) if ready else
C.secondary(tr("Test"), G.TEST, lambda e: app.test_proton()) if ready else
C.primary(tr("Install"), ft.Icons.DOWNLOAD_ROUNDED, lambda e: app.install_proton(), disabled=not sug),
help="proton"))
xr = (pr.get("openxr") or {}).get("name")
@@ -135,7 +136,7 @@ class FrameView:
lambda e, p=pkg, t=title: show_files_dialog(app, p, t)),
C.icon_btn(ft.Icons.PLAY_ARROW_ROUNDED, tr("Play: starts the game through the Frame's Steam (put the "
"headset on)"), lambda e, p=pkg: app.play(p, "frame"), color=T.ACCENT),
C.icon_btn(ft.Icons.SCIENCE_OUTLINED, C.tip(tr("Launch test. ") + HELP["launch_test"]),
C.icon_btn(G.TEST, C.tip(tr("Launch test. ") + HELP["launch_test"]),
lambda e, p=pkg: app.test_game(p, "frame")),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, C.tip(tr("Uninstall. ") + HELP["uninstall"]),
lambda e, p=pkg: app.uninstall(p, "frame")),
@@ -165,8 +166,7 @@ class FrameView:
label = {"devkit": "SteamOS · Developer Mode", "frameport": "Set up for FramePort",
"saved": "Remembered", "scan": "SSH found by network scan"}.get(f.source, f.source)
items.append(ft.Container(ft.Row([
ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED if f.source != "scan" else ft.Icons.DEVICES_OTHER_ROUNDED,
color=T.ACCENT),
C.as_icon(G.FRAME if f.source != "scan" else ft.Icons.DEVICES_OTHER_ROUNDED, T.px(24), T.ACCENT),
ft.Column([C.body(f.name if f.source != "scan" else f.host, T.TEXT, weight=ft.FontWeight.W_500),
C.meta(f"{f.host} · {label}")], spacing=T.px(2), expand=True),
C.primary(tr("Connect"), on_click=lambda e, f=f: app.connect(parse_target(f"{f.user}@{f.host}"),
+9 -8
View File
@@ -15,6 +15,7 @@ from ...i18n import tr, tr_n
from ...patches import base
from ...recommend import catalog, engine
from .. import components as C
from .. import glyphs as G
from .. import theme as T
from ..help import HELP
@@ -178,15 +179,15 @@ class GameView:
else tr("Runs directly — no Revive needed") if self.rift else
tr("Runs natively on SteamOS, started from the Frame's Steam library") if self.linux else "")
cards.append(self.target_card(
ft.Icons.VIEW_IN_AR_ROUNDED, tr("Steam Frame"), frame_line, frame_color, frame_sub,
[C.icon_btn(ft.Icons.SCIENCE_OUTLINED, C.tip(tr("Launch test on the Frame. ") + HELP["launch_test"]),
G.FRAME, tr("Steam Frame"), frame_line, frame_color, frame_sub,
[C.icon_btn(G.TEST, C.tip(tr("Launch test on the Frame. ") + HELP["launch_test"]),
lambda e: app.test_game(pkg, "frame"), not frame_ok),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, C.tip(tr("Uninstall from the Frame. ") + HELP["uninstall"]),
lambda e: app.uninstall(pkg, "frame"), not frame_ok)] if frame_ok else []))
if self.rift:
dep = app.pc_installs().get(pkg)
cards.append(self.target_card(
ft.Icons.COMPUTER_ROUNDED, tr("This PC (Steam + Revive)"),
G.PC, tr("This PC (Steam + Revive)"),
(tr("In your Steam library · launch settings changed — update it") if C.pc_outdated(g, dep) else
tr("In your Steam library")) if dep else tr("Not installed"),
(T.WARN if C.pc_outdated(g, dep) else T.PC) if dep else T.TEXT_3,
@@ -194,7 +195,7 @@ class GameView:
.format(value=dep.get('revive_version') or '', value2=dep.get('backend') or 'openxr')
if dep.get("revive_win") else tr("The repack's own Revive · runs the game directly")
if dep.get("launch") == "repack" else tr("Runs the game directly")) if dep else "",
[C.icon_btn(ft.Icons.SCIENCE_OUTLINED, tr("Launch test on this PC"),
[C.icon_btn(G.TEST, tr("Launch test on this PC"),
lambda e: app.test_game(pkg, "pc")),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Remove from this PC's Steam library"),
lambda e: app.uninstall(pkg, "pc"))] if dep else []))
@@ -202,7 +203,7 @@ class GameView:
def target_card(self, icon, name, line, color, sub, buttons) -> ft.Control:
return C.card(ft.Row([
ft.Container(ft.Icon(icon, color=color, size=T.px(22)), width=T.px(44), height=T.px(44),
ft.Container(C.as_icon(icon, T.px(22), color), width=T.px(44), height=T.px(44),
border_radius=T.px(10), bgcolor=T.soft(color, 0.14), alignment=ft.Alignment.CENTER),
ft.Column([C.body(name, T.TEXT, weight=ft.FontWeight.W_600), C.body(line, color, size=T.T_META)]
+ ([C.meta(sub)] if sub else []), spacing=T.px(2), expand=True),
@@ -283,7 +284,7 @@ class GameView:
other_rift = r.get("kind") == "rift"
links.append(C.ghost(tr("Also in your library: {value} version")
.format(value='Rift' if other_rift else 'Quest'),
ft.Icons.COMPUTER_ROUNDED if other_rift else ft.Icons.VIEW_IN_AR_ROUNDED,
G.PC if other_rift else G.FRAME,
lambda e, p=r["package"]: self.app.open_game(p), color=T.ACCENT))
if links:
out.append(ft.Row(links, spacing=T.S2, wrap=True))
@@ -451,7 +452,7 @@ class GameView:
visible = [p for p in on if not p.default_on or p.category in ("pcvr",)]
if entry and recipe.source.startswith("catalog"):
lead = f"Known-good recipe for this game, tested {entry.verified.get('date', '')}".strip(", ")
icon, color = ft.Icons.VERIFIED_ROUNDED, T.OK
icon, color = G.RECIPE, T.OK
elif recipe.source == "user":
lead, icon, color = tr("Your custom recipe"), ft.Icons.TUNE_ROUNDED, T.ACCENT
else:
@@ -495,7 +496,7 @@ class GameView:
return C.section(
tr("What FramePort will do"),
C.card(ft.Column([
ft.Row([ft.Icon(icon, color=color, size=T.px(18)), C.body(lead, T.TEXT, weight=ft.FontWeight.W_500,
ft.Row([C.as_icon(icon, T.px(18), color), C.body(lead, T.TEXT, weight=ft.FontWeight.W_500,
expand=True)], spacing=T.S2),
ft.Row(chips, spacing=T.S2, run_spacing=T.S2, wrap=True) if chips else
(ft.Container() if as_is else C.meta(tr("Nothing to patch: it runs as is."))),
+2 -1
View File
@@ -14,6 +14,7 @@ import flet as ft
from ...errors import explain
from ...i18n import tr
from .. import components as C
from .. import glyphs as G
from .. import theme as T
if TYPE_CHECKING:
@@ -50,7 +51,7 @@ class KeyboardView:
if not (app.target and app.frame_state == "connected"):
return ft.Column([
app.top_bar(heading, sub),
C.empty_state(ft.Icons.KEYBOARD_ROUNDED, tr("Connect your Frame first"),
C.empty_state(G.KEYS, tr("Connect your Frame first"),
tr("Typing on the Frame works once FramePort is connected to it."),
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")))], expand=True)
if self.root is None:
+4 -3
View File
@@ -13,6 +13,7 @@ from ...artwork import thumbs
from ...core import library
from ...i18n import tr, tr_n
from .. import components as C
from .. import glyphs as G
from .. import theme as T
from ..help import HELP
@@ -372,11 +373,11 @@ class LibraryView:
ft.Container(expand=True),
C.ghost(tr("Select all shown"), on_click=lambda e: self.select_visible()),
C.ghost(tr("Clear"), on_click=lambda e: (self.selected.clear(), self.set_select_mode(True))),
*([C.secondary(tr("Install {len} on this PC").format(len=len(rift)), ft.Icons.COMPUTER_ROUNDED,
*([C.secondary(tr("Install {len} on this PC").format(len=len(rift)), G.PC,
lambda e: app.install_many(sorted(rift), "pc"), disabled=not winhost.available())]
if rift else []),
C.primary(tr("Install {n} on Frame").format(n=n) if n else tr("Install on Frame"),
ft.Icons.VIEW_IN_AR_ROUNDED,
G.FRAME,
lambda e: app.install_many(sorted(self.selected), "frame"),
disabled=not n or app.frame_state != "connected",
tooltip=None if app.frame_state == "connected" else tr("Connect your Frame first")),
@@ -456,7 +457,7 @@ class LibraryView:
self.hint.content = C.callout(ft.Row([
C.body(tr("Connect your Steam Frame to install games and see what's on it."), T.TEXT, expand=True),
C.ghost(tr("Connect"), ft.Icons.ARROW_FORWARD_ROUNDED, lambda e: app.go("frame"), color=T.ACCENT)]),
"info", ft.Icons.VIEW_IN_AR_ROUNDED)
"info", G.FRAME)
self.hint.visible = True
else:
self.hint.visible = False
+2 -1
View File
@@ -15,6 +15,7 @@ import flet as ft
from ...errors import explain
from ...i18n import tr, tr_n
from .. import components as C
from .. import glyphs as G
from .. import theme as T
from .files_dialog import human
@@ -88,7 +89,7 @@ class LiveView:
if not (app.target and app.frame_state == "connected"):
return ft.Column([
app.top_bar(heading, sub),
C.empty_state(ft.Icons.CAST_ROUNDED, tr("Connect your Frame first"),
C.empty_state(G.LIVE, tr("Connect your Frame first"),
tr("The live view can start once FramePort is connected to the Frame."),
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")))], expand=True)
if self.root is None:
+15 -4
View File
@@ -24,10 +24,21 @@ if TYPE_CHECKING:
ROWS = 50 # process rows shown at most (the agent sends up to 150; the search narrows them)
DETAIL_HEIGHT = 250 # the three detail cards share one height (px at 100 %)
RETRY_SECONDS = 5 # reconnect after the stream was lost, while the tab is open
LEVEL_COLOR = {"ok": T.TEXT, "warn": T.WARN, "error": T.ERROR}
GROUP_COLOR = {"Game": T.ACCENT, "Steam": T.INFO, "SteamVR": T.PC, "Desktop": T.TEXT_2, "Other": T.TEXT_3}
CLUSTER_COLORS = (T.INFO, T.ACCENT, T.PC, T.OK)
POWER_COLORS = (T.INFO, T.ACCENT, T.PC, T.TEXT_3) # CPU, GPU, NPU, other
LEVEL_COLOR: dict[str, str] = {}
GROUP_COLOR: dict[str, str] = {}
CLUSTER_COLORS: list[str] = []
POWER_COLORS: list[str] = [] # CPU, GPU, NPU, other
def _fill_colors() -> None:
"""(Re)fill the colour maps with the active theme's colours, in place (theme switches)."""
LEVEL_COLOR.update({"ok": T.TEXT, "warn": T.WARN, "error": T.ERROR})
GROUP_COLOR.update({"Game": T.ACCENT, "Steam": T.INFO, "SteamVR": T.PC, "Desktop": T.TEXT_2, "Other": T.TEXT_3})
CLUSTER_COLORS[:] = [T.INFO, T.ACCENT, T.PC, T.OK]
POWER_COLORS[:] = [T.INFO, T.ACCENT, T.PC, T.TEXT_3]
T.on_change(_fill_colors)
def label(name: str) -> str:
+2 -1
View File
@@ -17,6 +17,7 @@ from ...artwork import thumbs
from ...errors import explain
from ...i18n import tr, tr_n
from .. import components as C
from .. import glyphs as G
from .. import theme as T
from .files_dialog import human
@@ -110,7 +111,7 @@ class ScreenshotsView:
self._loaded = False # load again once connected
return ft.Column([
app.top_bar(heading, sub),
C.empty_state(ft.Icons.PHOTO_LIBRARY_OUTLINED, tr("Connect your Frame first"),
C.empty_state(G.SHOT, tr("Connect your Frame first"),
tr("Screenshots on the Frame can be shown once FramePort is connected to it."),
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")))], expand=True)
if self.root is None:
+91 -2
View File
@@ -1,6 +1,7 @@
"""Settings: the portable toolchain (incl. Revive), this PC for PC VR games, data folder, about."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
import flet as ft
@@ -11,6 +12,7 @@ from ...errors import explain
from ...i18n import fmt_size, tr, tr_n
from ...recommend import catalog
from .. import components as C
from .. import glyphs as G
from .. import theme as T
if TYPE_CHECKING:
@@ -134,7 +136,7 @@ class SettingsView:
"next starts)"), value=bool(library.setting("update.auto_install", False)),
on_change=auto_install),
ft.Row([C.meta(tr("Asked to test a fix? Dev builds come before the next release."), expand=True),
C.ghost(tr("Install the latest dev build…"), ft.Icons.SCIENCE_OUTLINED,
C.ghost(tr("Install the latest dev build…"), G.TEST,
lambda e: app.updater.install_dev())],
vertical_alignment=ft.CrossAxisAlignment.CENTER),
], spacing=T.S3)
@@ -225,6 +227,93 @@ class SettingsView:
app.run_bg(lambda: show(urlhandler.status()))
return ft.Column(controls, spacing=T.S4)
def theme_picker(self) -> ft.Control:
"""One card per colour theme (built-in and installed theme files: a swatch of its window and its two portal
colours); a click switches at once. Theme files are installed from here (docs/THEMES.md)."""
from ...core import library
from ..app import themes_dir
app = self.app
about = {"portal": tr("Orange for actions, blue for the PC side: the logo's two portals"),
"portal_oled": tr("Portal on true black, for OLED screens"),
"original": tr("FramePort's original violet")}
def pick(tid: str):
if tid == T.THEME:
return
library.set_setting("ui.theme", tid)
app.restyle(tid)
def remove(tid: str):
name = T.THEMES[tid]["name"]
try:
T.remove_theme(tid)
except (T.ThemeError, OSError) as exc:
app.toast(tr("Couldn't remove the theme: {error}").format(error=exc), error=True)
return
if tid == T.THEME:
library.set_setting("ui.theme", T.DEFAULT_THEME)
app.restyle(T.DEFAULT_THEME)
else:
app.render()
app.toast(tr("Removed the theme \"{name}\"").format(name=name))
async def install(e):
files = await ft.FilePicker().pick_files(dialog_title=tr("Theme file to install"),
allowed_extensions=["json"])
path = next((f.path for f in files or [] if f.path), None)
if not path:
return
try:
tid = T.install_theme(Path(path), themes_dir())
except (T.ThemeError, OSError) as exc:
app.toast(tr("That theme file can't be used: {error}").format(error=exc), error=True)
return
library.set_setting("ui.theme", tid)
app.restyle(tid)
app.toast(tr("Installed the theme \"{name}\"").format(name=T.THEMES[tid]["name"]))
def card(tid: str) -> ft.Control:
theme, on = T.THEMES[tid], tid == T.THEME
c = theme["colors"]
dot = lambda color: ft.Container(width=T.px(18), height=T.px(18), border_radius=T.px(9), # noqa: E731
bgcolor=color)
bar = ft.Container(height=T.px(6), border_radius=T.px(3), expand=True,
gradient=ft.LinearGradient(colors=[c["SECONDARY"], c["ACCENT"]]) if theme["dual"]
else None, bgcolor=None if theme["dual"] else c["SURFACE_3"])
swatch = ft.Container(ft.Row([dot(c["ACCENT"]), dot(c["SECONDARY"]), bar], spacing=T.px(6),
vertical_alignment=ft.CrossAxisAlignment.CENTER),
bgcolor=c["BG"], border=ft.Border.all(1, c["BORDER"]), border_radius=T.RADIUS_SM,
padding=ft.Padding(T.S3, T.S3, T.S3, T.S3))
# small marks (an IconButton would make this card's name row taller than the others')
marks = [C.as_icon(ft.Icons.CHECK_CIRCLE_ROUNDED, T.px(18), T.ACCENT)] if on else []
if tid.startswith(T.USER_PREFIX):
marks.append(ft.Container(C.as_icon(ft.Icons.DELETE_OUTLINE_ROUNDED, T.px(18), T.TEXT_2),
tooltip=tr("Remove this theme"), border_radius=T.px(4), ink=True,
on_click=lambda e, t=tid: remove(t)))
corner = ft.Row(marks, spacing=T.S2, tight=True)
return ft.Container(ft.Column([
swatch,
ft.Row([C.body(theme["name"], T.TEXT, weight=ft.FontWeight.W_600, expand=True), corner],
vertical_alignment=ft.CrossAxisAlignment.CENTER),
C.meta(about.get(tid) or tr("Installed theme file · {file}").format(file=Path(theme["path"]).name)),
], spacing=T.S2), width=T.px(240), padding=T.S3, border_radius=T.RADIUS,
bgcolor=T.SURFACE_2 if on else None, ink=True, on_click=lambda e, t=tid: pick(t),
border=ft.Border.all(2, T.ACCENT if on else T.BORDER)) # same width: the content doesn't shift
return ft.Column([
# no expand= in here: an expanding child in a wrap=True Row is an invalid layout (Flet's grey box)
ft.Row([C.body(tr("Theme"), T.TEXT, weight=ft.FontWeight.W_500), ft.Container(width=T.S4),
C.ghost(tr("Copy this theme as a file"), ft.Icons.CONTENT_COPY_ROUNDED,
lambda e: (app.copy(T.theme_json(T.THEME)),
app.toast(tr("Copied: save it as a .json file, change the colours and "
"install it")))),
C.secondary(tr("Install theme file…"), ft.Icons.FILE_OPEN_OUTLINED, install)],
spacing=T.S2, wrap=True, vertical_alignment=ft.CrossAxisAlignment.CENTER),
ft.Row([card(t) for t in T.THEMES], spacing=T.S3, run_spacing=T.S3, wrap=True,
vertical_alignment=ft.CrossAxisAlignment.START),
], spacing=T.S2)
def appearance(self) -> ft.Control:
from ...core import library
@@ -244,7 +333,7 @@ class SettingsView:
dd = ft.Dropdown(label=tr("Text and layout size"), value=str(current) if current != "auto" else "auto",
options=options, width=T.px(260), on_select=changed)
controls = [dd, note]
controls = [self.theme_picker(), ft.Container(height=T.S2), dd, note]
languages = i18n.available()
if len(languages) > 1: # only once a translation exists
def language_changed(e):
+1 -3
View File
@@ -63,9 +63,7 @@ class WelcomeView:
g_state = "done" if library.games() else "todo"
return ft.Column([
ft.Container(height=T.S5),
ft.Row([ft.Container(ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=T.px(30), color=T.ON_ACCENT),
width=T.px(56), height=T.px(56), border_radius=T.px(16), bgcolor=T.ACCENT,
alignment=ft.Alignment.CENTER),
ft.Row([C.logo(T.px(60)),
ft.Column([C.title(tr("Welcome to FramePort")),
C.body(tr("Play your Quest, Android and PC VR games on the Steam Frame. Three steps "
"and you're set."))], spacing=T.px(2))], spacing=T.S4),
+146
View File
@@ -0,0 +1,146 @@
"""Colour themes (ui.theme: built-ins and installed theme files) and FramePort's own icons (ui.glyphs)."""
from __future__ import annotations
import ast
import json
from pathlib import Path
import flet as ft
import pytest
from frameport.ui import components as C
from frameport.ui import glyphs
from frameport.ui import theme as T
from frameport.ui.views import activity
ROOT = Path(__file__).resolve().parents[1]
UI = ROOT / "src" / "frameport" / "ui"
@pytest.fixture(autouse=True)
def default_theme():
T.set_theme(T.DEFAULT_THEME)
yield
for tid in [t for t in T.THEMES if t.startswith(T.USER_PREFIX)]:
del T.THEMES[tid]
T.set_theme(T.DEFAULT_THEME)
def test_builtin_themes():
assert list(T.BUILTIN) == ["portal", "portal_oled", "original"]
for tid, theme in T.BUILTIN.items():
assert set(theme["colors"]) == set(T.TOKENS), tid
assert all(v.startswith("#") and len(v) == 7 for v in theme["colors"].values()), tid
T.parse_theme({"base": tid, "colors": theme["colors"]}) # every built-in passes the theme-file checks
assert T.BUILTIN["portal_oled"]["colors"]["BG"] == "#000000"
assert T.BUILTIN["portal"]["dual"] and not T.BUILTIN["original"]["dual"]
def test_set_theme_switches_tokens_and_falls_back():
T.set_theme("original")
assert (T.THEME, T.ACCENT, T.DUAL) == ("original", T.BUILTIN["original"]["colors"]["ACCENT"], False)
T.set_theme("classic") # the earlier name
assert T.THEME == "original"
T.set_theme("no-such-theme")
assert (T.THEME, T.ACCENT, T.DUAL) == (T.DEFAULT_THEME, T.BUILTIN[T.DEFAULT_THEME]["colors"]["ACCENT"], True)
assert T.theme_from_setting(None) == T.DEFAULT_THEME
def test_module_level_maps_follow_the_theme():
T.set_theme("original")
assert C.STATUS_STYLE["works"][1] == T.BUILTIN["original"]["colors"]["OK"]
assert C.INSTALL_STYLE["on_pc"][2] == T.BUILTIN["original"]["colors"]["PC"]
assert activity.STATE_STYLE["running"][1] == T.BUILTIN["original"]["colors"]["ACCENT"]
T.set_theme("portal")
assert C.STATUS_STYLE["works"][1] == T.BUILTIN["portal"]["colors"]["OK"]
assert activity.CHECK_ICON[False][1] == T.BUILTIN["portal"]["colors"]["ERROR"]
def test_no_theme_colour_is_frozen_in_a_default_argument():
"""A default like color=T.TEXT keeps the colour of the theme active at import (a theme switch wouldn't reach it)."""
found = []
for f in UI.rglob("*.py"):
for node in ast.walk(ast.parse(f.read_text(encoding="utf-8"))):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
for d in node.args.defaults + [d for d in node.args.kw_defaults if d is not None]:
for sub in ast.walk(d):
if isinstance(sub, ast.Attribute) and isinstance(sub.value, ast.Name) \
and sub.value.id == "T" and sub.attr in T.TOKENS:
found.append(f"{f.name}:{d.lineno} T.{sub.attr}")
# updater._notes_style's inner text() is defined anew on every call: its default is current
assert [x for x in found if not x.startswith("updater.py")] == []
def test_theme_file_partial_and_validated():
t = T.parse_theme({"name": "Ember", "colors": {"accent": "#f80", "secondary": "#00A0FF"}})
assert t["name"] == "Ember" and t["dual"] is True
assert t["colors"]["ACCENT"] == "#FF8800" and t["colors"]["SECONDARY"] == "#00A0FF"
assert t["colors"]["BG"] == T.BUILTIN["portal"]["colors"]["BG"] # the rest comes from the base
assert T.parse_theme({"base": "original", "colors": {}})["dual"] is False
bad = [({"colors": {"ACCENT": "orange"}}, "not a colour"),
({"colors": {"GLOW": "#FFFFFF"}}, "unknown colour"),
({"colors": {"BG": "#F4F4F4", "TEXT": "#111111"}}, "too light"),
({"colors": {"TEXT": "#202020"}}, "hard to read"),
({"base": "nope"}, "isn't a built-in"),
({"colours": {}}, "unknown field"),
([1, 2], "isn't a theme")]
for data, message in bad:
with pytest.raises(T.ThemeError, match=message):
T.parse_theme(data)
def test_install_load_and_remove_theme_files(tmp_path):
src = tmp_path / "download.json"
src.write_text(json.dumps({"name": "Ember Night", "colors": {"ACCENT": "#FFAA00"}}), encoding="utf-8")
folder = tmp_path / "themes"
tid = T.install_theme(src, folder)
assert tid == "user:ember-night" and (folder / "ember-night.json").is_file()
T.set_theme(tid)
assert (T.THEME, T.ACCENT) == (tid, "#FFAA00")
(folder / "broken.json").write_text("{not json", encoding="utf-8")
problems = T.load_user_themes(folder)
assert tid in T.THEMES and problems and problems[0].startswith("broken.json: not valid JSON")
T.remove_theme(tid)
assert tid not in T.THEMES and not (folder / "ember-night.json").exists()
assert T.theme_from_setting(tid) == T.DEFAULT_THEME # a removed theme falls back
with pytest.raises(T.ThemeError):
T.remove_theme("portal")
def test_exported_theme_reinstalls(tmp_path):
f = tmp_path / "mine.json"
f.write_text(T.theme_json("portal_oled"), encoding="utf-8")
assert T.read_theme_file(f)["colors"] == T.BUILTIN["portal_oled"]["colors"]
def test_documented_example_theme_is_valid():
t = T.read_theme_file(ROOT / "docs" / "themes" / "example-theme.json")
assert t["name"] and t["dual"]
def test_glyphs_install_and_tint(tmp_path):
folder = glyphs.install(tmp_path)
names = {p.stem for p in folder.glob("*.svg")}
assert {"logo", "logo-solid", "frame", "port", "test", "pc"} <= names
assert glyphs.path(glyphs.FRAME, tmp_path) == folder / "frame.svg"
before = (folder / "frame.svg").stat().st_mtime_ns
glyphs.install(tmp_path) # unchanged files aren't rewritten
assert (folder / "frame.svg").stat().st_mtime_ns == before
assert glyphs.is_glyph(glyphs.TEST) and not glyphs.is_glyph(ft.Icons.TUNE_ROUNDED)
def test_wordmark_portal_follows_the_theme(tmp_path):
a = glyphs.portal_mark(tmp_path, "#ff8a1f", "#3AA8FF", "#0D0E12")
svg = a.read_text(encoding="utf-8")
assert "#FF8A1F" in svg and "#3AA8FF" in svg and "#0D0E12" in svg
assert glyphs.portal_mark(tmp_path, "#FF8A1F", "#3AA8FF", "#0D0E12") == a # cached per colour set
assert glyphs.portal_mark(tmp_path, "#FF5A36", "#5CE1E6", "#0D0E12") != a # an installed theme gets its own
T.set_theme("original")
assert isinstance(C.wordmark(17), ft.Text) # single-accent themes keep the plain wordmark
def test_as_icon_turns_glyphs_into_tinted_images():
img = C.as_icon(glyphs.FRAME, 20, T.ACCENT)
assert isinstance(img, ft.Image) and img.color == T.ACCENT and img.src.endswith("frame.svg")
assert isinstance(C.as_icon(ft.Icons.TUNE_ROUNDED, 20), ft.Icon)
assert C._btn_icon(ft.Icons.TUNE_ROUNDED, T.TEXT) == ft.Icons.TUNE_ROUNDED # buttons colour Material icons