Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bfdfbd0e8 | ||
|
|
19addc1570 | ||
|
|
1f35c77a69 | ||
|
|
5973b6c85c | ||
|
|
d74dc1aa61 | ||
|
|
7b14207f81 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
After Width: | Height: | Size: 248 KiB |
@@ -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.": "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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."))),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||