Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3dee5425e | ||
|
|
676b025c5b | ||
|
|
fac72d17c8 | ||
|
|
a2fa6021f5 | ||
|
|
c492e662cb | ||
|
|
ce803340b0 | ||
|
|
4be34d20ca | ||
|
|
a4e8e20fcd | ||
|
|
fc1bee2673 | ||
|
|
ad8847509b | ||
|
|
627c05bf98 | ||
|
|
b82b714165 | ||
|
|
0bfdfbd0e8 | ||
|
|
19addc1570 | ||
|
|
1f35c77a69 | ||
|
|
5973b6c85c | ||
|
|
d74dc1aa61 | ||
|
|
7b14207f81 |
No files matched your search
@@ -10,8 +10,20 @@ 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,
|
||||
status_row, art_fill, confirm, `update()` = safe update: in Flet 1.0 reading `.page` of an unmounted control
|
||||
`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,
|
||||
checklist, 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
|
||||
"Customize"; frame: device + readiness + installed, or connect wizard; files: the Frame's file manager (persistent
|
||||
@@ -53,6 +65,14 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
await a FilePicker) must be coroutine functions or go through `page.run_task`: Flet doesn't await a lambda's
|
||||
coroutine (the Files row Download button silently did nothing). `ui_smoke.py --fake-frame --gestures` drives real
|
||||
mouse drags/right-clicks.
|
||||
Live Frame data comes from one app-owned `app.monitor_hub` (`frame/monitor_hub.py`): subscribers name modules +
|
||||
interval, the hub runs one agent `_monitor` stream (union of modules, fastest interval; per-module collection
|
||||
needs agent v63), reconnects after a lost stream and stops it when nobody subscribes; `_poll` retries an "error".
|
||||
Subscribers: "monitor" (the Monitor tab, everything, only while shown) and "card" = the sidebar's live Frame card
|
||||
(`ui/frame_card.py` helpers: battery ring, "now playing" row with fps + 2-min sparkline → click opens Monitor;
|
||||
games + battery every 5 s, paused while a job's stage is "Upload…", only while connected and setting
|
||||
`ui.live_frame_card` (Settings → Appearance, default on) is on; else the battery comes from the 30 s poll).
|
||||
ui_smoke injects `FakeMonitorSession` as the hub's `session_factory`.
|
||||
User tags live in library entries (`tags`), filters in library setting `ui.library`.
|
||||
**Performance rules** (the app froze before): never put image bytes in controls — artwork is served by URL from the
|
||||
GUI assets dir (= user data dir; `ft.run(assets_dir=…)`), as thumbnails (`artwork/thumbs.py`, Pillow); the
|
||||
@@ -61,8 +81,18 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
**Never recreate clickable controls on progress ticks** (sidebar, activity tiles): update their properties —
|
||||
replacing them 5×/s swallowed clicks (couldn't leave the Library during an upload).
|
||||
Labelled switches: `C.switch(label, …)` (Material's default label colour is dark on our dark theme).
|
||||
Setup checklists (Frame → Ready to play, Settings → Tools / This PC) = `C.checklist([C.Check(ok, title, detail,
|
||||
help, fix=(label, icon, handler), extra=control)])`: ok True/False/"warn"/None (= spinner); the fix button shows
|
||||
inline only while an item isn't ready — wire existing actions only. Motion: `app.body` holds an
|
||||
`ft.AnimatedSwitcher` (`app._show_view`): a new route gets a new wrapper (150 ms fade), a same-route redraw swaps
|
||||
the wrapper's content (no animation; persistent views just remount); the wordmark's portal (`wordmark().data`)
|
||||
pulses once when frame_state turns "connected"; both off with setting `ui.reduce_motion` (Settings → Appearance
|
||||
"Reduce motion", `app.reduce_motion`). Settings has a fixed index column (`settings.SECTIONS` order, Appearance
|
||||
first, Remove FramePort last): entries scroll the section column via `scroll_to(scroll_key=…)` (sections wrapped
|
||||
in Containers with `ft.ScrollKey`), the header stays; `app.settings_view.show_section(key)` (ui_smoke
|
||||
`settings-index`).
|
||||
Help hints: wording for non-obvious terms lives in `ui/help.py` (`HELP`); show it with `C.help_icon(key)` or the
|
||||
`help=` argument of `section`/`status_row`/`kv`, tooltips via `C.tip()` (wraps). Game actions for the Library
|
||||
`help=` argument of `section`/`kv` (`C.Check` help key), tooltips via `C.tip()` (wraps). Game actions for the Library
|
||||
right-click menu (one `ft.ContextMenu` around the grid, filled on right-click) and the game page's "…" menu come
|
||||
from `app.game_actions()`. Picking art (`sources.apply_choice`) downloads into a staging dir and keeps the old
|
||||
art if nothing came back; "Update Steam art on Frame" re-sends the art set to the game's anchor (agent ≥ 12).
|
||||
|
||||
@@ -109,7 +109,7 @@ will attempt to guess key patches. If you find a new config that works for an ap
|
||||
|
||||
**[List of tested games](docs/GAMES.md)**
|
||||
|
||||
**Tested something? Share it.** In FramePort open the game → **…** → **Share working config…** (it fills in the
|
||||
**Tested something? Share it.** In FramePort open the game → **…** → **Share working recipe…** (it fills in the
|
||||
recipe for you) or **Report a problem…** (attaches a diagnostics zip with personal data removed). Without the app:
|
||||
[share a working config](https://github.com/spoopyghosty0/frameport/issues/new?template=working-config.yml) · [report a problem](https://github.com/spoopyghosty0/frameport/issues/new?template=bug-report.yml). Shared configs become built-in recipes for everyone.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ import time
|
||||
import zlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
AGENT_VERSION = 62
|
||||
AGENT_VERSION = 63
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -3838,6 +3838,8 @@ MON_INTERVALS = (0.1, 0.25, 0.5, 1, 2, 5) # seconds between samples; processes
|
||||
MON_DEFAULT_INTERVAL = 0.5
|
||||
SCAN_SECONDS = 2.0
|
||||
MON_FILTERS = ("game", "steam", "all")
|
||||
# what a client can ask for ({"modules": [...]}, agent v63); without that message every module is collected
|
||||
MON_MODULES = ("games", "procs", "cpu", "gpu", "mem", "temps", "power", "battery", "net", "disk")
|
||||
MON_PROC_LIMIT = 150
|
||||
MON_CONTEXT = 3 # "game" filter: the busiest other processes, shown for context
|
||||
# processes never signalled: the session, SSH and system plumbing (killing them logs the user out or drops FramePort)
|
||||
@@ -3882,6 +3884,20 @@ def _num(text, default=None):
|
||||
return default
|
||||
|
||||
|
||||
def monitor_plan(modules=None):
|
||||
"""The collectors one tick runs for the requested modules (None, "all" or anything unusable = all of them).
|
||||
mem includes PSI, temps the zone detail and the fan. The process scan ("scan") runs for games and procs and also
|
||||
for gpu: GPU busy is summed from the render fds the scan finds; "gpu_ns" (per-process GPU time) for the same."""
|
||||
if modules is None or modules == "all" or not isinstance(modules, (list, tuple, set, frozenset)):
|
||||
wanted = set(MON_MODULES)
|
||||
else:
|
||||
wanted = {m for m in modules if m in MON_MODULES}
|
||||
plan = set(wanted)
|
||||
if wanted & {"games", "procs", "gpu"}:
|
||||
plan |= {"scan", "gpu_ns"}
|
||||
return frozenset(plan)
|
||||
|
||||
|
||||
def read_cpu_times():
|
||||
"""[total, core0, core1, …] as (busy, all) jiffies from /proc/stat."""
|
||||
out = []
|
||||
@@ -4153,6 +4169,7 @@ class Monitor:
|
||||
self.page = os.sysconf("SC_PAGE_SIZE") if hasattr(os, "sysconf") else 4096
|
||||
self.btime = next((int(x.split()[1]) for x in _rd(f"{PROC}/stat").splitlines() if x.startswith("btime ")), 0)
|
||||
self.filter = "game"
|
||||
self.plan = monitor_plan() # collectors per tick (set_modules)
|
||||
self.kthreads = set()
|
||||
self.drm = {} # pid -> (fds, time of the fd scan)
|
||||
self.gpu_ns = {} # pid -> last drm-engine ns
|
||||
@@ -4184,7 +4201,15 @@ class Monitor:
|
||||
"clusters": [{"cpus": c["cpus"], "max_mhz": c["max_mhz"]} for c in self.clusters],
|
||||
"mem_total": read_meminfo()["total"], "rails": [r[0] for r in self.rails],
|
||||
"temp_groups": sorted({z[0] for z in self.zones}, key=[g for g, _ in TEMP_GROUPS].index),
|
||||
"fan": bool(self.fan), "intervals": list(MON_INTERVALS), "filters": list(MON_FILTERS)}
|
||||
"fan": bool(self.fan), "intervals": list(MON_INTERVALS), "filters": list(MON_FILTERS),
|
||||
"modules": list(MON_MODULES)}
|
||||
|
||||
def set_modules(self, modules):
|
||||
"""Collect only these modules from the next tick on (None/"all" = everything)."""
|
||||
plan = monitor_plan(modules)
|
||||
if ("scan" in plan and "scan" not in self.plan) or ("procs" in plan and "procs" not in self.plan):
|
||||
self.scan_at = -1e9 # processes wanted (again): scan at once
|
||||
self.plan = plan
|
||||
|
||||
# -------------------------------------------------------------------- games
|
||||
def deployments(self, now):
|
||||
@@ -4391,53 +4416,73 @@ class Monitor:
|
||||
dt = now - self.last if self.last is not None else 0.0
|
||||
self.last = now
|
||||
out = {"t": round(wall, 3), "dt": round(dt, 3)}
|
||||
cur = read_cpu_times()
|
||||
if self.cpu_prev:
|
||||
pct = cpu_percent(self.cpu_prev, cur)
|
||||
out["cpu"] = {"total": pct[0] if pct else 0.0, "cores": pct[1:]}
|
||||
plan = self.plan
|
||||
if "cpu" in plan:
|
||||
cur = read_cpu_times()
|
||||
if self.cpu_prev:
|
||||
pct = cpu_percent(self.cpu_prev, cur)
|
||||
out["cpu"] = {"total": pct[0] if pct else 0.0, "cores": pct[1:]}
|
||||
else:
|
||||
out["cpu"] = {"total": 0.0, "cores": [0.0] * self.ncpu}
|
||||
self.cpu_prev = cur
|
||||
out["cpu"]["mhz"] = [_num(_rd(f"{c['policy']}/scaling_cur_freq"), 0) // 1000 for c in self.clusters]
|
||||
else:
|
||||
out["cpu"] = {"total": 0.0, "cores": [0.0] * self.ncpu}
|
||||
self.cpu_prev = cur
|
||||
out["cpu"]["mhz"] = [_num(_rd(f"{c['policy']}/scaling_cur_freq"), 0) // 1000 for c in self.clusters]
|
||||
out["mem"] = read_meminfo()
|
||||
out["psi"] = read_psi()
|
||||
if now - self.temps_at >= 2 or self.temps is None: # 48 sensor files (~3 ms at idle clocks); heat is slow
|
||||
self.temps, self.zone_detail = read_temps(self.zones)
|
||||
self.temps_at = now
|
||||
temps, detail = self.temps, self.zone_detail
|
||||
out["temps"] = temps
|
||||
if now - self.zone_detail_at >= 5:
|
||||
out["zones"] = detail
|
||||
self.zone_detail_at = now
|
||||
if self.fan:
|
||||
out["fan"] = _num(_rd(self.fan))
|
||||
out["power"] = self.power.read(now)
|
||||
out["battery"] = self.battery.read(now)
|
||||
net = read_net()
|
||||
if self.net_prev and dt > 0:
|
||||
prev = self.net_prev
|
||||
out["net"] = {k: [max(0, int((v[0] - prev[k][0]) / dt)), max(0, int((v[1] - prev[k][1]) / dt))]
|
||||
for k, v in net.items() if k in prev}
|
||||
self.net_prev = net
|
||||
if now - self.disk_at >= 30:
|
||||
self.cpu_prev = None # no average over the time it was off
|
||||
if "mem" in plan:
|
||||
out["mem"] = read_meminfo()
|
||||
out["psi"] = read_psi()
|
||||
if "temps" in plan:
|
||||
if now - self.temps_at >= 2 or self.temps is None: # 48 sensor files (~3 ms at idle clocks); heat is slow
|
||||
self.temps, self.zone_detail = read_temps(self.zones)
|
||||
self.temps_at = now
|
||||
temps, detail = self.temps, self.zone_detail
|
||||
out["temps"] = temps
|
||||
if now - self.zone_detail_at >= 5:
|
||||
out["zones"] = detail
|
||||
self.zone_detail_at = now
|
||||
if self.fan:
|
||||
out["fan"] = _num(_rd(self.fan))
|
||||
if "power" in plan:
|
||||
out["power"] = self.power.read(now)
|
||||
if "battery" in plan:
|
||||
out["battery"] = self.battery.read(now)
|
||||
if "net" in plan:
|
||||
net = read_net()
|
||||
if self.net_prev and dt > 0:
|
||||
prev = self.net_prev
|
||||
out["net"] = {k: [max(0, int((v[0] - prev[k][0]) / dt)), max(0, int((v[1] - prev[k][1]) / dt))]
|
||||
for k, v in net.items() if k in prev}
|
||||
self.net_prev = net
|
||||
else:
|
||||
self.net_prev = None # a delta over one tick's dt needs the previous tick's counters
|
||||
if "disk" in plan and now - self.disk_at >= 30:
|
||||
try:
|
||||
st = os.statvfs(HOME)
|
||||
out["disk"] = {"free": st.f_bavail * st.f_frsize, "total": st.f_blocks * st.f_frsize}
|
||||
except OSError:
|
||||
pass
|
||||
self.disk_at = now
|
||||
scan = now - self.scan_at >= SCAN_SECONDS or not self.procs
|
||||
scan = "scan" in plan and (now - self.scan_at >= SCAN_SECONDS or not self.procs)
|
||||
if scan:
|
||||
self.scan(now, wall)
|
||||
self.scan_at = now
|
||||
busy = self.gpu_sample(now, dt)
|
||||
out["gpu"] = {"busy": round(busy, 1) if dt > 0 else None,
|
||||
"mhz": _num(_rd(f"{self.gpu}/cur_freq"), 0) // 1000000 if self.gpu else None}
|
||||
out["games"] = self.games_sample(now)
|
||||
if scan:
|
||||
if "gpu_ns" in plan:
|
||||
busy = self.gpu_sample(now, dt)
|
||||
else:
|
||||
busy = None
|
||||
self.gpu_ns, self.gpu_pct, self.gpu_acc = {}, {}, {} # stale counters would spike on the next delta
|
||||
if "gpu" in plan:
|
||||
out["gpu"] = {"busy": round(busy, 1) if dt > 0 and busy is not None else None,
|
||||
"mhz": _num(_rd(f"{self.gpu}/cur_freq"), 0) // 1000000 if self.gpu else None}
|
||||
if "games" in plan:
|
||||
out["games"] = self.games_sample(now)
|
||||
if scan and "procs" in plan:
|
||||
out["procs"] = self.filtered(now)
|
||||
out["filter"] = self.filter
|
||||
self.scan_prev = now
|
||||
elif scan:
|
||||
self.gpu_acc = {}
|
||||
self.scan_prev = now
|
||||
self.tick += 1
|
||||
out["self_ms"] = round((time.process_time() - t0) * 1000, 2)
|
||||
return out
|
||||
@@ -4524,9 +4569,10 @@ def monitor_end_game(pkg, wait=6.0):
|
||||
|
||||
def monitor_session(stdin, stdout, monitor=None, sleep=time.sleep, max_ticks=None):
|
||||
"""Long-lived: prints {"ready": 1, "static": {...}}, then one sample per tick. Control lines on stdin:
|
||||
{"interval": 1|2|5}, {"procs": "game"|"steam"|"all"}, {"pause": bool}, {"id": n, "kill": pid, "sig": "TERM"|
|
||||
"KILL", "force": bool}, {"id": n, "end_game": package} (each with an id gets {"reply": n, "ok": …}). Ends at EOF
|
||||
or when the SSH session (parent) is gone."""
|
||||
{"interval": 1|2|5}, {"procs": "game"|"steam"|"all"}, {"pause": bool}, {"modules": [...]|"all"} (v63: only those
|
||||
collectors run, see monitor_plan), {"id": n, "kill": pid, "sig": "TERM"|"KILL", "force": bool}, {"id": n,
|
||||
"end_game": package} (each with an id gets {"reply": n, "ok": …}). Ends at EOF or when the SSH session (parent)
|
||||
is gone."""
|
||||
import threading
|
||||
|
||||
mon = monitor or Monitor()
|
||||
@@ -4568,6 +4614,8 @@ def monitor_session(stdin, stdout, monitor=None, sleep=time.sleep, max_ticks=Non
|
||||
mon.scan_at = -1e9 # rescan now
|
||||
if "pause" in msg:
|
||||
state["pause"] = bool(msg["pause"])
|
||||
if "modules" in msg and hasattr(mon, "set_modules"):
|
||||
mon.set_modules(msg["modules"])
|
||||
if "kill" in msg or "end_game" in msg:
|
||||
threading.Thread(target=act, args=(msg,), daemon=True).start()
|
||||
continue
|
||||
|
||||
@@ -6,7 +6,7 @@ Other games get suggested patches from detection rules; each suggestion states i
|
||||
switched on or off under **Customize**: described in plain words, with **Show technical details** for the exact
|
||||
effect of each patch.
|
||||
|
||||
Tried an untested game? Its page asks how it runs; **Share working config…** opens a prefilled GitHub issue so your
|
||||
Tried an untested game? Its page asks how it runs; **Share working recipe…** opens a prefilled GitHub issue so your
|
||||
recipe can join the built-in catalog for everyone.
|
||||
|
||||

|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Two ways users feed results back, both without a GitHub token:
|
||||
|
||||
- **Share working config** (game menu, `frameport share-recipe <pkg>`): saves the recipe as a user catalog entry and
|
||||
- **Share working recipe** (game menu, `frameport share-recipe <pkg>`): saves the recipe as a user catalog entry and
|
||||
opens a prefilled issue from `.github/ISSUE_TEMPLATE/working-config.yml`. A maintainer checks it and adds the label
|
||||
`catalog-accepted`. Then `.github/workflows/catalog-from-issue.yml` runs `scripts/catalog_from_issue.py`, which reads
|
||||
only the YAML block, validates it, and writes `catalog/games/<pkg>.yaml`, and the workflow opens a PR.
|
||||
|
||||
@@ -24,7 +24,7 @@ Games/ ← scan this folder (Add games → Scan
|
||||
as alternates.
|
||||
- **Scanning:** pick the folder that contains the game folders (`Games/` above) to add them all, or one game's folder
|
||||
to add just that game. FramePort looks up to 5 levels deep.
|
||||
- **A single game:** Add games → Add an APK file… works with a lone APK too. Its data is found when the data folder sits next
|
||||
- **A single game:** Add games → Add an APK… works with a lone APK too. Its data is found when the data folder sits next
|
||||
to the APK, named as above.
|
||||
|
||||
Not sure of the package name? Look at the OBB file names: `main.<version>.<package name>.obb`. Or add the APK
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Tested games
|
||||
|
||||
Games tested on the Steam Frame with FramePort's recipes. Games not listed here may work too: FramePort suggests patches for them, and a working config can be shared from the app (**Share working config…**).
|
||||
Games tested on the Steam Frame with FramePort's recipes. Games not listed here may work too: FramePort suggests patches for them, and a working recipe can be shared from the app (**Share working recipe…**).
|
||||
Tested a game? [Share a working config](https://github.com/spoopyghosty0/frameport/issues/new?template=working-config.yml) or [report a problem](https://github.com/spoopyghosty0/frameport/issues/new?template=bug-report.yml) (in the app: the game's **…** menu does both and fills in the details).
|
||||
Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ which would end Desktop Mode). This is new: please report anything odd with **Re
|
||||
back/home/recents buttons are hidden by default (patch **Hide Android's navigation bar**). If FramePort guesses
|
||||
wrong (a phone app shows nothing in the headset, or a VR app opens as a flat window), choose **VR** or **Flat
|
||||
window** under **Show as VR or as a flat window** in the game's **Customize** section, then **Update on Frame**.
|
||||
- **Add games → Add a Windows program (.exe)…** adds a single Windows program; the Frame runs it through Proton (as a
|
||||
- **Add games → Add a Windows program…** adds a single Windows program; the Frame runs it through Proton (as a
|
||||
window unless it is a VR game). A program sitting in Downloads, the home folder or a drive root is copied on its
|
||||
own first, so the install doesn't upload everything next to it.
|
||||
- In the packaged app you can also **drag files onto the Library**: APKs, Linux apps (AppImage, `.zip`/`.tar.gz`),
|
||||
@@ -109,7 +109,7 @@ sideloader, documented at framedropvr.com/docs). FramePort understands the same
|
||||
the title, the files, their size and whether a checksum is given, and asks before it downloads anything. Then it
|
||||
downloads the build, adds it to your library and starts the usual install on the Frame. If no Frame is connected,
|
||||
the game is added now and installs once the Frame is back.
|
||||
- **Add games → Install from a link…** takes the button's address (right-click → Copy link), a `framedrop://` or
|
||||
- **Add games → Add from a link…** takes the button's address (right-click → Copy link), a `framedrop://` or
|
||||
`frameport://` link, a manifest (`.json`) or a direct link to an APK, a Linux build or a Windows program. Use it on
|
||||
macOS, where web pages can't hand links to FramePort yet.
|
||||
- **Settings → Install links** has one switch for `framedrop://` links (the buttons) and one for FramePort's own
|
||||
@@ -134,7 +134,7 @@ reality), showing only what matters for that game. Changes are kept with the gam
|
||||
|
||||
## Typing on the Frame
|
||||
|
||||
- **Type on Frame** (its own tab in the sidebar; also on the Steam Frame page and in a game's menu): while the tab is
|
||||
- **Type on Frame** (its own tab in the sidebar; also on the Steam Frame page): while the tab is
|
||||
open, this computer's keyboard works as a keyboard plugged into the Frame. Select a text field in the headset (in
|
||||
an app, Steam or the desktop) and type; Esc and shortcuts go to the Frame too. Paste longer text into the box to
|
||||
type it in one go (US keyboard layout). Opening another tab disconnects the keyboard.
|
||||
@@ -185,7 +185,7 @@ Command line: `frameport update` (`--check` only checks, exit code 10 = update a
|
||||
## PC VR games
|
||||
|
||||
PC VR games are Windows VR games (OpenXR, SteamVR or Oculus). Scan a folder of them (one folder per game) or use
|
||||
**Add games → Add one game folder…**. FramePort finds the game's program and asks when there is more than one
|
||||
**Add games → Add a PC game folder…**. FramePort finds the game's program and asks when there is more than one
|
||||
candidate. **Already patched** on a game page installs a copy unchanged. Oculus-only games need Revive: FramePort uses
|
||||
an installed Revive, or downloads a portable copy.
|
||||
|
||||
@@ -196,7 +196,7 @@ an installed Revive, or downloads a portable copy.
|
||||
on the game page.
|
||||
- Games that use the Oculus Platform SDK check the licence through the Oculus app, so they run on the PC only.
|
||||
|
||||
**Windows games without VR:** **Add games → Add one game folder…** with the game's folder (pick the program that
|
||||
**Windows games without VR:** **Add games → Add a PC game folder…** with the game's folder (pick the program that
|
||||
starts it if asked). FramePort installs it on the Frame and Proton runs it as a window, like Steam's own Windows games;
|
||||
it shows up in the Frame's Steam library tagged "Windows game on Frame". Whether a game runs depends on Proton on ARM
|
||||
(x86 games run through emulation).
|
||||
@@ -241,7 +241,7 @@ Command line: `frameport frame send <files> --dest videos` (`frameport frame sto
|
||||
|
||||
## Sharing a working game, reporting a problem
|
||||
|
||||
- **Share working config…** (game menu): opens a prefilled GitHub issue with the game's patches and settings. Accepted
|
||||
- **Share working recipe…** (game menu): opens a prefilled GitHub issue with the game's patches and settings. Accepted
|
||||
configs become built-in recipes. Untested games ask on their page once they've been installed or tested: **It
|
||||
works**, **It has issues** or **It doesn't run**.
|
||||
- **Report a problem…** (game menu, or Settings → Problems and feedback): saves a diagnostics zip to Documents (logs,
|
||||
|
||||
@@ -88,7 +88,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
|---|---|---|
|
||||
| 2D Android app: Steam says it's running but nothing shows | Lepton runs apps headless unless the app folder has `lepton-show-flatscreen` | FramePort adds it for apps without VR (agent v28+); reinstall apps installed before |
|
||||
| A phone/tablet app shows nothing in the headset, or a VR app opens as a flat window | FramePort's VR/2D guess (`vr_kind`) is wrong for this app | patch `device.display_mode` → **Flat window** or **VR** (Customize → "Show as VR or as a flat window"), then Update on Frame |
|
||||
| "Install with FrameDrop" button opens FrameDrop (or nothing) instead of FramePort | another program owns `framedrop://`, or the switch is off, or macOS (Flet can't receive links there) | Settings → Install links → **Use FramePort for these links**; else Add games → Install from a link… (paste the button's address) |
|
||||
| "Install with FrameDrop" button opens FrameDrop (or nothing) instead of FramePort | another program owns `framedrop://`, or the switch is off, or macOS (Flet can't receive links there) | Settings → Install links → **Use FramePort for these links**; else Add games → Add from a link… (paste the button's address) |
|
||||
| 2D Android app: back/home/recents buttons cover the app's own buttons | Android's navigation bar | patch `device.hide_navbar` (on by default for apps without VR) sets `qemu.hw.mainkeys=1`; reinstall to apply |
|
||||
| Black screen, audio works, GLES engine with direct VrApi | Mesa rejects Quest-style GLSL | `frame.gl_shim` (logs `GLShim: SHADER COMPILE FAILED` + source lines) |
|
||||
| Black screen, audio works, log `Unsupported VrApi layer type N` | bridge drops frames containing that layer | extend the bridge (cylinder=3 is converted to a quad already) |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ def render() -> str:
|
||||
rows.append(f"| {e.title} | {platform} | {STATUS[e.status]} | {note} |")
|
||||
return ("# Tested games\n\n"
|
||||
"Games tested on the Steam Frame with FramePort's recipes. Games not listed here may work too: FramePort "
|
||||
"suggests patches for them, and a working config can be shared from the app (**Share working config…**).\n"
|
||||
"suggests patches for them, and a working recipe can be shared from the app (**Share working recipe…**).\n"
|
||||
f"Tested a game? [Share a working config]({SHARE}) or [report a problem]({REPORT}) (in the app: the game's "
|
||||
"**…** menu does both and fills in the details).\n"
|
||||
"Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.\n\n"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,7 +70,11 @@ class FakeFS:
|
||||
owners = [(g["package"], g.get("title") or g["package"], arts) for g, arts, _real in games[:3]]
|
||||
spare = games[3][1] if len(games) > 3 else [] # SteamVR's own shots (taken outside a FramePort game)
|
||||
owners.append((None, "SteamVR", spare))
|
||||
# the same days layout at any time of day (6 shots on the first day, 4 on the one before; --gestures drags
|
||||
# across the first row): shortly after midnight the shots start the evening before
|
||||
now, shots = time.time(), []
|
||||
if time.localtime(now).tm_hour < 5:
|
||||
now -= (time.localtime(now).tm_hour + 1) * 3600
|
||||
for i in range(10):
|
||||
t = now - i * 2400 - (86400 if i >= 6 else 0)
|
||||
name = time.strftime("%Y%m%d%H%M%S", time.localtime(t)) + "_1.jpg"
|
||||
@@ -249,12 +253,19 @@ class FakeMonitorSession:
|
||||
"procs": procs, "filter": "game"}
|
||||
|
||||
def _run(self) -> None:
|
||||
for self.t in range(100000):
|
||||
if self.closed:
|
||||
return
|
||||
self.t = 0
|
||||
while not self.closed and self.t < 100000:
|
||||
self.on_sample(self._sample())
|
||||
if self.t >= self.BACKFILL: # the first two minutes at once (full charts), then one per second
|
||||
time.sleep(1)
|
||||
self.t += 1
|
||||
|
||||
def set_modules(self, m):
|
||||
# the hub asks for everything when the Monitor tab subscribes (the Frame card started the stream): send the
|
||||
# two minutes again so its charts are full in screenshots too
|
||||
if m == "all":
|
||||
self.t = 0
|
||||
return True
|
||||
|
||||
def set_interval(self, s): pass # noqa: E704
|
||||
def set_filter(self, w): pass # noqa: E704
|
||||
@@ -270,6 +281,29 @@ class FakeMonitorSession:
|
||||
self.closed = True
|
||||
|
||||
|
||||
FAKE_INSTALL_STAGES = ["Patching the game", "OVRPort (primary)", "Frame fixes (primary)", "sign (primary)",
|
||||
"validate (primary)", "Prepare Frame", "Upload APK"]
|
||||
|
||||
|
||||
def start_fake_install(app: FramePortApp, package: str) -> None:
|
||||
"""A running install job of `package` that reports the real stage names up to the upload (62 %) and then waits
|
||||
(it never ends: the smoke run exits with it)."""
|
||||
from frameport.ui.jobs import Job
|
||||
|
||||
hold, reached = threading.Event(), threading.Event()
|
||||
|
||||
def run(job):
|
||||
for stage in FAKE_INSTALL_STAGES:
|
||||
job.reporter.stage(stage)
|
||||
time.sleep(0.3) # past the job queue's notification throttle
|
||||
job.reporter.progress(0.62, "com.example.apk", speed="36.4 MB/s · ~1 min left")
|
||||
reached.set()
|
||||
hold.wait()
|
||||
|
||||
app.jobs.submit(Job(f"Install {app._title(package)} on Frame", run, package, "install"))
|
||||
reached.wait(10)
|
||||
|
||||
|
||||
def open_type_tab(app: FramePortApp) -> None:
|
||||
"""The Type on Frame tab, with one key 'pressed' so the screenshot shows it working."""
|
||||
app.type_on_frame()
|
||||
@@ -315,18 +349,25 @@ def add_fake_linux_apps() -> None:
|
||||
pipeline.add_linux_app(app)
|
||||
|
||||
|
||||
STEP_SECONDS = 4 # per screen (the screenshot is taken ~3 s in)
|
||||
PAINT_SECONDS = 3 # after a step, before its screenshot: Flutter paints (and a view's content loads)
|
||||
STEP_TIMEOUT = 240 # the longest a step may wait for its screenshot (a crashed browser mustn't hang the run)
|
||||
|
||||
|
||||
def driver(app: FramePortApp, steps: list[tuple[str, callable]], ready: threading.Event, done_step: list):
|
||||
def driver(app: FramePortApp, steps: list[tuple[str, callable]], ready: threading.Event, shot_done: threading.Event,
|
||||
done_step: list):
|
||||
"""Runs one step, hands it to the shooter ("ready") and waits until its screenshot and mouse actions are done
|
||||
("shot_done") before the next one: so a screenshot always shows its own step, however long the shooter takes."""
|
||||
for name, action in steps:
|
||||
try:
|
||||
action(app)
|
||||
except Exception: # noqa: BLE001
|
||||
ERRORS.append(f"{name}: {traceback.format_exc()}")
|
||||
done_step.append(name)
|
||||
shot_done.clear()
|
||||
ready.set()
|
||||
time.sleep(STEP_SECONDS)
|
||||
if not shot_done.wait(STEP_TIMEOUT):
|
||||
ERRORS.append(f"{name}: no screenshot within {STEP_TIMEOUT} s, stopping")
|
||||
return
|
||||
|
||||
|
||||
def find_button(root, text: str):
|
||||
@@ -376,18 +417,18 @@ def main() -> int:
|
||||
"(a Frame cabled to this PC) and screenshot what follows")
|
||||
ap.add_argument("--docs", action="store_true", help="only the screens used in the docs (Library, --game, Frame)")
|
||||
ap.add_argument("--gestures", action="store_true", help="with --fake-frame: real mouse drags (drag-select) and "
|
||||
"right-clicks (menus) in Files, Screenshots and the Library; prints what got selected")
|
||||
"right-clicks (menus) in Files, Screenshots and the Library; prints what got selected "
|
||||
"(positions for --viewport 1440x900)")
|
||||
ap.add_argument("--only", default=None, help="comma-separated step names to keep (e.g. monitor,monitor-details)")
|
||||
ap.add_argument("--linux", action="store_true", help="add two pretend arm64 Linux apps and render their pages")
|
||||
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()
|
||||
global STEP_SECONDS
|
||||
if args.hover:
|
||||
STEP_SECONDS = 7
|
||||
from frameport.ui import theme
|
||||
|
||||
theme.set_scale(args.scale)
|
||||
@@ -398,15 +439,20 @@ def main() -> int:
|
||||
("files", lambda a: a.go("files")), ("screenshots", lambda a: a.go("screenshots")),
|
||||
("live", lambda a: a.go("live")), ("keyboard", lambda a: a.go("keyboard")),
|
||||
("monitor", lambda a: a.go("monitor")), ("monitor-details", open_monitor_details),
|
||||
("tools", lambda a: a.go("settings"))]
|
||||
("tools", lambda a: a.go("settings")),
|
||||
# the index on the left: "This PC" scrolls the page there
|
||||
("settings-index", lambda a: a.page.run_task(a.settings_view.show_section, "pc"))]
|
||||
if game:
|
||||
steps.insert(1, ("game", lambda a: a.open_game(game)))
|
||||
steps.insert(2, ("game-customize", lambda a: a.open_game(game, advanced=True)))
|
||||
steps += [("share-dialog", lambda a: a.share_config_dialog(game)),
|
||||
("report-dialog", lambda a: (a.page.pop_dialog(), a.report_problem_dialog(game))),
|
||||
("custom-art-dialog", lambda a: (a.page.pop_dialog(), a.custom_artwork(game)))]
|
||||
# a pretend install of --game, mid-upload: the transit in the sidebar, the game page and Activity
|
||||
steps += [("transit-game", lambda a: (a.page.pop_dialog(), start_fake_install(a, game), a.open_game(game))),
|
||||
("transit-activity", lambda a: a.show_activity(True))]
|
||||
# last: the right-click menu stays open over whatever comes next
|
||||
steps.append(("library-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
|
||||
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(game))))
|
||||
if args.docs:
|
||||
steps = [("library", lambda a: a.navigate(0))]
|
||||
@@ -422,10 +468,33 @@ def main() -> int:
|
||||
steps.append(("type-on-frame", lambda a: (a.page.pop_dialog(), open_type_tab(a))))
|
||||
steps.append(("monitor", lambda a: a.go("monitor")))
|
||||
steps.append(("power-confirm", lambda a: (a.page.pop_dialog(), a.frame_power("restart"))))
|
||||
mouse: dict[str, callable] = {} # step name -> mouse actions (Playwright page) after its screenshot
|
||||
if args.gestures:
|
||||
STEP_SECONDS = 14 # the mouse actions run after each step's screenshot, before the next step
|
||||
|
||||
# step name -> mouse actions (Playwright page) after its screenshot, then a "-mouse" screenshot unless the
|
||||
# actions return True (they saved their own picture, e.g. an opened menu)
|
||||
mouse: dict[str, callable] = {}
|
||||
hover: dict[str, tuple[float, float]] = {} # step name -> where the mouse rests for its screenshot
|
||||
if args.hover:
|
||||
hover["library"] = tuple(float(v) for v in args.hover.split(","))
|
||||
if not args.docs and args.viewport == "1440x900": # menus that open from buttons (positions at this size)
|
||||
def open_and_shoot(x, y, name):
|
||||
def run(page):
|
||||
page.mouse.click(x, y)
|
||||
time.sleep(2)
|
||||
page.screenshot(path=str(args.out / f"{name}.png"))
|
||||
page.mouse.click(1420, 880) # outside the menu (Flutter's popup ignores Escape)
|
||||
return True
|
||||
return run
|
||||
mouse["library"] = open_and_shoot(1354, 54, "library-add-menu")
|
||||
# type to search: click an empty part of the header, then type (the "-mouse" shot shows "arc" + the matches)
|
||||
steps.insert(1, ("library-typed", lambda a: a.library_view.clear_search()))
|
||||
mouse["library-typed"] = lambda page: (page.mouse.click(480, 60), time.sleep(0.5),
|
||||
page.keyboard.type("arc", delay=120))
|
||||
steps.insert(2, ("library-cleared", lambda a: a.library_view.clear_search()))
|
||||
if args.fake_frame: # the "On your Frame" shelf's first card, hovered: its Play button shows
|
||||
steps.insert(3, ("library-shelf-hover", lambda a: None))
|
||||
hover["library-shelf-hover"] = (403, 268)
|
||||
if game:
|
||||
mouse["game"] = open_and_shoot(491, 306, "game-more-menu") # the hero's "…"
|
||||
if args.gestures: # the mouse actions run after each step's screenshot, before the next step
|
||||
def drag(*points):
|
||||
def run(page):
|
||||
page.mouse.move(*points[0])
|
||||
@@ -462,11 +531,12 @@ def main() -> int:
|
||||
"files-dragged": right_click(900, 320), # a selected one: "… 3 items"
|
||||
"files-menu": lambda page: (escape(page), time.sleep(0.5), right_click(900, 600)(page)),
|
||||
"files-space-menu": escape,
|
||||
"screenshots": drag((680, 280), (950, 280), (950, 430)), # 3 shots
|
||||
"screenshots-dragged": right_click(950, 430),
|
||||
"screenshots": drag((400, 300), (680, 300), (950, 300)), # 3 shots (the first row)
|
||||
"screenshots-dragged": right_click(950, 300),
|
||||
"screenshots-menu": escape,
|
||||
"library": drag((360, 300), (560, 300), (760, 300)), # 3 cards, select mode on
|
||||
"library-dragged": drag((560, 300), (760, 300))} # from a selected card: deselects 2
|
||||
# the grid's first row (below the "On your Frame" shelf)
|
||||
"library": drag((360, 560), (560, 560), (760, 560)), # 3 cards, select mode on
|
||||
"library-dragged": drag((560, 560), (760, 560))} # from a selected card: deselects 2
|
||||
if args.linux:
|
||||
def linux_filter(a, value):
|
||||
a.navigate(0)
|
||||
@@ -502,10 +572,7 @@ def main() -> int:
|
||||
ImageDraw.Draw(im).ellipse((72, 72, 184, 184), fill=(255, 214, 102, 255))
|
||||
im.save(icon)
|
||||
|
||||
def add_menu(a):
|
||||
a.navigate(0)
|
||||
time.sleep(2)
|
||||
steps = [("library", add_menu),
|
||||
steps = [("library", lambda a: a.navigate(0)),
|
||||
("links-paste", lambda a: a.pick_link()),
|
||||
("links-confirm", lambda a: (a.page.pop_dialog(), link_dialog._confirm(a, fake, sizes, False, icon))),
|
||||
("links-settings", lambda a: (a.page.pop_dialog(), a.go("settings")))]
|
||||
@@ -516,7 +583,31 @@ def main() -> int:
|
||||
time.sleep(2)
|
||||
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
|
||||
return True
|
||||
# tall pages: run with e.g. --viewport 1280x3200 (at 1440x900 the tour's own Add games click is kept)
|
||||
mouse.setdefault("library", add_menu_open)
|
||||
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 is its first section."""
|
||||
a.go("settings")
|
||||
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,7 +668,11 @@ 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]
|
||||
ready, done = threading.Event(), []
|
||||
# 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, shot_done, done = threading.Event(), threading.Event(), []
|
||||
|
||||
if args.fake_frame: # never reach a real Frame (start-up auto-connect, discovery, the 30 s poll)
|
||||
from frameport.frame import keyboard, monitor
|
||||
@@ -592,40 +687,56 @@ def main() -> int:
|
||||
try:
|
||||
app = FramePortApp(page)
|
||||
if args.fake_frame:
|
||||
app.monitor_hub.session_factory = FakeMonitorSession # the shared stream (Monitor tab, Frame card)
|
||||
app.target = FakeTarget()
|
||||
app.frame_info, app.frame_state = app.target.describe(), "connected"
|
||||
except Exception: # noqa: BLE001
|
||||
ERRORS.append("startup: " + traceback.format_exc())
|
||||
return
|
||||
threading.Thread(target=driver, args=(app, steps, ready, done), daemon=True).start()
|
||||
threading.Thread(target=driver, args=(app, steps, ready, shot_done, 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"])
|
||||
page = browser.new_page(viewport={"width": vw, "height": vh})
|
||||
page.goto(f"http://127.0.0.1:{PORT}", wait_until="networkidle", timeout=120_000)
|
||||
shot = 0
|
||||
deadline = time.time() + 240 + 8 * len(steps)
|
||||
while len(done) < len(steps) and time.time() < deadline:
|
||||
if ready.wait(1):
|
||||
ready.clear()
|
||||
name = done[-1]
|
||||
time.sleep(3) # let Flutter paint
|
||||
if args.hover and name == "library":
|
||||
hx, hy = (float(v) for v in args.hover.split(","))
|
||||
page.mouse.move(hx, hy)
|
||||
shot = handled = 0
|
||||
while handled < len(steps): # one hand-off per step (see driver)
|
||||
if not ready.wait(STEP_TIMEOUT):
|
||||
ERRORS.append(f"browser: no step after {done[-1] if done else 'start'} "
|
||||
f"within {STEP_TIMEOUT} s")
|
||||
break
|
||||
ready.clear()
|
||||
name = done[-1]
|
||||
try:
|
||||
time.sleep(PAINT_SECONDS)
|
||||
if name in hover:
|
||||
page.mouse.move(*hover[name])
|
||||
time.sleep(1.5)
|
||||
page.screenshot(path=str(args.out / f"{shot:02d}-{name}.png"))
|
||||
shot += 1
|
||||
if name in mouse:
|
||||
mouse[name](page)
|
||||
time.sleep(2)
|
||||
if name in hover:
|
||||
page.mouse.move(130, 520) # the sidebar's empty part: nothing left hovered
|
||||
if name in mouse and mouse[name](page) is not True:
|
||||
time.sleep(PAINT_SECONDS) # e.g. typed search: the matching cards fade in
|
||||
page.screenshot(path=str(args.out / f"{shot:02d}-{name}-mouse.png"))
|
||||
shot += 1
|
||||
finally:
|
||||
handled += 1
|
||||
shot_done.set()
|
||||
browser.close()
|
||||
except Exception: # noqa: BLE001
|
||||
ERRORS.append("browser: " + traceback.format_exc())
|
||||
|
||||
|
After Width: | Height: | Size: 248 KiB |
@@ -110,3 +110,65 @@ def prewarm(package: str) -> None:
|
||||
src = pick(package, kinds)
|
||||
if src:
|
||||
thumb(src, width, src.stem)
|
||||
compute_tint(package)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ cover tint
|
||||
TINT_ART = ("portrait", "square", "cover", "icon") # the library card's art (the cover people recognise)
|
||||
_tints: dict[str, str | None] = {} # package -> "#rrggbb" (None: no art / no usable colour)
|
||||
|
||||
|
||||
def tint_from_rgb(rgb: tuple[int, int, int]) -> str:
|
||||
"""A cover's dominant colour made to sit on the dark background: lightness 0.35-0.55, saturation at most 0.55."""
|
||||
import colorsys
|
||||
|
||||
h, lum, s = colorsys.rgb_to_hls(*(max(0, min(255, c)) / 255 for c in rgb))
|
||||
r, g, b = colorsys.hls_to_rgb(h, min(0.55, max(0.35, lum)), min(0.55, s))
|
||||
return "#{:02x}{:02x}{:02x}".format(*(round(c * 255) for c in (r, g, b)))
|
||||
|
||||
|
||||
def dominant_rgb(colors: list[tuple[int, tuple[int, int, int]]]) -> tuple[int, int, int] | None:
|
||||
"""The most common clearly coloured entry of [(count, rgb)] (saturation ≥ 0.25, neither near black nor near
|
||||
white); else the most common one overall. None for an empty list."""
|
||||
import colorsys
|
||||
|
||||
def colourful(rgb) -> bool:
|
||||
_, lum, s = colorsys.rgb_to_hls(*(c / 255 for c in rgb))
|
||||
return s >= 0.25 and 0.12 <= lum <= 0.88
|
||||
ranked = [rgb for _, rgb in sorted(colors, key=lambda c: -c[0])]
|
||||
return next((rgb for rgb in ranked if colourful(rgb)), ranked[0] if ranked else None)
|
||||
|
||||
|
||||
def compute_tint(package: str) -> str | None:
|
||||
"""The cover's tint, cached next to the thumbnails (t_tint_<sha8>.json, named after its source like a thumbnail)
|
||||
and in memory. Image work: call from a background thread (render paths use cached_tint)."""
|
||||
import json
|
||||
|
||||
src = pick(package, TINT_ART)
|
||||
value = None
|
||||
if src:
|
||||
out = src.parent / f"t_tint_{_digest(src)}.json"
|
||||
try:
|
||||
value = json.loads(out.read_text(encoding="utf-8")).get("tint")
|
||||
except (OSError, ValueError, AttributeError):
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(thumb(src, 400, src.stem)) as im:
|
||||
small = im.convert("RGB").resize((48, 48))
|
||||
q = small.quantize(colors=8)
|
||||
pal = q.getpalette() or []
|
||||
rgb = dominant_rgb([(n, tuple(pal[i * 3:i * 3 + 3])) for n, i in (q.getcolors() or [])])
|
||||
value = tint_from_rgb(rgb) if rgb else None
|
||||
for old in src.parent.glob("t_tint_*.json"):
|
||||
old.unlink(missing_ok=True)
|
||||
out.write_text(json.dumps({"tint": value}), encoding="utf-8")
|
||||
except Exception: # noqa: BLE001 (unreadable image: no tint)
|
||||
value = None
|
||||
_tints[package] = value
|
||||
return value
|
||||
|
||||
|
||||
def cached_tint(package: str) -> tuple[bool, str | None]:
|
||||
"""(known, tint) from memory only (no I/O, for render paths); known=False: compute_tint hasn't run yet."""
|
||||
return (package in _tints, _tints.get(package))
|
||||
@@ -15,6 +15,8 @@ from collections.abc import Callable
|
||||
from ..core import applog
|
||||
|
||||
MIN_AGENT = 62 # first agent with `_monitor`
|
||||
MIN_AGENT_MODULES = 63 # first agent that takes {"modules": [...]} (collects only what's asked for)
|
||||
MODULES = ("games", "procs", "cpu", "gpu", "mem", "temps", "power", "battery", "net", "disk") # agent MON_MODULES
|
||||
HISTORY = 120 # points per sparkline (2 min at 1 s)
|
||||
REPLY_TIMEOUT = 15.0
|
||||
|
||||
@@ -101,6 +103,18 @@ class MonitorSession:
|
||||
def pause(self, paused: bool) -> None:
|
||||
self.send({"pause": bool(paused)})
|
||||
|
||||
@property
|
||||
def supports_modules(self) -> bool:
|
||||
return int(self.static.get("agent") or 0) >= MIN_AGENT_MODULES
|
||||
|
||||
def set_modules(self, modules) -> bool:
|
||||
"""Ask the agent to collect only these modules (an iterable of MODULES names, or "all"). False (nothing sent)
|
||||
when the agent is older than MIN_AGENT_MODULES: it keeps sending everything."""
|
||||
if not self.supports_modules:
|
||||
return False
|
||||
self.send({"modules": "all" if modules == "all" else sorted(modules)})
|
||||
return True
|
||||
|
||||
def _request(self, msg: dict, timeout: float = REPLY_TIMEOUT) -> dict:
|
||||
rid = next(self._ids)
|
||||
slot = [threading.Event(), None]
|
||||
@@ -219,7 +233,7 @@ def fmt_duration(s: float | None) -> str:
|
||||
|
||||
|
||||
def fmt_pct(v: float | None) -> str:
|
||||
return "–" if v is None else f"{v:.0f} %"
|
||||
return "–" if v is None else f"{v:.0f}%"
|
||||
|
||||
|
||||
# (warn, error) thresholds; "low" metrics warn when the value falls below them
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""MonitorHub: one app-owned `_monitor` stream shared by every place that shows live Frame data (the Monitor tab, the
|
||||
sidebar's live Frame card). Subscribers name the modules they need and how often; the hub runs at most one
|
||||
MonitorSession with the union of the active subscribers' modules and the fastest interval, reconfigures it when
|
||||
subscribers change, fans every sample out, reconnects every RETRY_SECONDS after the stream was lost and stops it when
|
||||
nobody (unpaused) is left. No Flet here: callbacks run on the session's reader thread or the hub's own threads.
|
||||
|
||||
States passed to `on_state(name, state, detail)`: "connecting", "live", "lost" (detail = why; a retry is scheduled),
|
||||
"error" (detail = the exception; no automatic retry, `reconnect()` tries again)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..core import applog
|
||||
from . import monitor as M
|
||||
|
||||
RETRY_SECONDS = 5.0 # reconnect after the stream was lost, while someone subscribes
|
||||
ALL = "all"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Subscriber:
|
||||
name: str
|
||||
modules: frozenset | str # MODULES names or "all"
|
||||
interval: float
|
||||
on_sample: Callable[[dict], None]
|
||||
on_state: Callable[[str, str, object], None] | None = None
|
||||
paused: bool = False
|
||||
|
||||
|
||||
def norm_modules(modules) -> frozenset | str:
|
||||
if modules is None or modules == ALL:
|
||||
return ALL
|
||||
if isinstance(modules, str):
|
||||
return frozenset({modules})
|
||||
return frozenset(modules)
|
||||
|
||||
|
||||
def snap_interval(seconds: float) -> float:
|
||||
"""The agent only takes M.INTERVALS: the slowest one that is at least as fast as asked."""
|
||||
fits = [i for i in M.INTERVALS if i <= seconds + 1e-9]
|
||||
return float(max(fits) if fits else M.INTERVALS[0])
|
||||
|
||||
|
||||
def merge(subs: Iterable[Subscriber]) -> tuple[frozenset | str | None, float | None]:
|
||||
"""(modules, interval) one stream needs for the active (unpaused) subscribers: the union of their modules ("all"
|
||||
if any wants everything) and the fastest interval. (None, None) when nobody is active."""
|
||||
active = [s for s in subs if not s.paused]
|
||||
if not active:
|
||||
return None, None
|
||||
if any(s.modules == ALL for s in active):
|
||||
modules: frozenset | str = ALL
|
||||
else:
|
||||
modules = frozenset().union(*(s.modules for s in active))
|
||||
return modules, snap_interval(min(s.interval for s in active))
|
||||
|
||||
|
||||
def _timer(delay: float, fn: Callable[[], None]):
|
||||
t = threading.Timer(delay, fn)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def _thread(fn: Callable[[], None]) -> None:
|
||||
threading.Thread(target=fn, name="monitor-hub-connect", daemon=True).start()
|
||||
|
||||
|
||||
class MonitorHub:
|
||||
def __init__(self, get_target: Callable[[], object], session_factory=M.MonitorSession,
|
||||
retry_seconds: float = RETRY_SECONDS, spawn: Callable = _thread, timer: Callable = _timer):
|
||||
self.get_target = get_target
|
||||
self.session_factory = session_factory
|
||||
self.retry_seconds = retry_seconds
|
||||
self._spawn = spawn
|
||||
self._timer = timer
|
||||
self._lock = threading.RLock()
|
||||
self._subs: dict[str, Subscriber] = {}
|
||||
self.session = None
|
||||
self.static: dict = {}
|
||||
self.state, self.detail = "idle", None
|
||||
self._gen = 0
|
||||
self._connecting = False
|
||||
self._pending = None # generation whose connect waits for the lock to be released
|
||||
self._retry = None
|
||||
self._applied: tuple = (ALL, M.DEFAULT_INTERVAL) # what the live session runs with
|
||||
self._filter = "game"
|
||||
self._stream_paused = False
|
||||
self.closed = False
|
||||
|
||||
# ---------------------------------------------------------------- subscribers
|
||||
def subscribe(self, name: str, modules, interval: float, on_sample: Callable[[dict], None],
|
||||
on_state: Callable[[str, str, object], None] | None = None) -> None:
|
||||
with self._lock:
|
||||
self._subs[name] = Subscriber(name, norm_modules(modules), float(interval), on_sample, on_state)
|
||||
note = self._reconcile()
|
||||
self._after(note, name)
|
||||
|
||||
def update(self, name: str, modules=None, interval: float | None = None) -> None:
|
||||
with self._lock:
|
||||
sub = self._subs.get(name)
|
||||
if sub is None:
|
||||
return
|
||||
if modules is not None:
|
||||
sub.modules = norm_modules(modules)
|
||||
if interval is not None:
|
||||
sub.interval = float(interval)
|
||||
note = self._reconcile()
|
||||
self._after(note)
|
||||
|
||||
def unsubscribe(self, name: str) -> None:
|
||||
with self._lock:
|
||||
if self._subs.pop(name, None) is None:
|
||||
return
|
||||
note = self._reconcile()
|
||||
self._after(note)
|
||||
|
||||
def pause(self, name: str, paused: bool = True) -> None:
|
||||
"""A paused subscriber gets no samples and doesn't count towards the stream's modules/interval."""
|
||||
with self._lock:
|
||||
sub = self._subs.get(name)
|
||||
if sub is None or sub.paused == bool(paused):
|
||||
return
|
||||
sub.paused = bool(paused)
|
||||
note = self._reconcile()
|
||||
self._after(note, None if paused else name)
|
||||
|
||||
def subscribed(self, name: str) -> bool:
|
||||
return name in self._subs
|
||||
|
||||
def reconnect(self) -> None:
|
||||
"""Try again now (after an "error", or when the Frame connection came back)."""
|
||||
with self._lock:
|
||||
if self.session is not None or self._connecting or self.closed:
|
||||
return
|
||||
self._cancel_retry()
|
||||
note = self._reconcile()
|
||||
self._after(note)
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self.closed = True
|
||||
self._subs.clear()
|
||||
self._stop()
|
||||
|
||||
# ---------------------------------------------------------- pass-through actions
|
||||
def set_filter(self, which: str) -> None:
|
||||
"""The process filter ("game" | "steam" | "all"); kept for reconnects."""
|
||||
with self._lock:
|
||||
self._filter = which
|
||||
session = self.session
|
||||
if session is not None:
|
||||
session.set_filter(which)
|
||||
|
||||
def pause_stream(self, paused: bool) -> None:
|
||||
"""Pause the agent's samples for every subscriber (the stream stays open; kept for reconnects)."""
|
||||
with self._lock:
|
||||
self._stream_paused = bool(paused)
|
||||
session = self.session
|
||||
if session is not None:
|
||||
session.pause(bool(paused))
|
||||
|
||||
def _live(self):
|
||||
session = self.session
|
||||
if session is None:
|
||||
raise M.MonitorUnavailable("the monitor isn't connected")
|
||||
return session
|
||||
|
||||
def kill(self, pid: int, sig: str = "TERM", force: bool = False) -> dict:
|
||||
return self._live().kill(pid, sig, force)
|
||||
|
||||
def end_game(self, package: str) -> dict:
|
||||
return self._live().end_game(package)
|
||||
|
||||
# ---------------------------------------------------------------- internals
|
||||
def _reconcile(self) -> list:
|
||||
"""Start, stop or reconfigure the stream for the current subscribers. Called with the lock held; returns the
|
||||
state notifications to send once it's released."""
|
||||
if self.closed:
|
||||
return []
|
||||
modules, interval = merge(self._subs.values())
|
||||
if modules is None:
|
||||
self._stop()
|
||||
return []
|
||||
if self.session is not None:
|
||||
self._configure(self.session, modules, interval)
|
||||
return []
|
||||
if self._connecting or self._retry is not None:
|
||||
return []
|
||||
return self._start()
|
||||
|
||||
def _configure(self, session, modules, interval) -> None:
|
||||
old_modules, old_interval = self._applied
|
||||
try:
|
||||
if interval != old_interval:
|
||||
session.set_interval(interval)
|
||||
if modules != old_modules:
|
||||
session.set_modules(modules)
|
||||
except Exception: # noqa: BLE001 - the stream ending is reported through on_end
|
||||
applog.log.exception("monitor hub: reconfigure failed")
|
||||
self._applied = (modules, interval)
|
||||
|
||||
def _start(self) -> list:
|
||||
self._connecting = True
|
||||
self._set_state("connecting", None)
|
||||
self._pending = self._gen # connected by _after() once the lock is released
|
||||
return self._targets()
|
||||
|
||||
def _connect(self, gen: int) -> None:
|
||||
try:
|
||||
target = self.get_target()
|
||||
if target is None:
|
||||
raise M.MonitorUnavailable("not connected to a Frame")
|
||||
session = self.session_factory(target.frame, lambda s, g=gen: self._on_sample(g, s),
|
||||
lambda why, g=gen: self._on_end(g, why))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
with self._lock:
|
||||
if gen != self._gen:
|
||||
return
|
||||
self._connecting = False
|
||||
self._set_state("error", exc)
|
||||
note = self._targets()
|
||||
self._notify(note)
|
||||
return
|
||||
with self._lock:
|
||||
self._connecting = False
|
||||
modules, interval = merge(self._subs.values())
|
||||
if gen != self._gen or self.closed or modules is None: # nobody left while connecting
|
||||
stale = True
|
||||
else:
|
||||
stale = False
|
||||
self.session = session
|
||||
self.static = getattr(session, "static", {}) or {}
|
||||
self._applied = (ALL, M.DEFAULT_INTERVAL) # the agent's defaults
|
||||
self._configure(session, modules, interval)
|
||||
if self._filter != "game":
|
||||
session.set_filter(self._filter)
|
||||
if self._stream_paused:
|
||||
session.pause(True)
|
||||
self._set_state("live", None)
|
||||
note = self._targets()
|
||||
if stale:
|
||||
session.close()
|
||||
return
|
||||
self._notify(note)
|
||||
|
||||
def _on_sample(self, gen: int, sample: dict) -> None:
|
||||
if gen != self._gen:
|
||||
return
|
||||
for sub in [s for s in list(self._subs.values()) if not s.paused]:
|
||||
try:
|
||||
sub.on_sample(sample)
|
||||
except Exception: # noqa: BLE001 - one subscriber's bug must not starve the others
|
||||
applog.log.exception("monitor hub: subscriber %s failed", sub.name)
|
||||
|
||||
def _on_end(self, gen: int, why: str) -> None:
|
||||
with self._lock:
|
||||
if gen != self._gen or self.closed:
|
||||
return
|
||||
self.session = None
|
||||
if merge(self._subs.values())[0] is None:
|
||||
return
|
||||
self._set_state("lost", why)
|
||||
note = self._targets()
|
||||
self._retry = self._timer(self.retry_seconds, lambda: self._retry_now(gen))
|
||||
self._notify(note)
|
||||
|
||||
def _retry_now(self, gen: int) -> None:
|
||||
with self._lock:
|
||||
if gen != self._gen or self.closed:
|
||||
return
|
||||
self._retry = None
|
||||
note = self._reconcile()
|
||||
self._after(note)
|
||||
|
||||
def _cancel_retry(self) -> None:
|
||||
if self._retry is not None:
|
||||
try:
|
||||
self._retry.cancel()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._retry = None
|
||||
|
||||
def _stop(self) -> None:
|
||||
"""No active subscriber: end the stream (and anything pending). Lock held."""
|
||||
self._gen += 1
|
||||
self._connecting = False
|
||||
self._cancel_retry()
|
||||
session, self.session = self.session, None
|
||||
self.state, self.detail = "idle", None
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def _set_state(self, state: str, detail) -> None:
|
||||
self.state, self.detail = state, detail
|
||||
|
||||
def _targets(self) -> list:
|
||||
return [(s, self.state, self.detail) for s in self._subs.values() if not s.paused and s.on_state]
|
||||
|
||||
def _tell(self, name: str) -> None:
|
||||
"""Bring one (new or resumed) subscriber up to date with the stream's state."""
|
||||
sub = self._subs.get(name)
|
||||
if sub and sub.on_state and not sub.paused and self.state != "idle":
|
||||
self._notify([(sub, self.state, self.detail)])
|
||||
|
||||
def _after(self, note: list, name: str | None = None) -> None:
|
||||
"""Outside the lock: send state notifications (or bring `name` up to date), then start a queued connect."""
|
||||
if note:
|
||||
self._notify(note)
|
||||
elif name is not None:
|
||||
self._tell(name)
|
||||
with self._lock:
|
||||
gen, self._pending = self._pending, None
|
||||
if gen is not None:
|
||||
self._spawn(lambda: self._connect(gen))
|
||||
|
||||
@staticmethod
|
||||
def _notify(note: list) -> None:
|
||||
for sub, state, detail in note:
|
||||
try:
|
||||
sub.on_state(sub.name, state, detail)
|
||||
except Exception: # noqa: BLE001
|
||||
applog.log.exception("monitor hub: state handler of %s failed", sub.name)
|
||||
@@ -52,7 +52,7 @@
|
||||
"90 Hz": "",
|
||||
"920 × 430": "",
|
||||
"96 Hz": "",
|
||||
"A folder with Android games (APK + OBB, e.g. Quest games) or PC VR games.": "",
|
||||
"A folder with Android games (APK + OBB, for example Quest games) or PC VR games.": "",
|
||||
"A game is running on the Frame": "",
|
||||
"A game is running on the Frame. Close it in the headset first, then try again.": "",
|
||||
"a known blocker": "",
|
||||
@@ -60,14 +60,15 @@
|
||||
"A Meta Quest game (APK). It's rebuilt for the Frame and runs in Lepton, Valve's Android container.": "",
|
||||
"A native Linux program built for arm64 (aarch64), like the Frame's own CPU. It's installed unchanged and runs directly on SteamOS (no Android container, no Proton), started from the Frame's Steam library. VR apps use the Frame's OpenXR runtime.": "",
|
||||
"A new FramePort version is ready to install": "",
|
||||
"A newer known-good config for this game is available (you changed this game's settings, so it wasn't applied by itself).": "",
|
||||
"A newer known-good recipe for this game is available (you changed this game's settings, so it wasn't applied by itself).": "",
|
||||
"A recipe is the list of patches FramePort applies to a game before installing it. Known-good recipes were tested on a Frame; the others are suggested from the game's engine and VR API. Hover a patch for why it's there.": "",
|
||||
"a source checkout (git pull + uv sync)": "",
|
||||
"A VR app made for another Android headset. OpenXR apps are translated like Quest games; apps built on a headset maker's own SDK (Pico, HTC Wave) can't run on the Frame.": "",
|
||||
"A web page asked FramePort to install {name}.": "",
|
||||
"A Windows game without VR, added with \"Add one game folder…\". On the Frame, Proton runs it as a window (like Steam's own Windows games); it's in the Frame's Steam library.": "",
|
||||
"A Windows game without VR, added with \"Add a PC game folder…\". On the Frame, Proton runs it as a window (like Steam's own Windows games); it's in the Frame's Steam library.": "",
|
||||
"A Windows PC VR game (OpenXR, SteamVR or Oculus). It runs on this PC with SteamVR, or on the Frame with Proton. Oculus-only games also need Revive (Oculus → OpenXR).": "",
|
||||
"A Windows program (.exe)": "",
|
||||
"A zip with FramePort's logs, the game's recipe and analysis, launch-test logs and the Frame's runtime details — enough to debug without the game files. IP addresses, host and user names, home folders and Steam ids are replaced by placeholders. Attach it to a GitHub issue.": "",
|
||||
"A zip with FramePort's logs, the game's recipe and analysis, launch-test logs and the Frame's runtime details — enough to debug without the game files. IP addresses, host and user names, home folders and Steam IDs are replaced by placeholders. Attach it to a GitHub issue.": "",
|
||||
"ABIs": "",
|
||||
"About": "",
|
||||
"About this game": "",
|
||||
@@ -76,16 +77,17 @@
|
||||
"Activity": "",
|
||||
"Add a Linux app folder…": "",
|
||||
"Add a Linux app…": "",
|
||||
"Add a PC game folder…": "",
|
||||
"Add a tag": "",
|
||||
"Add a Windows program (.exe)…": "",
|
||||
"Add an APK file": "",
|
||||
"Add a Windows program…": "",
|
||||
"Add an APK file…": "",
|
||||
"Add an APK…": "",
|
||||
"Add from a link…": "",
|
||||
"Add games": "",
|
||||
"Add one game folder…": "",
|
||||
"Add this tag": "",
|
||||
"Add to Steam library: {title}": "",
|
||||
"Add videos": "",
|
||||
"Add videos and files…": "",
|
||||
"Add videos and files": "",
|
||||
"Add your games": "",
|
||||
"Add {name}": "",
|
||||
"Added automatically (engine, VR API or store genre)": "",
|
||||
@@ -96,6 +98,7 @@
|
||||
],
|
||||
"Added {title}": "",
|
||||
"Added {title}: it starts with {exe}. Not the right program? Change it on its page.": "",
|
||||
"Adding to the library": "",
|
||||
"Adds a launcher entry point (Lepton additionally needs category LAUNCHER, see the Frame 'launcher' fix).": "",
|
||||
"Adds a real ovrMessageType_ToString (OVRPort's platform compat library) for games whose platform loader lacks it (e.g. The Climb 2). OVRPort 1.2.5+ adds the same library itself; then this is skipped.": "",
|
||||
"Adds a small piece of Meta's platform library that some games need to start.": "",
|
||||
@@ -121,8 +124,10 @@
|
||||
"Also in your library: {value} version": "",
|
||||
"Also remove FramePort's games and files from the Frame": "",
|
||||
"An AppImage is a Linux app packed into one file. FramePort unpacks it on the Frame once, so it starts without FUSE.": "",
|
||||
"An AppImage, a folder or a .zip/.tar archive with a Linux program built for arm64 (aarch64). x86_64 builds can't run on the Frame. The app must bring the libraries SteamOS doesn't have.": "",
|
||||
"An AppImage, a folder or a .zip/.tar archive with a Linux program, best built for arm64 (aarch64). x86_64 builds run through FEX, Valve's x86 translator, but slower. The app must bring the libraries SteamOS doesn't have.": "",
|
||||
"an installed Python package (reinstalled from the release)": "",
|
||||
"An ordinary Android app or game (no VR). It's installed unchanged and runs in Lepton, Valve's Android container.": "",
|
||||
"Analyze": "",
|
||||
"Analyze again": "",
|
||||
"Analyze {title} again": "",
|
||||
"Android": "",
|
||||
@@ -139,6 +144,7 @@
|
||||
"Apply the suggested patches and reinstall": "",
|
||||
"Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped). Without it such apps get no controller input.": "",
|
||||
"arm64 (runs natively)": "",
|
||||
"Artwork": "",
|
||||
"Artwork for {get}": "",
|
||||
"Artwork updated ({src}). The Frame's Steam library still shows the old art.": "",
|
||||
"Artwork updated for {get}": "",
|
||||
@@ -162,7 +168,7 @@
|
||||
"Before each Play on this PC, FramePort reads SteamVR's record of the game's last session (frames dropped; frame times from fpsVR if installed). If it dropped frames, it lowers the game's refresh rate to one whose frame budget fits (e.g. 96 → 90 Hz) and turns motion smoothing on, through SteamVR's per-application settings; each later session can step down again. Set a refresh rate or smoothing mode here to choose yourself. Ignored on the Frame.": "",
|
||||
"Best guess": "",
|
||||
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan). Fixes UI panels and text that show upside down (e.g. Assassin's Creed Nexus).": "",
|
||||
"Browse and manage files on the Frame (Files tab): upload from this PC, download, rename, delete. Videos, Downloads and Documents are shared by every Quest game (/sdcard/Movies, /sdcard/Download, /sdcard/Documents); each installed game also has its own storage (its /sdcard). Apps find files by browsing folders. Video players that list only their own folder (e.g. 4XVR's Internal Storage = 4XPlayer): upload into that folder in the game's storage.": "",
|
||||
"Browse and manage files on the Frame (Files tab): upload from this PC, download, rename, delete. Videos, Downloads and Documents are shared by every Quest game (/sdcard/Movies, /sdcard/Download, /sdcard/Documents); each installed game also has its own storage (its /sdcard). Apps find files by browsing folders. Video players that list only their own folder (for example 4XVR's Internal Storage = 4XPlayer): upload into that folder in the game's storage.": "",
|
||||
"Build page": "",
|
||||
"Cable connected.": "",
|
||||
"Camera": "",
|
||||
@@ -171,29 +177,29 @@
|
||||
"Can't use that link: {reason}": "",
|
||||
"Cancel": "",
|
||||
"Cancel all": "",
|
||||
"cancelled": "",
|
||||
"Cancelled": "",
|
||||
"canceled": "",
|
||||
"Canceled": "",
|
||||
"Catalog": "",
|
||||
"Ceiling height for scene_emul.": "",
|
||||
"Ceiling height of the pretend room.": "",
|
||||
"Change executable…": "",
|
||||
"Change program…": "",
|
||||
"Change settings…": "",
|
||||
"Changes apply the next time FramePort starts.": "",
|
||||
"Changes apply to the next install. Hover a chip above for why it was suggested.": "",
|
||||
"Changes are used the next time the game starts. Not sure? Leave a setting as it is.": "",
|
||||
"Change…": "",
|
||||
"Check": "",
|
||||
"Check exe": "",
|
||||
"Check for new versions automatically": "",
|
||||
"Check for updates": "",
|
||||
"Check game files": "",
|
||||
"Check now": "",
|
||||
"Check that the Frame and this computer are on the same network.": "",
|
||||
"Check program": "",
|
||||
"Check that the Frame and this PC are on the same network.": "",
|
||||
"Checking for updates": "",
|
||||
"Checking this PC…": "",
|
||||
"Checking tools…": "",
|
||||
"Checking which app opens these links…": "",
|
||||
"checksum checked": "",
|
||||
"Check…": "",
|
||||
"Choose \"Flat window\" if a phone/tablet app shows nothing in the headset, \"VR\" if a VR app opens as a flat window.": "",
|
||||
"Choose PNG, JPEG or WebP images. Steam fills any missing shape from the ones you choose; FramePort never replaces your images automatically.": "",
|
||||
"Choose…": "",
|
||||
@@ -208,6 +214,7 @@
|
||||
"Clear selection": "",
|
||||
"Clear tags": "",
|
||||
"Click here, then type. Everything you type goes to the Frame (Esc and shortcuts too).": "",
|
||||
"Click to open · right-click for quick actions": "",
|
||||
"Click to open · right-click for quick actions · drag across cards to select several": "",
|
||||
"Close": "",
|
||||
"Close FramePort": "",
|
||||
@@ -217,7 +224,7 @@
|
||||
"Collect logs: {title}": "",
|
||||
"Command-line arguments for the game program. Games that support several VR runtimes pick one with an argument, e.g. Unreal's -hmd=OpenXR or -hmd=SteamVR, Unity's -vrmode OpenVR, or a game's own switch (-Runtime=OpenXRHMD). FramePort fills them in from the game files (the repack's VD.bat with the Oculus module swapped for OpenXR, else the engine's usual switch).": "",
|
||||
"Connect": "",
|
||||
"Connect the Frame's USB-C port to this computer with a USB cable (a data cable, not a charge-only one).": "",
|
||||
"Connect the Frame's USB-C port to this PC with a USB cable (a data cable, not a charge-only one).": "",
|
||||
"Connect your Frame": "",
|
||||
"Connect your Frame first": "",
|
||||
"Connect your Steam Frame": "",
|
||||
@@ -235,6 +242,7 @@
|
||||
"Controllers": "",
|
||||
"Converts Quest games to OpenXR": "",
|
||||
"Copied": "",
|
||||
"Copied: save it as a .json file, change the colors and install it": "",
|
||||
"Copy": "",
|
||||
"Copy all": "",
|
||||
"Copy failed; select the text instead": "",
|
||||
@@ -245,6 +253,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 +263,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).": "",
|
||||
@@ -288,12 +298,13 @@
|
||||
"Desktop": "",
|
||||
"Details": "",
|
||||
"Dev builds let you test fixes before they're released. They're less tested than releases and may have bugs. You get the next release as a normal update (you have {version}).": "",
|
||||
"Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on your network and connect to it over SSH. The first-time setup command turns it on for you.": "",
|
||||
"Developer Mode (on the Frame: Settings → System → Enable Developer Mode) lets FramePort find the Frame on your network and connect to it over SSH. The first-time setup command turns it on for you. With Developer Mode on you can also pair without the setup command: on the Frame open Settings → Developer → Pair new host, click Connect there and approve FramePort. That way needs no connection from the Frame into this PC.": "",
|
||||
"Diagnostics: the Vulkan shim (frame.vk_sanitize) adds Khronos' validation layer to the game's instance; its findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK.": "",
|
||||
"Didn't finish ({value}): {error}": "",
|
||||
"Disable application space warp if used": "",
|
||||
"Disable controller tracking offset": "",
|
||||
"Disable Meta XR Audio telemetry": "",
|
||||
"Disconnect": "",
|
||||
"Dismiss": "",
|
||||
"Display refresh rate for this game (72, 80, 90, 96, 108, 120 or 144; 0 = the game's choice). A video's frame rate that divides the refresh rate plays smoothest (e.g. 30 fps at 90 Hz, 24 fps at 72 Hz).": "",
|
||||
"Documents": "",
|
||||
@@ -301,12 +312,10 @@
|
||||
"done": "",
|
||||
"Done": "",
|
||||
"Done: look for the cable": "",
|
||||
"Download": "",
|
||||
"Download all": "",
|
||||
"Download all ({n})…": "",
|
||||
"Download all…": "",
|
||||
"Download and install": "",
|
||||
"Download selected": "",
|
||||
"Download to this PC": "",
|
||||
"Download selected…": "",
|
||||
"Download to this PC…": "",
|
||||
"Download to which folder on this PC?": "",
|
||||
"Download {name}": "",
|
||||
@@ -326,6 +335,7 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"Downloading tools": "",
|
||||
"Downloading…": "",
|
||||
"Downloads": "",
|
||||
"Download…": "",
|
||||
@@ -338,16 +348,17 @@
|
||||
"Each patch is described in plain words. Turn this on to also see its id, exactly what it changes in the game (useful when reporting a problem) and its parameters.": "",
|
||||
"Enabled by you.": "",
|
||||
"End all": "",
|
||||
"End all {n}": "",
|
||||
"End all {n} {name}?": "",
|
||||
"End all {n}…": "",
|
||||
"End game": "",
|
||||
"End game…": "",
|
||||
"End process": "",
|
||||
"End the whole game": "",
|
||||
"End {name}?": "",
|
||||
"End {title}?": "",
|
||||
"Ending this stops Steam, SteamVR or the desktop": "",
|
||||
"End…": "",
|
||||
"Enter the address": "",
|
||||
"Enter the Frame's address (e.g. steamos@frame.local or its IP)": "",
|
||||
"Enter the Frame's address (for example steamos@frame.local or its IP)": "",
|
||||
"Environment variables exported by the launcher (e.g. VK_INSTANCE_LAYERS=\"\" to test without Valve's layers).": "",
|
||||
"Environment variables for the Proton launcher on the Frame (e.g. DXVK_HUD=fps), one KEY=value per line.": "",
|
||||
"Every 'local' play space the game creates lines up with the one at the start. For games whose menus or screens jump to where you look on the Frame.": "",
|
||||
@@ -357,7 +368,6 @@
|
||||
"Every {n}": "",
|
||||
"everything in ~ (not seen by games)": "",
|
||||
"Everything is up to date": "",
|
||||
"Executable": "",
|
||||
"experimental": "",
|
||||
"Experimental Vulkan mutable-format workaround.": "",
|
||||
"Extra diagnostics (log)": "",
|
||||
@@ -372,13 +382,15 @@
|
||||
"Failed: {error}": "",
|
||||
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality games that build their level from the room (e.g. Demeter).": "",
|
||||
"Fan": "",
|
||||
"file": "",
|
||||
"Files": "",
|
||||
"Files and environment on the Frame": "",
|
||||
"Files and environment variables placed next to the game on the Frame.": "",
|
||||
"Files on the Frame can be browsed once FramePort is connected to it.": "",
|
||||
"Files on the Frame — {title}": "",
|
||||
"Files on the Frame: browse what this install contains": "",
|
||||
"Filter files (e.g. .pak, Binaries)": "",
|
||||
"Filter files (for example .pak, Binaries)": "",
|
||||
"Find another Frame…": "",
|
||||
"Find artwork and store details": "",
|
||||
"Find artwork…": "",
|
||||
"Find automatically": "",
|
||||
@@ -395,6 +407,7 @@
|
||||
"Fixes the black picture in Minecraft launchers (e.g. QuestCraft): uses depth formats the Frame's graphics driver accepts.": "",
|
||||
"Flat": "",
|
||||
"focus_hold hides focus dips up to this long (100-5000). Longer for a headset whose wear sensor flickers (\"HMD off\" for 0.5-2 s while worn, e.g. Blade & Sorcery pausing); taking the headset off or the system menu pauses the game only after this long.": "",
|
||||
"folder": "",
|
||||
"Folder": "",
|
||||
"folder and everything in it": "",
|
||||
"Folder name": "",
|
||||
@@ -417,24 +430,28 @@
|
||||
"For Unreal games whose models flicker or show through walls and windows on the Frame (e.g. Into The Radius 2). Needs a rebuild.": "",
|
||||
"For Vulkan games whose models flicker or vanish while you look at them (e.g. Into The Radius 2). Needs a rebuild.": "",
|
||||
"Force enable passthrough": "",
|
||||
"Force kill": "",
|
||||
"Force kill all": "",
|
||||
"Force kill all {n}": "",
|
||||
"Force kill all {n} {name}?": "",
|
||||
"Force kill {name}?": "",
|
||||
"Force quit": "",
|
||||
"Force quit all": "",
|
||||
"Force quit all {n} {name}?": "",
|
||||
"Force quit all {n}…": "",
|
||||
"Force quit {name}?": "",
|
||||
"Force quit…": "",
|
||||
"Found": "",
|
||||
"fps": "",
|
||||
"fps isn't reported for PC VR and Linux games": "",
|
||||
"fps · target {hz} Hz": "",
|
||||
"fps: waiting for the game's first frames": "",
|
||||
"Frame agent": "",
|
||||
"Frame battery: charging": "",
|
||||
"Frame battery: not charging": "",
|
||||
"Frame not connected": "",
|
||||
"Frame OpenXR compatibility layer": "",
|
||||
"FrameBridge OpenXR adapter": "",
|
||||
"FramePort": "",
|
||||
"FramePort can't add {name}: drop an APK, a Linux build (.zip, AppImage), a Windows program (.exe) or a folder": "",
|
||||
"FramePort can't replace itself in {value} (no permission): opening the release page to download it": "",
|
||||
"FramePort checks GitHub for a new release every few hours and shows it in the sidebar and the Library. \"Update now\" downloads it, checks its checksum (and on Windows its signature), closes FramePort and opens the new version; your games, settings and Frame connection are kept. With automatic install, the download happens in the background and the new version starts next time.": "",
|
||||
"FramePort downloads it, checks it, closes and opens again as the new version. Your games, settings and Frame connection stay as they are.": "",
|
||||
"FramePort found more than one arm64 program in this app. Pick the one that starts it (helpers such as crash reporters or updaters are the wrong choice).": "",
|
||||
"FramePort found more than one program that could start this game. Pick the one you'd double-click to play it. Oculus builds usually work better with Revive than Steam builds.": "",
|
||||
"FramePort has Steam on the Frame download Proton and the runtime it needs (about 1 GiB). Steam restarts once, which closes a running game.": "",
|
||||
@@ -455,6 +472,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.": "",
|
||||
@@ -462,6 +480,7 @@
|
||||
"Free up space": "",
|
||||
"Free up space on the Frame": "",
|
||||
"Free up space?": "",
|
||||
"Free up space…": "",
|
||||
"Freed {value:.1f} GiB on the Frame": "",
|
||||
"From the tested recipe for this game": "",
|
||||
"From the {src}": "",
|
||||
@@ -470,12 +489,12 @@
|
||||
"Game": "",
|
||||
"Game arguments (select SteamVR / OpenXR)": "",
|
||||
"Game config files": "",
|
||||
"Game configs are up to date": "",
|
||||
"Game configs from GitHub: last checked {when}": "",
|
||||
"game data (OBB)": "",
|
||||
"Game launch capacity": "",
|
||||
"Game not found": "",
|
||||
"Game page": "",
|
||||
"Game recipes are up to date": "",
|
||||
"Game recipes from GitHub: last checked {when}": "",
|
||||
"Game settings": "",
|
||||
"Game settings · {title}": "",
|
||||
"Game settings. ": "",
|
||||
@@ -524,7 +543,7 @@
|
||||
"How a PC VR game starts: Proton runs the Windows program on the Frame; for Oculus games, Revive translates the Oculus API to OpenXR.": "",
|
||||
"How Lepton runs the app: VR (headless Android, only what the app draws through OpenXR reaches the headset) or a flat window (Lepton's lepton-show-flatscreen marker: the Android window on a screen in the headset, like a phone or tablet app). Automatic uses a flat window for apps without VR code. Choose \"Flat window\" for a 2D app FramePort took for VR (it shows nothing in the headset), \"VR\" for a VR app FramePort took for 2D.": "",
|
||||
"How Lepton shows an Android app: VR (the headset shows what the app draws through OpenXR) or a flat window (a phone/tablet app on a screen in the headset). Automatic picks a window for apps without VR code.": "",
|
||||
"How much of the time the Frame's graphics chip was busy, added up from every program that draws (the game, SteamVR's compositor, Steam). Near 100 % the game can't keep its frame rate.": "",
|
||||
"How much of the time the Frame's graphics chip was busy, added up from every program that draws (the game, SteamVR's compositor, Steam). Near 100% the game can't keep its frame rate.": "",
|
||||
"How often the Frame sends new numbers": "",
|
||||
"How often the picture updates. Higher is smoother if the game can keep up.": "",
|
||||
"How well the game runs on the Steam Frame. \"Works\" and \"Works with issues\" come from recipes tested on a real Frame. \"Untested\" means FramePort suggested a recipe from the game's engine and VR API that nobody has confirmed yet. \"Can't run\" means there's a known blocker (for example a 32-bit-only game).": "",
|
||||
@@ -535,15 +554,16 @@
|
||||
"If you know the Frame's address.": "",
|
||||
"Ignore focus dips up to": "",
|
||||
"Image for the {label}": "",
|
||||
"In the headset, select a text field, then type here.": "",
|
||||
"In your Steam library": "",
|
||||
"In your Steam library · launch settings changed — update it": "",
|
||||
"Install": "",
|
||||
"Install anyway": "",
|
||||
"Install anyway…": "",
|
||||
"Install dev build": "",
|
||||
"Install dev build {version}?": "",
|
||||
"Install from a link": "",
|
||||
"Install from a link…": "",
|
||||
"Install it anyway to try it, e.g. with different patches.": "",
|
||||
"Install it anyway to try it, for example with different patches.": "",
|
||||
"Install Lepton on the Frame": "",
|
||||
"Install link: {host}": "",
|
||||
"Install links": "",
|
||||
@@ -555,6 +575,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,13 +584,19 @@
|
||||
"Install {title} on {value}": "",
|
||||
"Installed": "",
|
||||
"Installed games ({len})": "",
|
||||
"Installed the theme \"{name}\"": "",
|
||||
"Installed theme file · {file}": "",
|
||||
"Installed {join}": "",
|
||||
"Installed · a newer build is ready": "",
|
||||
"Installed · launch test failed": "",
|
||||
"Installed · patch settings changed since ({changed}) — update to apply": "",
|
||||
"Installed, but it didn't start properly: ": "",
|
||||
"Installing": "",
|
||||
"Installing {name}": "",
|
||||
"Installs the app as it is, unpacked on the Frame, with an entry in its Steam library": "",
|
||||
"Installs the app as it is, with an entry in the Frame's Steam library": "",
|
||||
"Installs the game exactly as it is: no patches (your copy is already patched)": "",
|
||||
"Install…": "",
|
||||
"into {value}": "",
|
||||
"It belongs to a running game, which may stop with it.": "",
|
||||
"It can't be reached (asleep, turned off or out of Wi-Fi). Wake it or turn it on: FramePort continues by itself, and uploads pick up where they stopped.": "",
|
||||
@@ -577,7 +604,6 @@
|
||||
"It has a known blocker on the Steam Frame.": "",
|
||||
"It has issues: share…": "",
|
||||
"it stopped at '{value}'": "",
|
||||
"It trusts this app, turns on Developer Mode and installs Lepton if needed. Turning on Developer Mode closes the desktop; the setup finishes on its own and FramePort connects by itself. You only do this once.": "",
|
||||
"It was removed from the library.": "",
|
||||
"It will be closed without saving. Continue?": "",
|
||||
"It won't start on the Frame: SteamOS doesn't have these libraries and the app doesn't bring them. Look for a build of the app that includes them, then install that one.": "",
|
||||
@@ -600,11 +626,9 @@
|
||||
"Last build": "",
|
||||
"Last launch test: {get} · furthest: {value}": "",
|
||||
"late": "",
|
||||
"Later": "",
|
||||
"Launch log": "",
|
||||
"Launch log · {title}": "",
|
||||
"Launch test after installing on the Frame (starts the game once without the headset and checks its log)": "",
|
||||
"Launch test on Frame": "",
|
||||
"Launch test on the Frame. ": "",
|
||||
"Launch test on this PC": "",
|
||||
"Launch test. ": "",
|
||||
@@ -651,8 +675,11 @@
|
||||
"Logs details for debugging: composition layers and swapchains, focus changes, play-space creation, pointer vs grip poses, refresh rates. No effect on the game.": "",
|
||||
"Logs every Meta Platform SDK request the game makes (user, entitlement, cloud saves, ...) and the messages that answer them, and every 10 s the requests that never got an answer (logcat tag fp_ovrtrace, in launch.log). For games that wait forever on a loading screen (e.g. Vader Immortal after its intro). Changes nothing: every call goes to the real function.": "",
|
||||
"Longer for headsets that briefly think they're off your head.": "",
|
||||
"Looking at the app": "",
|
||||
"Looking at the program": "",
|
||||
"Looking for Frames on your network…": "",
|
||||
"Looking for the Frame on a USB cable (Developer Mode on, USB-C port connected to this computer).": "",
|
||||
"Looking for games": "",
|
||||
"Looking for the Frame on a USB cable (Developer Mode on, USB-C port connected to this PC).": "",
|
||||
"Looking for your Frame…": "",
|
||||
"Looking up the store description and screenshots…": "",
|
||||
"Low": "",
|
||||
@@ -684,7 +711,7 @@
|
||||
"Mixed-reality games that build their level from your room get a simple room with walls.": "",
|
||||
"Modem": "",
|
||||
"Monitor": "",
|
||||
"Monitor is using {pct} % of the Frame's CPU": "",
|
||||
"Monitor is using {pct}% of the Frame's CPU": "",
|
||||
"More actions": "",
|
||||
"Moves where the pointer starts, forward or back.": "",
|
||||
"Moves where the pointing ray starts forward (+) or back (−).": "",
|
||||
@@ -698,11 +725,11 @@
|
||||
"Network": "",
|
||||
"Neutralises Meta XR Audio's Quest-only calls (Unity/Wwise).": "",
|
||||
"never": "",
|
||||
"New config applied: use Update on Frame to install it": "",
|
||||
"New folder": "",
|
||||
"New folder…": "",
|
||||
"New Frame? Run one command on it and FramePort does the rest. Did this once already? Your Frame appears under step 2.": "",
|
||||
"New name": "",
|
||||
"New recipe applied: use Update on Frame to install it": "",
|
||||
"Next": "",
|
||||
"No": "",
|
||||
"No activity": "",
|
||||
@@ -713,12 +740,13 @@
|
||||
"No folders to rescan yet: add games with Scan a folder": "",
|
||||
"No Frame is connected: the game is added to your library now and installs once the Frame is connected.": "",
|
||||
"No FramePort game is running. Start one in the headset or with Play, or switch the filter to see everything on the Frame.": "",
|
||||
"No Frames found. New Frame? Use first-time setup (step 1). Otherwise make sure Developer Mode is on (Settings → System → Developer) and the Frame is on the same network.": "",
|
||||
"No Frames found. New Frame? Use first-time setup (step 1). Otherwise turn on Developer Mode and keep the Frame on the same network.": "",
|
||||
"No games found in that folder": "",
|
||||
"No limit": "",
|
||||
"No matching files": "",
|
||||
"No new games in {name}": "",
|
||||
"No notes.": "",
|
||||
"No other Frames remembered yet.": "",
|
||||
"No process matches.": "",
|
||||
"No release notes.": "",
|
||||
"No screenshots yet": "",
|
||||
@@ -752,6 +780,7 @@
|
||||
"Now {scale:.0%}.": "",
|
||||
"Now {scale:.0%}. Changes apply the next time FramePort starts.": "",
|
||||
"Now {scale:.0%}; {new:.0%} after restarting FramePort.": "",
|
||||
"nowhere": "",
|
||||
"NPU": "",
|
||||
"Oculus game — needs Revive, which doesn't run on the Frame. Play it on this PC (SteamVR).": "",
|
||||
"Oculus OS telemetry class stubs": "",
|
||||
@@ -763,13 +792,14 @@
|
||||
"On Frame": "",
|
||||
"On the Frame, open Steam's Settings (Steam button → Settings).": "",
|
||||
"On the Frame, place Revive's runtime (FramePort's Revive, or the repack's bundled LibRevive) where the game's Oculus SDK looks for LibOVRRT so it can load a VR runtime (pure runtime substitution — what Revive's LoadLibrary hook does on Windows; that hook doesn't work under Proton on the Frame). It does NOT bypass the Oculus runtime signature check: a game that verifies the runtime's signature will still refuse it. Ignored on this PC (Revive's hook handles it there).": "",
|
||||
"On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → Konsole, and run (it reaches this computer over the USB cable):": "",
|
||||
"On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → Konsole, and run (it reaches this PC over the USB cable):": "",
|
||||
"On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → Konsole, and run:": "",
|
||||
"On this PC": "",
|
||||
"On this PC, launch the game through Revive's OpenVR backend, which talks straight to SteamVR — the original, most reliable Revive path. On by default for PC installs. Turn it off to use Revive's newer OpenXR backend instead. Ignored on the Frame (which always uses OpenXR).": "",
|
||||
"On this PC, runs Revive through SteamVR (works with more headsets).": "",
|
||||
"On your Frame": "",
|
||||
"on: {names}": "",
|
||||
"One PC VR game folder": "",
|
||||
"One PC game folder": "",
|
||||
"Only if 360° pictures show wrongly.": "",
|
||||
"Only if 360° pictures show wrongly: 1 = upside down, 2 = mirrored, 4 = turned around (add values to combine).": "",
|
||||
"Only install software from sites you trust. FramePort downloads it from {host} and installs it on your Frame.": "",
|
||||
@@ -777,17 +807,21 @@
|
||||
"Open \"Install with FrameDrop\" buttons (framedrop:// links) in FramePort": "",
|
||||
"Open frameport:// links in FramePort": "",
|
||||
"Open game": "",
|
||||
"Open game page": "",
|
||||
"Open GitHub issue": "",
|
||||
"Open the Monitor": "",
|
||||
"Open viewer": "",
|
||||
"Open {scheme}:// links with FramePort?": "",
|
||||
"OpenGL ES games run on the Frame through Zink (OpenGL on top of Vulkan), which is stricter than Quest drivers. Vulkan games run natively.": "",
|
||||
"Opens a prefilled GitHub issue with this game's recipe, so it can join the built-in catalog (no account token needed; you review and submit it on GitHub). No game files or personal data are sent.": "",
|
||||
"Opens the app straight in VR, skipping its flat launcher screen (turn off to use the launcher, e.g. to import new content).": "",
|
||||
"Opens this player's storage on the Frame (Files tab): upload videos into the folder it lists": "",
|
||||
"OpenXR bridge registered": "",
|
||||
"OpenXR runtime": "",
|
||||
"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.": "",
|
||||
"Or turn on Developer Mode on the Frame (Settings → System → Enable Developer Mode) and pair it under step 2: that needs no connection into this PC.": "",
|
||||
"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).": "",
|
||||
@@ -804,6 +838,7 @@
|
||||
"Pairing: on the Frame open Settings → Developer → Pair new host, then approve FramePort.": "",
|
||||
"Password (first time only)": "",
|
||||
"Paste the address of an \"Install with FrameDrop\" button (right-click it → Copy link), a FramePort or FrameDrop manifest (.json) or a direct link to an APK, a Linux build (.zip) or a Windows program (.exe).": "",
|
||||
"Patch": "",
|
||||
"Patch Meta XR Audio": "",
|
||||
"Patch Oculus detection for Unity": "",
|
||||
"Patch Oculus detection for Unreal": "",
|
||||
@@ -840,11 +875,14 @@
|
||||
"Play on this PC": "",
|
||||
"Play your Quest, Android and PC VR games on the Steam Frame. Three steps and you're set.": "",
|
||||
"Play: starts the game through the Frame's Steam (put the headset on)": "",
|
||||
"Point FramePort at a folder with Android games (APK + OBB, e.g. Quest games) or PC VR games (one folder per game, or a folder of them). It finds them, works out what each needs and fetches artwork.": "",
|
||||
"playing": "",
|
||||
"playing · {t}": "",
|
||||
"Point FramePort at a folder with Quest or Android games (APK + OBB) or PC VR games. It finds them, works out what each needs and fetches artwork.": "",
|
||||
"Pointer angle": "",
|
||||
"Pointer start": "",
|
||||
"Pointer turn": "",
|
||||
"Points Oculus games at Revive on the Frame.": "",
|
||||
"Portal on true black, for OLED screens": "",
|
||||
"Power": "",
|
||||
"Power ICs": "",
|
||||
"Prepare {title}": "",
|
||||
@@ -854,9 +892,10 @@
|
||||
"Problems and feedback": "",
|
||||
"Processes": "",
|
||||
"Program": "",
|
||||
"Protected by SteamOS": "",
|
||||
"Proton couldn't run a test program (see the log)": "",
|
||||
"Proton debug log (Frame)": "",
|
||||
"Proton is Valve's Windows compatibility layer. On the Frame it runs PC VR games. Its ARM64 build isn't installed by default; FramePort can install it (Steam restarts once).": "",
|
||||
"Proton is Valve's Windows compatibility layer. On the Frame it runs PC VR games. Its arm64 build isn't installed by default; FramePort can install it (Steam restarts once).": "",
|
||||
"Proton version (Frame)": "",
|
||||
"Proton works on your Frame ({tool})": "",
|
||||
"Provide the Oculus runtime from Revive (Frame)": "",
|
||||
@@ -872,12 +911,15 @@
|
||||
"Quest ✓": "",
|
||||
"Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR games can run on the Frame.": "",
|
||||
"Quest ✗": "",
|
||||
"Queued": "",
|
||||
"Queued {len} installs — they run one after another in the background": "",
|
||||
"Quiet Meta's VrApi loader (OpenXR games)": "",
|
||||
"Raises minSdk where the loader needs it.": "",
|
||||
"Raises the minimum Android version the game declares, so the new parts load.": "",
|
||||
"Re-create reference space": "",
|
||||
"Reading the file list from the Frame…": "",
|
||||
"Reading the install link": "",
|
||||
"ready": "",
|
||||
"Ready": "",
|
||||
"Ready to play": "",
|
||||
"Rebuild only (no install)": "",
|
||||
@@ -885,30 +927,34 @@
|
||||
"Recently used": "",
|
||||
"Recipe": "",
|
||||
"Recipe reset": "",
|
||||
"Recipe reset: update the game on the Frame to use it": "",
|
||||
"Recipes tested on a real Frame: bundled with FramePort, fetched from the online catalog, and the ones you saved as known-good.": "",
|
||||
"Reconnect": "",
|
||||
"Recreate spaces after the first frames (diagnostic).": "",
|
||||
"Reduce motion (no fades between pages, no portal pulse when the Frame connects)": "",
|
||||
"Refresh": "",
|
||||
"Refresh rate": "",
|
||||
"Refresh store details": "",
|
||||
"Reinstall anyway": "",
|
||||
"Reinstall anyway…": "",
|
||||
"Reinstall on Frame": "",
|
||||
"Reinstall on this PC": "",
|
||||
"Reinstalls FramePort from the release, then restarts it.": "",
|
||||
"Release page": "",
|
||||
"Remembered": "",
|
||||
"Remove": "",
|
||||
"Remove converted copies now": "",
|
||||
"Remove FramePort": "",
|
||||
"Remove from library": "",
|
||||
"Remove from this PC": "",
|
||||
"Remove from this PC's Steam library": "",
|
||||
"Remove from 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.": "",
|
||||
@@ -940,12 +986,16 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"Reset": "",
|
||||
"Reset to recommended": "",
|
||||
"Reset to suggested recipe": "",
|
||||
"Reset to suggested recipe…": "",
|
||||
"Reset {title}'s recipe?": "",
|
||||
"Restart": "",
|
||||
"Restart now": "",
|
||||
"Restart the Frame": "",
|
||||
"Restart the Frame?": "",
|
||||
"Restart the Frame…": "",
|
||||
"Restarting": "",
|
||||
"Resume": "",
|
||||
"Resuming — files already copied are skipped": "",
|
||||
"Retry rejected GLES formats/MSAA with sRGB, samples=1.": "",
|
||||
@@ -954,12 +1004,16 @@
|
||||
"Revive (Oculus LibOVR → OpenXR)": "",
|
||||
"Revive {value} · {value2} backend": "",
|
||||
"Revive: SteamVR (OpenVR) backend on PC": "",
|
||||
"Rift": "",
|
||||
"Room depth": "",
|
||||
"Room height": "",
|
||||
"Room width": "",
|
||||
"Rotate quads 180°": "",
|
||||
"Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (e.g. Lucky's Tale's save slots, GitHub #9). Needs a rebuild.": "",
|
||||
"Run launch test on Frame": "",
|
||||
"Run launch test on this PC": "",
|
||||
"Running": "",
|
||||
"Running a Windows program under Proton": "",
|
||||
"running for {t}": "",
|
||||
"Running now": "",
|
||||
"Runs directly — no Revive needed": "",
|
||||
@@ -979,7 +1033,6 @@
|
||||
"Saved. The settings are used when the game is installed.": "",
|
||||
"saves and settings (the app's Android storage)": "",
|
||||
"Scales every controller vibration the game asks for (0-1): plain vibrations, Meta's amplitude envelopes and PCM buffers. For games whose vibrations feel much stronger than on a Quest.": "",
|
||||
"Scan a folder": "",
|
||||
"Scan a folder…": "",
|
||||
"Scan {name}": "",
|
||||
"Scans the folders you added games from again and adds games that appeared since. Games already in the library keep their recipe, tags and art; unchanged ones aren't analyzed again.": "",
|
||||
@@ -988,7 +1041,6 @@
|
||||
"SDL apps: start without a clipboard service": "",
|
||||
"Search": "",
|
||||
"Search again": "",
|
||||
"Search games and tags": "",
|
||||
"Search name, PID or game": "",
|
||||
"Search the stores instead": "",
|
||||
"Searched a moment ago.": "",
|
||||
@@ -1001,14 +1053,15 @@
|
||||
"Select games to install": "",
|
||||
"Sends this game's recipe (the patches and settings it uses) to FramePort's GitHub as a prefilled issue, so it can become a built-in recipe for everyone. You review and submit it in the browser; no GitHub token and no game files are involved.": "",
|
||||
"Set by you.": "",
|
||||
"Set up for FramePort": "",
|
||||
"Set up the connection to your Steam Frame first": "",
|
||||
"Set up the Frame": "",
|
||||
"Set up with a USB cable": "",
|
||||
"Sets Unity QualitySettings antiAliasing to 0. Unity's multisampled render-to-texture path can hang the Frame's GL driver ('zink: DEVICE LOST', e.g. Sniper Elite VR). Try it for GLES Unity games that freeze or crash the GPU.": "",
|
||||
"Settings": "",
|
||||
"shader cache: makes later starts load faster; rebuilt if deleted": "",
|
||||
"Share working config · {title}": "",
|
||||
"Share working config…": "",
|
||||
"Share working recipe · {title}": "",
|
||||
"Share working recipe…": "",
|
||||
"Sharpness": "",
|
||||
"Sharpness, refresh rate, controllers, menus and more for this game. Changes are used the next time it starts.": "",
|
||||
"Show 360° backgrounds and videos": "",
|
||||
@@ -1029,6 +1082,7 @@
|
||||
"Show Steam Frame controllers": "",
|
||||
"Show technical details (patch ids, exact effects, parameters)": "",
|
||||
"Show the app's Android window (for typing)": "",
|
||||
"Show the running game on the Frame card": "",
|
||||
"Show video panels": "",
|
||||
"Showing the top {shown} of {n} rows: search to find others.": "",
|
||||
"Showing {len} of {n}": "",
|
||||
@@ -1037,11 +1091,12 @@
|
||||
"Shows videos that games play on a flat panel (e.g. an intro video).": "",
|
||||
"Shut down": "",
|
||||
"Shut down the Frame?": "",
|
||||
"Sign": "",
|
||||
"Signing keys matter: game updates must be signed with the same key or their saves are lost on reinstall.": "",
|
||||
"Signs rebuilt games": "",
|
||||
"Size": "",
|
||||
"Size in pixels per eye of the 360° background, redrawn every frame on a background thread (512–4096). Higher is sharper but costs more GPU time per frame.": "",
|
||||
"Size of text and layout. Automatic follows Windows' display scaling halfway (150 % in Windows → 125 %) when FramePort runs under WSL, where the window doesn't get it from Windows itself.": "",
|
||||
"Size of text and layout. Automatic follows Windows' display scaling halfway (150% in Windows → 125%) when FramePort runs under WSL, where the window doesn't get it from Windows itself.": "",
|
||||
"Skip for now": "",
|
||||
"Skip Meta avatars": "",
|
||||
"Skip this version": "",
|
||||
@@ -1050,7 +1105,7 @@
|
||||
"Sleep": "",
|
||||
"software encoder": "",
|
||||
"Some apps open a flat Android launcher that then starts a separate VR activity (e.g. WiiCompiled). Shown with Lepton's flat window, that window stays in view after the VR part starts. This makes the VR activity the one Lepton starts (its LAUNCHER category), so the app opens in VR with no flat window. Everything only the launcher does (choosing or importing content) is skipped: turn it off for a run when you need the launcher, and turn off \"Show the app's Android window\".": "",
|
||||
"Some developers put an \"Install with FrameDrop\" button on their site (the protocol of the FrameDrop sideloader). FramePort reads the same links: it shows what the link offers, asks, downloads it, adds it to your library and installs it on the Frame. Only https links to public servers are used. Windows and Linux open these links in FramePort; on macOS, paste the link into Add games → Install from a link….": "",
|
||||
"Some developers put an \"Install with FrameDrop\" button on their site (the protocol of the FrameDrop sideloader). FramePort reads the same links: it shows what the link offers, asks, downloads it, adds it to your library and installs it on the Frame. Only https links to public servers are used. Windows and Linux open these links in FramePort; on macOS, paste the link into Add games → Add from a link….": "",
|
||||
"Some engines leave the pNext of unused Vulkan attachment references uninitialized. The Frame's driver never reads it, but Lepton always loads Steam's Fossilize shader-cache layer, which follows it and crashes on the first frame (SIGSEGV in libVkLayer_fossilize.so from FVulkanRenderPass, e.g. Deadpool VR). Loads Vulkan through a small shim that keeps valid pointers and drops only unreadable ones or ones pointing at the wrong structure type. It also drops a depth resolve named in a subpass without a depth attachment, which crashes the Frame's driver at the first render pass (e.g. Metro Awakening).": "",
|
||||
"Some game folders are already set up for SteamVR: a Revive copy (LibRevive64.dll) next to the game, started by a small loader DLL (e.g. xinput1_3.dll) that Windows loads from the game's folder. Then FramePort starts the game program directly — like double-clicking it — instead of through its own Revive (two Revives conflict; e.g. the Oculus Platform check then fails). On the Frame, Wine is told to use those loader DLLs from the game folder instead of its own.": "",
|
||||
"Some games keep content in separate files next to their OBB (e.g. Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.": "",
|
||||
@@ -1061,9 +1116,11 @@
|
||||
"Sort": "",
|
||||
"Source": "",
|
||||
"square, 256 × 256": "",
|
||||
"SSH found by network scan": "",
|
||||
"Standard for every game": "",
|
||||
"Start live view": "",
|
||||
"Start straight in VR (skip the 2D launcher)": "",
|
||||
"Start the live view to watch the headset in your browser": "",
|
||||
"Starting SteamVR and {title} — put your headset on": "",
|
||||
"Starting the stream on the Frame…": "",
|
||||
"Starting {title} on the Frame — put the headset on": "",
|
||||
@@ -1085,6 +1142,7 @@
|
||||
"Steam Frame patches": "",
|
||||
"Steam Frame: installed on the headset with an entry in its Steam library. This PC: a Steam shortcut on this Windows PC that starts the game through Revive, for a PC-tethered headset.": "",
|
||||
"Steam on the Frame isn't responding. Restart the Frame (or its Steam) and try again.": "",
|
||||
"SteamOS · Developer Mode": "",
|
||||
"steamos@frame.local or an IP address": "",
|
||||
"SteamVR": "",
|
||||
"Stereo (3D) 360° layers: 0 = as the game sends them, 2 = flat (the left image to both eyes).": "",
|
||||
@@ -1105,6 +1163,7 @@
|
||||
""
|
||||
],
|
||||
"Store details: {title}": "",
|
||||
"Streams a small sample from the Frame every 5 seconds (game, frame rate, battery). Turn off to save the Frame's processor.": "",
|
||||
"Strip depth layers": "",
|
||||
"Stub missing Meta platform functions": "",
|
||||
"Suggested after a launch test": "",
|
||||
@@ -1115,6 +1174,7 @@
|
||||
"swap {n}": "",
|
||||
"Swapchain format fallback": "",
|
||||
"Swaps in an OpenXR OVRPlugin for games that use VrApi through OVRPlugin (older Unity/Unreal).": "",
|
||||
"Switch Frame": "",
|
||||
"Switch Frame…": "",
|
||||
"Tags": "",
|
||||
"Tags:": "",
|
||||
@@ -1127,12 +1187,14 @@
|
||||
"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 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. Turning on Developer Mode closes the Frame's desktop; the setup finishes on its own and FramePort connects by itself. No password needed.": "",
|
||||
"The connection to the Frame is busy. Try again in a moment.": "",
|
||||
"The copy on your Frame differs from what FramePort would install now (the recipe or your game files changed). Update to install the new build; saves are kept.": "",
|
||||
"The CPU types the game ships code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games can't run on it.": "",
|
||||
"The download didn't finish. Check your internet connection.": "",
|
||||
"the downloaded app": "",
|
||||
"The first picture takes a few seconds.": "",
|
||||
"The Frame can't keep up at this quality: {n} frame dropped in the last 10 seconds. Choose a lower quality for a smoother picture.": [
|
||||
"",
|
||||
@@ -1151,8 +1213,8 @@
|
||||
"The Frame restarts in a few seconds; FramePort reconnects when it's back.": "",
|
||||
"The Frame turns off in a few seconds. Turn it on again with its power button.": "",
|
||||
"The Frame went offline: {exc}": "",
|
||||
"The Frame's battery is at {pct} % and not charging. Plug it in (a strong charger, not a PC port): long installs can drain it, and FramePort pauses them at {pause} %.": "",
|
||||
"The Frame's battery is at {pct} %: installs paused so it doesn't switch off mid-upload. Plug it in; the queue continues by itself.": "",
|
||||
"The Frame's battery is at {pct}% and not charging. Plug it in (a strong charger, not a PC port): long installs can drain it, and FramePort pauses them at {pause}%.": "",
|
||||
"The Frame's battery is at {pct}%: installs paused so it doesn't switch off mid-upload. Plug it in; the queue continues by itself.": "",
|
||||
"The Frame's CPU is arm64. Programs built for x86_64 PCs run through FEX, Valve's x86 translator, with the x86 libraries SteamOS ships for it (glibc, Mesa) - the way Steam on the Frame runs x86 Linux games. That works for many programs but is slower: use an arm64 build when there is one.": "",
|
||||
"The Frame's desktop password, only needed the first time so FramePort can add its own key. After that it connects with the key.": "",
|
||||
"The Frame's Steam couldn't start {title} (Steam error {code}). Please send a problem report so we can see why.": "",
|
||||
@@ -1160,6 +1222,7 @@
|
||||
"The Frame's Steam library still shows the old artwork.": "",
|
||||
"the game didn't pass its checks (see the list above)": "",
|
||||
"The game is closed the way Steam's Exit game does it; if it doesn't stop, FramePort stops it. Progress that the game hasn't saved is lost.": "",
|
||||
"The headset's view and sound open in your web browser. Stop the stream when you're done.": "",
|
||||
"the installed game (APK and its data)": "",
|
||||
"The launch settings changed since it was installed: update the Steam shortcut": "",
|
||||
"The live view can start once FramePort is connected to the Frame.": "",
|
||||
@@ -1168,13 +1231,15 @@
|
||||
"The monitor shows your Frame's games, load, temperatures and battery live once FramePort is connected to it.": "",
|
||||
"the other app": "",
|
||||
"The PC VR counterpart of OVRPort's 'Patch Oculus detection for Unreal'. Unreal's Oculus plugin only starts when the Oculus service announces a headset (the Windows event 'OculusHMDConnected'); without it the game runs as a flat window. On the Frame the launcher runs the game through FramePort's small helper (fp_oculushmd.exe) that provides that event while the game runs, instead of relying only on Revive's hook of the check. Ignored on this PC (the Oculus app or Revive handle it there).": "",
|
||||
"The picture opens in your default web browser, where it plays smoothly and can go full screen. It shows the headset's view with its sound (click Sound on in the player) whatever is running: Steam's menus, SteamVR or a game; while the headset sleeps the picture is black and updates about once a second. The Frame's hardware video encoder does the work (if it isn't available, the processor does, which costs a game more); stop the stream when you're done.": "",
|
||||
"The picture opens in your default web browser, where it plays smoothly and can go full screen. It shows the headset's view with its sound (click Sound on in the player) whatever is running: Steam's menus, SteamVR or a game; while the headset sleeps the picture is black and updates about once a second. The Frame's hardware video encoder does the work (if it isn't available, the processor does, which costs a game more): stop the stream when you're done.": "",
|
||||
"the previous version, kept for one rollback": "",
|
||||
"The repack's own Revive · runs the game directly": "",
|
||||
"The small helper program FramePort runs on the Frame (installs, launch tests, Steam entries). It comes with this app and is replaced on the Frame automatically whenever it differs.": "",
|
||||
"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.": "",
|
||||
@@ -1185,17 +1250,18 @@
|
||||
"They use the Oculus Platform SDK, which comes with the Meta Horizon (Oculus) app — it isn't installed on this PC, so they may quit right after starting. FramePort doesn't change how a game checks its license. Untick the ones you'd rather skip.": "",
|
||||
"They use the Oculus Platform SDK, which comes with the Meta Horizon (Oculus) app. It doesn't exist on the Steam Frame, so there they crash right at startup (seen with Robo Recall, Lies Beneath and Lone Echo) — play them on this PC. Tick any you still want to try on the Frame.": "",
|
||||
"They're Oculus games that need Revive to reach VR, and Revive can't run on the Frame. Play them on this PC instead (Install on this PC — SteamVR + Revive). Tick any you still want to put on the Frame to experiment (they'll likely run flat or crash).": "",
|
||||
"This computer's keyboard works as a keyboard on your Frame while this tab is open. In the headset, select a text field (in a game or app, in Steam or on the desktop), then type here.": "",
|
||||
"This deletes the {what} on the Frame. It can't be undone.": "",
|
||||
"This folder is empty. Upload files with the buttons above.": "",
|
||||
"This Frame isn't set up for FramePort yet. Run the first-time setup: Steam Frame → Show setup command.": "",
|
||||
"This game can't run on the Steam Frame.": "",
|
||||
"This game is marked \"Can't run\" on the Steam Frame:": "",
|
||||
"This game's folder has several programs and FramePort isn't sure which one starts the game. Open the game (or right-click → Change executable) to pick it.": "",
|
||||
"This game's folder has several programs and FramePort isn't sure which one starts the game. Open the game (or right-click → Change program…) to pick it.": "",
|
||||
"this PC": "",
|
||||
"This PC": "",
|
||||
"This PC (for PC VR games)": "",
|
||||
"This PC (Steam + Revive)": "",
|
||||
"This PC's disk is full. Free some space and try again.": "",
|
||||
"This PC's keyboard works as a keyboard on your Frame while this tab is open. In the headset, select a text field (in a game or app, in Steam or on the desktop), then type here.": "",
|
||||
"This removes:": "",
|
||||
"This source checkout has uncommitted changes: commit or stash them, then update": "",
|
||||
"This system can't send web links to FramePort: use Add games → Install from a link…": "",
|
||||
@@ -1209,6 +1275,7 @@
|
||||
"transparent PNG": "",
|
||||
"Treat newer Quest controllers as Touch": "",
|
||||
"Tried it in the headset? Tell us how it runs: with your recipe it can join FramePort's built-in list, so it works out of the box for everyone.": "",
|
||||
"Troubleshoot": "",
|
||||
"Troubleshooting": "",
|
||||
"Try \"Fixed\" or \"Off\" if one eye shimmers or jitters while menus look fine.": "",
|
||||
"Try again": "",
|
||||
@@ -1230,6 +1297,7 @@
|
||||
"Type": "",
|
||||
"Type it": "",
|
||||
"Type on Frame": "",
|
||||
"Type to search games and tags": "",
|
||||
"Typed it, except characters the Frame's US keyboard layout doesn't have: {chars}": "",
|
||||
"Typing on the Frame works once FramePort is connected to it.": "",
|
||||
"ufw is active. Allow the setup port with: sudo ufw allow {port}/tcp (and afterwards: sudo ufw delete allow {port}/tcp)": "",
|
||||
@@ -1237,8 +1305,9 @@
|
||||
"Uninstall FramePort": "",
|
||||
"Uninstall FramePort?": "",
|
||||
"Uninstall FramePort…": "",
|
||||
"Uninstall from Frame": "",
|
||||
"Uninstall from Frame…": "",
|
||||
"Uninstall from the Frame. ": "",
|
||||
"Uninstall from this PC…": "",
|
||||
"Uninstall {title}": "",
|
||||
"Uninstall {title}?": "",
|
||||
"Uninstall. ": "",
|
||||
@@ -1253,17 +1322,18 @@
|
||||
"Unity: render each eye separately (no multiview)": "",
|
||||
"Unity: skip OVROverlay layers (fades, splash screens)": "",
|
||||
"Unity: start VR without Meta's system apps": "",
|
||||
"unknown": "",
|
||||
"Unknown date": "",
|
||||
"Unreal builds of Meta XR Audio abort ('ClassNotFoundException com.oculus.os.AnalyticsEvent') on non-Quest devices. Patches that one JNI FindClass call so the library continues (e.g. NOPE Challenge).": "",
|
||||
"Unreal games start CrashReportClient when they crash, which leaves a crash dialog instead of simply closing. This passes -nocrashreports to the game and, on the Frame, renames the game's copy of CrashReportClient.exe so it can't start (your game files on this PC aren't changed).": "",
|
||||
"Untested": "",
|
||||
"Update all": "",
|
||||
"Update all ({updates})": "",
|
||||
"Update anyway": "",
|
||||
"Update anyway…": "",
|
||||
"Update available": "",
|
||||
"Update failed: {exc}": "",
|
||||
"Update FramePort to {version}": "",
|
||||
"Update game configs from GitHub automatically (confirmed configs arrive without a FramePort update)": "",
|
||||
"Update game recipes from GitHub automatically (confirmed recipes arrive without a FramePort update)": "",
|
||||
"Update now": "",
|
||||
"Update on Frame": "",
|
||||
"Update on this PC": "",
|
||||
@@ -1273,11 +1343,11 @@
|
||||
"Update tools": "",
|
||||
"Updates": "",
|
||||
"Updates every game marked \"Update ready\" (a newer build, or patch settings changed since it was installed), one after another.": "",
|
||||
"Updates this source checkout (git pull + uv sync), then restarts FramePort.": "",
|
||||
"Updates, tools, this PC and where FramePort keeps its data": "",
|
||||
"Updating works in the desktop app": "",
|
||||
"Upload files": "",
|
||||
"Upload": "",
|
||||
"Upload files…": "",
|
||||
"Upload folder": "",
|
||||
"Upload folder…": "",
|
||||
"Upload to {label}": "",
|
||||
"Uploaded {n} file": [
|
||||
@@ -1290,14 +1360,14 @@
|
||||
"Use FramePort": "",
|
||||
"Use FramePort for these links": "",
|
||||
"Use tags to group and filter your library": "",
|
||||
"Use the new config": "",
|
||||
"Use the new recipe": "",
|
||||
"Use the repack's launcher (bundled Revive)": "",
|
||||
"Use this computer's keyboard on the Frame": "",
|
||||
"Use this PC's keyboard on the Frame": "",
|
||||
"Use this program": "",
|
||||
"Use your own artwork…": "",
|
||||
"Use your own images…": "",
|
||||
"Uses OVRPort, Revive (LibreVR), Valve's Lepton and Proton. Not affiliated with Valve or Meta.": "",
|
||||
"Uses the Oculus Platform SDK: it checks your Oculus license. Normally that needs the Oculus app on this PC with a license you own, so it may quit right after starting on the headset. You can still try it.": "",
|
||||
"Uses the Oculus Platform SDK, which checks your Oculus license: it needs the Oculus app on this PC and may quit right after starting. You can still try it.": "",
|
||||
"Uses the repack's bundled Revive — experimental on the Frame": "",
|
||||
"Uses the store title/icon (OVRPort image service) for the app label.": "",
|
||||
"Uses the store's name and icon for the game.": "",
|
||||
@@ -1332,14 +1402,14 @@
|
||||
"Waiting for the Frame": "",
|
||||
"Waiting for your Frame": "",
|
||||
"Waiting for your Frame… (code {code})": "",
|
||||
"waiting {pct} %": "",
|
||||
"waiting {pct}%": "",
|
||||
"Watch what the headset shows, in a window on this PC": "",
|
||||
"Welcome to FramePort": "",
|
||||
"What FramePort will do": "",
|
||||
"What happens? (optional: you can also write it on GitHub)": "",
|
||||
"What the whole Frame draws right now (its main power rail), with the share of the CPU and the graphics chip. Higher power means more heat and a shorter battery.": "",
|
||||
"What your Frame is doing right now": "",
|
||||
"What's new": "",
|
||||
"What's new…": "",
|
||||
"Where it's installed": "",
|
||||
"Where the power goes": "",
|
||||
"Where this game's recipe came from: catalog (tested on a Frame), heuristics (suggested by FramePort) or user (changed by you).": "",
|
||||
@@ -1352,6 +1422,7 @@
|
||||
"Windows game (Proton)": "",
|
||||
"Windows not detected": "",
|
||||
"Windows program": "",
|
||||
"Windows program ran": "",
|
||||
"Windows treats this network as public and its firewall blocks the Frame. In Windows' network settings set this network to Private (or allow FramePort on public networks when Windows asks).": "",
|
||||
"Windows' firewall for WSL blocks the Frame from reaching FramePort. Open the setup command again and allow the change when Windows asks (admin).": "",
|
||||
"Windows' firewall for WSL blocks the Frame. Open the setup command again and allow the change when Windows asks (admin).": "",
|
||||
@@ -1375,7 +1446,9 @@
|
||||
"You already have the latest dev build ({version})": "",
|
||||
"You have the latest version as of the last check ({when}).": "",
|
||||
"You have {version}. {how}": "",
|
||||
"You only do this once: FramePort connects by itself when the setup has finished.": "",
|
||||
"You pasted a link to {name}.": "",
|
||||
"Your changes to its patches and game settings are replaced by the suggested recipe.": "",
|
||||
"Your copy is used as it is (only the Frame's copy gets launch patches)": "",
|
||||
"Your custom recipe": "",
|
||||
"your Frame": "",
|
||||
@@ -1433,11 +1506,11 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game config was updated": [
|
||||
"{n} game didn't work out": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game didn't work out": [
|
||||
"{n} game recipe was updated": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
@@ -1487,11 +1560,14 @@
|
||||
"{title} will be in the Frame's Steam library when the game that's running is closed": "",
|
||||
"{title} will be in the Frame's Steam library when you're back in Gaming Mode: start it from there": "",
|
||||
"{title}: analyzed again": "",
|
||||
"{title}: checks failed": "",
|
||||
"{title}: details from {value}": "",
|
||||
"{title}: done": "",
|
||||
"{title}: launch test {verdict} (furthest: {value})": "",
|
||||
"{title}: ready": "",
|
||||
"{title}: Steam artwork updated on the Frame": "",
|
||||
"{used} of {total}": "",
|
||||
"{used}/{max} kernel keys used": "",
|
||||
"{user}@{host} · {get} {get2} (build {get3})": "",
|
||||
"{value:.0f} MiB": "",
|
||||
"{value:.1f} GiB": "",
|
||||
|
||||
@@ -486,12 +486,7 @@ def set_recipe(package: str, recipe: Recipe) -> None:
|
||||
|
||||
|
||||
def reset_recipe(package: str) -> Recipe:
|
||||
if is_rift(library.game(package) or {}):
|
||||
entry = library.game(package)
|
||||
a = library.analysis_from_dict(entry["analysis"])
|
||||
recipe = engine.suggest(a)
|
||||
set_recipe(package, recipe)
|
||||
return recipe
|
||||
"""Back to the suggested recipe (catalog or heuristics) for the game's analysis (Quest and Rift alike)."""
|
||||
entry = library.game(package)
|
||||
recipe = engine.suggest(library.analysis_from_dict(entry["analysis"]))
|
||||
set_recipe(package, recipe)
|
||||
|
||||
@@ -307,7 +307,7 @@ TOGGLED_DEVICE = ("device.text_input_window",)
|
||||
|
||||
def entry_from_library(g: dict, status: str | None = None, notes: str | None = None,
|
||||
verified: dict | None = None) -> CatalogEntry:
|
||||
"""A catalog recipe from a library entry (what "Save as known-good" and "Share working config" publish)."""
|
||||
"""A catalog recipe from a library entry (what "Save as known-good" and "Share working recipe" publish)."""
|
||||
import time
|
||||
|
||||
from ..core import library
|
||||
|
||||
@@ -32,10 +32,10 @@ def advice(battery: dict | None, frame_work: bool, paused: str | None, warned: b
|
||||
|
||||
|
||||
def label(battery: dict | None) -> str:
|
||||
""" "76 %" / "76 % ⚡" (charging or plugged in); "" without a battery reading."""
|
||||
""" "76%" / "76% ⚡" (charging or plugged in); "" without a battery reading."""
|
||||
if not battery:
|
||||
return ""
|
||||
return f"{battery.get('percent', 0)} %" + (" ⚡" if charging(battery) else "")
|
||||
return f"{battery.get('percent', 0)}%" + (" ⚡" if charging(battery) else "")
|
||||
|
||||
|
||||
def icon(battery: dict) -> str:
|
||||
@@ -55,10 +55,10 @@ def low(battery: dict | None) -> bool:
|
||||
def message(kind: str, battery: dict) -> str:
|
||||
pct = battery.get("percent", 0)
|
||||
if kind == "pause":
|
||||
return tr("The Frame's battery is at {pct} %: installs paused so it doesn't switch off mid-upload. Plug it "
|
||||
return tr("The Frame's battery is at {pct}%: installs paused so it doesn't switch off mid-upload. Plug it "
|
||||
"in; the queue continues by itself.").format(pct=pct)
|
||||
if kind == "warn":
|
||||
return tr("The Frame's battery is at {pct} % and not charging. Plug it in (a strong charger, not a PC "
|
||||
"port): long installs can drain it, and FramePort pauses them at {pause} %.").format(
|
||||
return tr("The Frame's battery is at {pct}% and not charging. Plug it in (a strong charger, not a PC "
|
||||
"port): long installs can drain it, and FramePort pauses them at {pause}%.").format(
|
||||
pct=pct, pause=PAUSE_AT)
|
||||
return tr("The Frame is charging: continuing the installs.")
|
||||
@@ -3,26 +3,38 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import NamedTuple
|
||||
|
||||
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 +75,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 +174,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)
|
||||
@@ -173,25 +187,27 @@ def switch(label: str = "", wrap: bool = True, **kw) -> ft.Control:
|
||||
update(sw)
|
||||
if sw.on_change:
|
||||
sw.on_change(SimpleNamespace(control=sw))
|
||||
return ft.Row([sw, ft.Container(ft.Text(label, color=T.TEXT, size=T.px(14)), expand=True, on_click=click_label)],
|
||||
return ft.Row([sw, ft.Container(ft.Text(label, color=T.TEXT, size=T.px(14)), expand=True, on_click=click_label,
|
||||
ink=True, border_radius=T.RADIUS_XS)],
|
||||
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),
|
||||
@@ -210,6 +226,8 @@ def install_badge(state: str, solid: bool = True) -> ft.Container:
|
||||
|
||||
|
||||
def dot(color: str, size: int = 8) -> ft.Container:
|
||||
"""A status dot; `size` in pixels at 100 % (scaled here)."""
|
||||
size = T.px(size)
|
||||
return ft.Container(width=size, height=size, border_radius=size, bgcolor=color)
|
||||
|
||||
|
||||
@@ -242,7 +260,67 @@ 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 icon: Material icons stay as they are (the control colours them), glyphs become tinted images at
|
||||
the size Flutter gives icons in buttons (18; not scaled with the UI, like the Material icons next to them).
|
||||
Menu items size their icons themselves (menu_items)."""
|
||||
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
|
||||
portal = ft.Image(src=asset_url(mark), width=size * 0.8, height=size * 1.4, fit=ft.BoxFit.CONTAIN, scale=1.0,
|
||||
animate_scale=ft.Animation(220, ft.AnimationCurve.EASE_OUT))
|
||||
# .data = the portal (the app pulses it once when the Frame connects)
|
||||
return ft.Row([word("Frame", T.ACCENT), portal, word("Port", T.SECONDARY)], data=portal,
|
||||
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 button_shape(radius=None) -> ft.RoundedRectangleBorder:
|
||||
"""The buttons' shape (RADIUS_SM unless given)."""
|
||||
return _shape(radius)
|
||||
|
||||
|
||||
def _shape(radius=None):
|
||||
radius = T.RADIUS_SM if radius is None else radius
|
||||
return ft.RoundedRectangleBorder(radius=radius)
|
||||
@@ -252,7 +330,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 +343,201 @@ 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, url: str | None = None) -> ft.TextButton:
|
||||
"""A quiet action (Close, Cancel, links); `color` e.g. T.ACCENT for a link, `url` opens in the browser."""
|
||||
return ft.TextButton(label, icon=_btn_icon(icon, color or T.TEXT_2, disabled), on_click=on_click,
|
||||
disabled=disabled, tooltip=tooltip, url=url,
|
||||
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()))
|
||||
|
||||
|
||||
def danger(label: str, icon: str | None = None, on_click: Callable | None = None, disabled: bool = False,
|
||||
tooltip: str | None = None, outline: bool = False) -> ft.FilledButton | ft.OutlinedButton:
|
||||
"""A destructive action (uninstall, delete): C.primary's shape and size on the error colour. outline = C.secondary's
|
||||
look in the error colour, for an entry that asks first (the filled one is the dialog's final button)."""
|
||||
if outline:
|
||||
btn = secondary(label, icon, on_click, disabled, tooltip)
|
||||
btn.style.side = ft.BorderSide(1, T.soft(T.ERROR, 0.6))
|
||||
btn.style.color = {ft.ControlState.DEFAULT: T.ERROR, ft.ControlState.DISABLED: T.TEXT_3}
|
||||
btn.style.icon_color = {ft.ControlState.DEFAULT: T.ERROR, ft.ControlState.DISABLED: T.TEXT_3}
|
||||
btn.style.overlay_color = T.soft(T.ERROR, 0.08)
|
||||
if glyphs.is_glyph(icon):
|
||||
btn.icon = _btn_icon(icon, T.ERROR, disabled)
|
||||
return btn
|
||||
btn = primary(label, icon, on_click, disabled, tooltip)
|
||||
btn.style.bgcolor = {ft.ControlState.DEFAULT: T.ERROR, ft.ControlState.DISABLED: T.SURFACE_3}
|
||||
return btn
|
||||
|
||||
|
||||
_danger = danger # for functions with a `danger` argument
|
||||
|
||||
|
||||
def primary_menu(label: str, icon: str | None, items: list[ft.PopupMenuItem], tooltip: str | None = None
|
||||
) -> ft.PopupMenuButton:
|
||||
"""A menu that opens from a button looking exactly like C.primary (e.g. Library → Add games). The button is
|
||||
drawn disabled (in the enabled colours) so it doesn't take the click: the menu button around it does."""
|
||||
btn = primary(label, icon, disabled=True)
|
||||
btn.style.bgcolor = T.ACCENT
|
||||
btn.style.color = T.ON_ACCENT
|
||||
btn.style.icon_color = T.ON_ACCENT
|
||||
btn.style.mouse_cursor = ft.MouseCursor.CLICK
|
||||
return menu_button(items, content=btn, tooltip=tooltip or "")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ inputs
|
||||
def _input_style(kw: dict) -> dict:
|
||||
"""The one input look: filled SURFACE_3, no border until focused (then the accent), small radius."""
|
||||
kw.setdefault("dense", True)
|
||||
kw.setdefault("border_radius", T.RADIUS_SM)
|
||||
kw.setdefault("border_color", ft.Colors.TRANSPARENT)
|
||||
kw.setdefault("focused_border_color", T.ACCENT)
|
||||
kw.setdefault("text_size", T.T_BODY)
|
||||
kw.setdefault("content_padding", ft.Padding(T.px(12), T.px(10), T.px(12), T.px(10)))
|
||||
return kw
|
||||
|
||||
|
||||
def field(**kw) -> ft.TextField:
|
||||
"""A text field in the app's input style (all ft.TextField arguments work)."""
|
||||
kw = _input_style(kw)
|
||||
kw.setdefault("bgcolor", T.SURFACE_3) # (filled=True would add Material's extra height)
|
||||
kw.setdefault("cursor_color", T.ACCENT)
|
||||
if kw.get("label") is not None:
|
||||
kw.setdefault("label_style", ft.TextStyle(color=T.TEXT_2, size=T.T_BODY))
|
||||
return ft.TextField(**kw)
|
||||
|
||||
|
||||
def search(**kw) -> ft.TextField:
|
||||
"""A search field: C.field with a magnifier."""
|
||||
kw.setdefault("prefix_icon", ft.Icons.SEARCH_ROUNDED)
|
||||
kw.setdefault("content_padding", ft.Padding(T.px(12), T.px(8), T.px(12), T.px(8)))
|
||||
return field(**kw)
|
||||
|
||||
|
||||
def dropdown(**kw) -> ft.Dropdown:
|
||||
"""A dropdown in the app's input style (all ft.Dropdown arguments work)."""
|
||||
kw.setdefault("content_padding", ft.Padding(T.px(12), T.px(6), T.px(8), T.px(6)))
|
||||
kw = _input_style(kw)
|
||||
kw.setdefault("filled", True)
|
||||
kw.setdefault("fill_color", T.SURFACE_3)
|
||||
if kw.get("label") is not None:
|
||||
kw.setdefault("label_style", ft.TextStyle(color=T.TEXT_2, size=T.T_BODY))
|
||||
return ft.Dropdown(**kw)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ selection
|
||||
def selected_style(box: ft.Container, on: bool, icon: ft.Control | None = None, text: ft.Control | None = None,
|
||||
idle_bg: str | None = None, subtle: bool = False) -> None:
|
||||
"""The app-wide "this one is selected" look, set on an existing container (call again when it changes): dual
|
||||
themes = the portal fade with a white icon and label (an orange icon on the fade clashed: owner), else the accent
|
||||
tint with an accent icon. `idle_bg` = the background when not selected; subtle = a lighter fade (an overlay on
|
||||
a picture, e.g. a selected screenshot)."""
|
||||
if T.DUAL:
|
||||
box.bgcolor, box.gradient = (None, portal_gradient(opacity=0.14 if subtle else 0.22)) if on else (idle_bg, None)
|
||||
else:
|
||||
box.bgcolor, box.gradient = (T.soft(T.ACCENT, 0.12) if subtle else T.ACCENT_SOFT) if on else idle_bg, None
|
||||
if icon is not None:
|
||||
icon.color = (T.TEXT if T.DUAL else T.ACCENT) if on else T.TEXT_2
|
||||
if text is not None:
|
||||
text.color = T.TEXT if on else T.TEXT_2
|
||||
|
||||
|
||||
def quiet(*controls: ft.Control, shown: bool = False) -> ft.Container:
|
||||
"""Row/tile actions that only show on hover, keyboard focus or selection ("quiet rows"): opacity 0 keeps their
|
||||
space, so the layout doesn't jump when they appear. Toggle with reveal()."""
|
||||
content = controls[0] if len(controls) == 1 else ft.Row(list(controls), spacing=0, tight=True)
|
||||
return ft.Container(content, opacity=1.0 if shown else 0.0, animate_opacity=ft.Animation(120))
|
||||
|
||||
|
||||
def reveal(box: ft.Container, on: bool) -> bool:
|
||||
"""Show/hide a quiet() box; True when it changed (the caller updates it)."""
|
||||
value = 1.0 if on else 0.0
|
||||
if box.opacity == value:
|
||||
return False
|
||||
box.opacity = value
|
||||
return True
|
||||
|
||||
|
||||
def segmented(options: list[tuple[str, str]], value: str | None, on_change: Callable[[str], None]) -> ft.Container:
|
||||
"""A pill track of choices (one selected): the selected one is the portal fade in dual themes, else SURFACE_3.
|
||||
Clicking restyles in place, then calls on_change(value)."""
|
||||
items: dict[str, ft.Container] = {}
|
||||
|
||||
def paint(current):
|
||||
for v, box in items.items():
|
||||
on = v == current
|
||||
box.content.color = T.TEXT if on else T.TEXT_2
|
||||
if T.DUAL:
|
||||
box.bgcolor, box.gradient = None, portal_gradient(opacity=0.22) if on else None
|
||||
else:
|
||||
box.bgcolor, box.gradient = T.SURFACE_3 if on else None, None
|
||||
|
||||
def click(v):
|
||||
def handler(e):
|
||||
if track.data == v:
|
||||
return
|
||||
track.data = v
|
||||
paint(v)
|
||||
update(track)
|
||||
on_change(v)
|
||||
return handler
|
||||
|
||||
for v, label in options:
|
||||
items[v] = ft.Container(ft.Text(label, size=T.T_META, weight=ft.FontWeight.W_600),
|
||||
padding=ft.Padding(T.px(12), T.px(6), T.px(12), T.px(6)), border_radius=T.px(20),
|
||||
on_click=click(v), ink=True)
|
||||
track = ft.Container(ft.Row(list(items.values()), spacing=T.px(2), tight=True), padding=T.px(3),
|
||||
border_radius=T.px(22), border=ft.Border.all(1, T.BORDER), data=value)
|
||||
paint(value)
|
||||
return track
|
||||
|
||||
|
||||
def hoverable(box: ft.Container) -> ft.Container:
|
||||
"""A clickable container: ink (the hover highlight and the click ripple, drawn over its background or the
|
||||
selected fade) like the buttons around it."""
|
||||
box.ink = True
|
||||
return box
|
||||
|
||||
|
||||
def spinner(size: str = "s", color: str | None = None) -> ft.ProgressRing:
|
||||
"""Something is running: "s" (16, next to text) or "m" (24); blue in dual themes (progress), else the accent.
|
||||
`color` only for a spinner on a coloured fill (e.g. ON_ACCENT inside a primary button)."""
|
||||
px = T.ICON_S if size == "s" else T.ICON_L
|
||||
return ft.ProgressRing(width=px, height=px, stroke_width=T.px(2),
|
||||
color=color or (T.SECONDARY if T.DUAL else T.ACCENT))
|
||||
|
||||
|
||||
def gauge(value: float = 0.0, size: int = 40, color: str | None = None, stroke: float = 3.5) -> ft.ProgressRing:
|
||||
"""A level as a ring (e.g. the Frame card's battery): value 0..1 on a SURFACE_3 track; `size` px at 100 %."""
|
||||
px = T.px(size)
|
||||
return ft.ProgressRing(value=value, width=px, height=px, stroke_width=T.px(stroke), color=color or T.OK,
|
||||
bgcolor=T.SURFACE_3)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ 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,20 +553,33 @@ 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)))
|
||||
|
||||
|
||||
def portal_scene(icon: str, height: float | None = None) -> ft.Control:
|
||||
"""The transit's portal with what the page is about (`icon`) coming out of it, on a faint glow: the empty states'
|
||||
illustration (the Live view's idle picture uses the same portal + headset)."""
|
||||
h = height or T.px(72)
|
||||
portal = transit_parts()["portal"]
|
||||
return ft.Container(
|
||||
ft.Row([ft.Image(src=portal, height=h, width=h * 24 / 50, fit=ft.BoxFit.CONTAIN),
|
||||
as_icon(icon, h * 0.5, T.TEXT_2)],
|
||||
spacing=T.S3, tight=True, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
# the glow fades out at the box's top and bottom edges (radius = half the shortest side): no visible edge
|
||||
padding=ft.Padding(T.S6, T.S3, T.S6, T.S3),
|
||||
gradient=ft.RadialGradient(colors=[T.soft(T.ACCENT, 0.13), T.soft(T.ACCENT, 0.0)], radius=0.5))
|
||||
|
||||
|
||||
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),
|
||||
border_radius=T.px(42), bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
|
||||
portal_scene(icon),
|
||||
ft.Container(height=T.S2),
|
||||
title(heading, 22),
|
||||
title(heading, T.T_DISPLAY),
|
||||
ft.Container(body(text, text_align=ft.TextAlign.CENTER), width=T.px(460)),
|
||||
ft.Container(height=T.S2),
|
||||
ft.Row(list(actions), alignment=ft.MainAxisAlignment.CENTER, spacing=T.S3),
|
||||
@@ -330,20 +593,56 @@ def kv(label: str, value: str | ft.Control, help: str | None = None) -> ft.Row:
|
||||
expand=True)], vertical_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
|
||||
def status_row(ok: bool | None, heading: str, detail: str = "", action: ft.Control | None = None,
|
||||
help: str | None = None) -> ft.Container:
|
||||
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)),
|
||||
ft.Column([with_help(body(heading, T.TEXT, weight=ft.FontWeight.W_500), help)]
|
||||
+ ([meta(detail)] if detail else []),
|
||||
spacing=1, expand=True),
|
||||
*([action] if action else []),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3), padding=ft.Padding(0, T.px(6), 0, T.px(6)))
|
||||
class Check(NamedTuple):
|
||||
"""One line of a setup checklist (checklist()). ok: True = ready, False = a problem, "warn" = optional/not
|
||||
needed yet, None = still checking (spinner). fix = (label, icon, handler) shown on the right while the item isn't
|
||||
ready (handler() takes no arguments); extra = a control shown on the right in every state (e.g. "Test")."""
|
||||
ok: bool | str | None
|
||||
title: str
|
||||
detail: str = ""
|
||||
help: str | None = None
|
||||
fix: tuple[str, str | None, Callable[[], object]] | None = None
|
||||
extra: ft.Control | None = None
|
||||
|
||||
|
||||
def art_fill(src: str | None, radius: int | None = None, placeholder_icon: str = ft.Icons.VIDEOGAME_ASSET_ROUNDED,
|
||||
def check_look(ok: bool | str | None) -> tuple[str | None, str]:
|
||||
"""(icon, colour) of a checklist mark; icon None = a spinner (still checking)."""
|
||||
return {True: (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK), False: (ft.Icons.ERROR_ROUNDED, T.ERROR),
|
||||
"warn": (ft.Icons.RADIO_BUTTON_UNCHECKED_ROUNDED, T.WARN),
|
||||
None: (None, T.SECONDARY if T.DUAL else T.ACCENT)}[ok]
|
||||
|
||||
|
||||
def check_fix(item: Check) -> tuple[str, str | None, Callable[[], object]] | None:
|
||||
"""The fix button an item shows: only while it's a problem or not set up (not while ready or still checking)."""
|
||||
return item.fix if item.ok in (False, "warn") else None
|
||||
|
||||
|
||||
def checklist(items: list[Check | tuple]) -> ft.Column:
|
||||
"""The setup checklists (Frame → Ready to play, Settings → Tools and This PC): a mark, the title (+ help) and
|
||||
detail, and the item's fix button inline on the right. Put it in a card(padding=Padding(S4, S2, S4, S2))."""
|
||||
rows = []
|
||||
for raw in items:
|
||||
item = raw if isinstance(raw, Check) else Check(*raw)
|
||||
icon, color = check_look(item.ok)
|
||||
mark = ft.Container(spinner() if icon is None else as_icon(icon, T.px(20), color), width=T.px(20),
|
||||
alignment=ft.Alignment.CENTER)
|
||||
right: list[ft.Control] = []
|
||||
fix = check_fix(item)
|
||||
if fix:
|
||||
label, fix_icon, handler = fix
|
||||
right.append(secondary(label, fix_icon, lambda e, h=handler: h()))
|
||||
if item.extra is not None:
|
||||
right.append(item.extra)
|
||||
rows.append(ft.Container(ft.Row([
|
||||
mark,
|
||||
ft.Column([with_help(body(item.title, T.TEXT, weight=ft.FontWeight.W_500), item.help)]
|
||||
+ ([meta(item.detail)] if item.detail else []), spacing=1, expand=True),
|
||||
*right,
|
||||
], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3), padding=ft.Padding(0, T.px(6), 0, T.px(6))))
|
||||
return ft.Column(rows, spacing=0)
|
||||
|
||||
|
||||
def art_fill(src: str | None, radius: int | None = None, placeholder_icon: str = ft.Icons.VIDEOGAME_ASSET_OUTLINED,
|
||||
hero: bool = False, **kw) -> ft.Container:
|
||||
"""Artwork that covers its box (cards, heroes), by asset URL (artwork.thumbs.url — never image bytes: those are
|
||||
re-sent on every update); an accent gradient with a large faint icon when there's none."""
|
||||
@@ -351,12 +650,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:
|
||||
@@ -365,40 +664,130 @@ def bottom_fade(height: int | None = None, strength: float = 0.85) -> ft.Contain
|
||||
colors=[ft.Colors.TRANSPARENT, T.soft("#000000", strength)]))
|
||||
|
||||
|
||||
DIALOG_TITLE_SIZE = 20 # px at 100 %
|
||||
|
||||
|
||||
def dialog(title: str | ft.Control | None, content: ft.Control | None, actions: list[ft.Control] | None = None,
|
||||
size: str = "m", modal: bool = False, on_dismiss: Callable | None = None,
|
||||
title_actions: list[ft.Control] | None = None, width: float | None = None, height: float | None = None,
|
||||
**kw) -> ft.AlertDialog:
|
||||
"""The one dialog look: SURFACE_2, RADIUS corners, a semibold title, content `size` wide ("s"/"m"/"l" =
|
||||
DIALOG_S/M/L, or `width`; `height` fixes it), actions at the end (Cancel/Close first, then the action).
|
||||
title_actions go at the title's right (e.g. a counter or a close button)."""
|
||||
if isinstance(title, str):
|
||||
title = ft.Text(title, size=T.px(DIALOG_TITLE_SIZE), weight=ft.FontWeight.W_600, color=T.TEXT)
|
||||
if title is not None and title_actions:
|
||||
title = ft.Row([ft.Container(title, expand=True), *title_actions],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
width = width or {"s": T.DIALOG_S, "m": T.DIALOG_M, "l": T.DIALOG_L}[size]
|
||||
box = ft.Container(content, width=width, height=height) if content is not None else None
|
||||
return ft.AlertDialog(title=title, content=box,
|
||||
actions=actions or [], modal=modal, on_dismiss=on_dismiss, bgcolor=T.SURFACE_2,
|
||||
shape=_shape(T.RADIUS), actions_alignment=ft.MainAxisAlignment.END, **kw)
|
||||
|
||||
|
||||
def dialog_open(page: ft.Page | None) -> bool:
|
||||
"""Whether a dialog (or viewer/bottom sheet) is open: Flet 1.0's page.show_dialog keeps every shown dialog in
|
||||
page._dialogs until Flutter reports it dismissed, and pop_dialog sets open=False at once. Snack bars (toasts)
|
||||
travel the same way but don't take the keyboard, so they don't count. Without that list: assume none."""
|
||||
try:
|
||||
shown = page._dialogs.controls # noqa: SLF001 (Flet has no public "is a dialog open")
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return any(d.open and not isinstance(d, ft.SnackBar) for d in shown)
|
||||
|
||||
|
||||
def viewer(content: ft.Control, on_dismiss: Callable | None = None, **kw) -> ft.AlertDialog:
|
||||
"""A full-size image/screenshot viewer: no title, a dark backdrop around the picture."""
|
||||
return ft.AlertDialog(content=content, bgcolor=T.BG, content_padding=T.S3, shape=_shape(T.RADIUS),
|
||||
on_dismiss=on_dismiss, **kw)
|
||||
|
||||
|
||||
def confirm(page: ft.Page, heading: str, text: str, ok_label: str, on_ok: Callable[[], None],
|
||||
danger: bool = False, extra: ft.Control | None = None) -> None:
|
||||
"""A yes/no dialog; `extra` (e.g. an option checkbox) goes below the text and is read by on_ok."""
|
||||
def go(e):
|
||||
page.pop_dialog()
|
||||
on_ok()
|
||||
content = body(text) if extra is None else ft.Column([body(text), extra], spacing=T.px(12), tight=True)
|
||||
page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(heading, weight=ft.FontWeight.W_600), content=ft.Container(content, width=T.px(420)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
ft.FilledButton(ok_label, on_click=go, style=ft.ButtonStyle(
|
||||
shape=_shape(), bgcolor=T.ERROR if danger else T.ACCENT, color=T.ON_ACCENT))]))
|
||||
content = body(text) if extra is None else ft.Column([body(text), extra], spacing=T.S3, tight=True)
|
||||
page.show_dialog(dialog(heading, content, [ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
(_danger if danger else primary)(ok_label, on_click=go)],
|
||||
size="s"))
|
||||
|
||||
|
||||
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=T.px(6))
|
||||
|
||||
|
||||
def menu_items(actions: list[tuple | None]) -> list[ft.PopupMenuItem]:
|
||||
"""[(label, icon, handler) | None] → popup menu items; None becomes a divider (never first, last or doubled)."""
|
||||
out: list[ft.PopupMenuItem] = []
|
||||
for a in actions:
|
||||
if a is None:
|
||||
if out and out[-1].content is not None:
|
||||
out.append(ft.PopupMenuItem())
|
||||
def _is_header(a) -> bool:
|
||||
from .menus import Header
|
||||
|
||||
return isinstance(a, Header)
|
||||
|
||||
|
||||
def menu_entries(actions: list) -> list:
|
||||
"""The entries menu_items shows: a header is dropped when nothing follows it before the next divider/header,
|
||||
dividers are never first, last or doubled."""
|
||||
kept = []
|
||||
for i, a in enumerate(actions):
|
||||
if _is_header(a) and (i + 1 >= len(actions) or actions[i + 1] is None or _is_header(actions[i + 1])):
|
||||
continue
|
||||
label, icon, handler = a
|
||||
out.append(ft.PopupMenuItem(content=ft.Text(label), icon=icon, on_click=handler))
|
||||
while out and out[-1].content is None:
|
||||
kept.append(a)
|
||||
out: list = []
|
||||
for a in kept:
|
||||
if a is None and (not out or out[-1] is None):
|
||||
continue
|
||||
out.append(a)
|
||||
while out and out[-1] is None:
|
||||
out.pop()
|
||||
return out
|
||||
|
||||
|
||||
def menu_items(actions: list) -> list[ft.PopupMenuItem]:
|
||||
"""[(label, icon, handler) | menus.Header | None] → compact popup menu items (32 px rows). None becomes a divider
|
||||
(never first, last or doubled), a Header a small caps group heading (dropped when its group is empty); an item
|
||||
without a handler is shown disabled (e.g. "Protected by SteamOS")."""
|
||||
out: list[ft.PopupMenuItem] = []
|
||||
for a in menu_entries(actions):
|
||||
if a is None:
|
||||
out.append(ft.PopupMenuItem())
|
||||
elif _is_header(a):
|
||||
out.append(ft.PopupMenuItem(
|
||||
content=ft.Text(a.label.upper(), size=T.T_SMALL, color=T.TEXT_3, weight=ft.FontWeight.W_600,
|
||||
font_family="monospace", style=ft.TextStyle(letter_spacing=T.px(1))),
|
||||
height=T.px(24), padding=ft.Padding(T.px(14), T.px(6), T.px(16), 0), disabled=True,
|
||||
mouse_cursor=ft.MouseCursor.BASIC))
|
||||
else:
|
||||
label, icon, handler = a
|
||||
color = T.TEXT if handler else T.TEXT_3
|
||||
out.append(ft.PopupMenuItem(
|
||||
content=ft.Text(label, size=T.T_BODY, color=color),
|
||||
icon=as_icon(icon, T.ICON_S, T.TEXT_2 if handler else T.TEXT_3) if icon else None,
|
||||
height=T.px(32), padding=ft.Padding(T.px(14), 0, T.px(18), 0), on_click=handler,
|
||||
disabled=handler is None))
|
||||
return out
|
||||
|
||||
|
||||
def check_item(label: str, checked: bool, on_click: Callable) -> ft.PopupMenuItem:
|
||||
"""A compact menu row with a check mark (filter/sort chips)."""
|
||||
return ft.PopupMenuItem(content=ft.Text(label, size=T.T_BODY, color=T.TEXT), checked=checked, on_click=on_click,
|
||||
height=T.px(32), padding=ft.Padding(T.px(14), 0, T.px(18), 0))
|
||||
|
||||
|
||||
def menu_button(items: list[ft.PopupMenuItem], **kw) -> ft.PopupMenuButton:
|
||||
"""A PopupMenuButton with the shared menu look (border, shadow, SURFACE_2, small radius; also the theme's
|
||||
popup_menu_theme, which right-click ContextMenus use)."""
|
||||
kw.setdefault("tooltip", "")
|
||||
return ft.PopupMenuButton(items=items, bgcolor=T.SURFACE_2, elevation=8, shadow_color="#000000",
|
||||
shape=menu_shape(), menu_padding=ft.Padding(0, T.px(4), 0, T.px(4)), **kw)
|
||||
|
||||
|
||||
def menu_shape() -> ft.RoundedRectangleBorder:
|
||||
return ft.RoundedRectangleBorder(radius=T.RADIUS_SM, side=ft.BorderSide(1, T.BORDER_STRONG))
|
||||
|
||||
|
||||
def menu_targets(clicked, selected) -> list:
|
||||
"""What a right-click acts on: the whole selection when the clicked item is part of a selection of several (as in
|
||||
a file manager), else just that item. `selected` keeps its order."""
|
||||
@@ -488,10 +877,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)
|
||||
@@ -547,6 +937,151 @@ class Sparkline:
|
||||
self.guide.elements = guide
|
||||
|
||||
|
||||
def _darker(color: str, factor: float = 0.6) -> str:
|
||||
c = color.lstrip("#")
|
||||
return "#" + "".join(f"{round(int(c[i:i + 2], 16) * factor):02X}" for i in (0, 2, 4))
|
||||
|
||||
|
||||
def transit_parts() -> dict[str, str]:
|
||||
"""The logo's monitor, portal and headset in the active theme's colours (asset URLs; glyphs.transit_parts)."""
|
||||
from ..artwork.thumbs import asset_url
|
||||
from ..core.paths import user_data_dir
|
||||
|
||||
parts = glyphs.transit_parts(user_data_dir(), {
|
||||
"line": T.TEXT, "body": T.BG, "screen": T.SECONDARY, "lens": T.ACCENT,
|
||||
"near": T.SECONDARY if T.DUAL else T.ACCENT, "far": T.ACCENT, "hole": _darker(T.BG)})
|
||||
return {k: asset_url(v) for k, v in parts.items()}
|
||||
|
||||
|
||||
class Transit:
|
||||
"""Install progress as a transit: the PC's monitor · a track the game's cover rides along, into the portal at the
|
||||
progress head · the Frame headset (a monitor again for installs on this PC). Below: "Title → Frame" with what
|
||||
happens now (heading=False: without "Title → Frame"), and the six steps (not when compact). Created once per
|
||||
place; set() changes only properties, so it can follow every progress tick."""
|
||||
|
||||
def __init__(self, compact: bool = False, heading: bool = True):
|
||||
from .transit import STEPS
|
||||
|
||||
self.compact = compact
|
||||
heading = heading and not compact
|
||||
self.width = 0.0
|
||||
self.frac = 0.0
|
||||
self.done = False
|
||||
self._job_id = None
|
||||
self._mono = None
|
||||
icon_h = T.px(22 if compact else 30)
|
||||
self.track_h = T.px(26 if compact else 36)
|
||||
self.bar_h = T.px(4 if compact else 6)
|
||||
self.cover_s = T.px(22 if compact else 36) # fits the track height
|
||||
self.portal_w, self.portal_h = self.track_h * 24 / 50, self.track_h
|
||||
self.parts = transit_parts()
|
||||
size = lambda name, h: (h * glyphs.TRANSIT_SIZES[name][0] / glyphs.TRANSIT_SIZES[name][1], h) # noqa: E731
|
||||
mw, mh = size("monitor", icon_h)
|
||||
self._dest_sizes = {"frame": size("headset", icon_h * 0.86), "pc": (mw, mh)}
|
||||
self.source = ft.Image(src=self.parts["monitor"], width=mw, height=mh, fit=ft.BoxFit.CONTAIN)
|
||||
hw, hh = self._dest_sizes["frame"]
|
||||
self.dest = ft.Image(src=self.parts["headset"], width=hw, height=hh, fit=ft.BoxFit.CONTAIN)
|
||||
anim = ft.Animation(250, ft.AnimationCurve.EASE_OUT)
|
||||
top = (self.track_h - self.bar_h) / 2
|
||||
self.fill = ft.Container(left=0, top=top, height=self.bar_h, width=0, border_radius=self.bar_h,
|
||||
gradient=portal_gradient() if T.DUAL else None,
|
||||
bgcolor=None if T.DUAL else T.ACCENT, animate_size=anim)
|
||||
self.cover = ft.Container(left=0, top=(self.track_h - self.cover_s) / 2, width=self.cover_s,
|
||||
height=self.cover_s, border_radius=T.px(4), bgcolor=T.SURFACE_3,
|
||||
border=ft.Border.all(1, T.BORDER_STRONG), alignment=ft.Alignment.CENTER,
|
||||
clip_behavior=ft.ClipBehavior.ANTI_ALIAS, animate_position=anim)
|
||||
self.portal = ft.Image(src=self.parts["portal"], left=0, top=0, width=self.portal_w, height=self.portal_h,
|
||||
fit=ft.BoxFit.CONTAIN, animate_position=anim, animate_opacity=anim)
|
||||
base = ft.Container(left=0, right=0, top=top, height=self.bar_h, border_radius=self.bar_h,
|
||||
bgcolor=T.SURFACE_3)
|
||||
self.track = ft.Container(ft.Stack([base, self.fill, self.cover, self.portal], height=self.track_h,
|
||||
clip_behavior=ft.ClipBehavior.NONE),
|
||||
expand=True, height=self.track_h, on_size_change=self._resized)
|
||||
row = ft.Row([self.source, self.track, self.dest], spacing=T.px(6 if compact else 10),
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
self.heading = ft.Text("", size=T.T_META, weight=ft.FontWeight.W_600, color=T.TEXT, max_lines=1,
|
||||
overflow=ft.TextOverflow.ELLIPSIS, expand=True)
|
||||
self.detail = ft.Text("", size=T.T_META, color=T.TEXT_2, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS,
|
||||
expand=not heading, text_align=ft.TextAlign.RIGHT if heading else ft.TextAlign.LEFT)
|
||||
info = ft.Row([self.heading, self.detail] if heading else [self.detail], spacing=T.S2,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
labels = {"analyze": tr("Analyze"), "patch": tr("Patch"), "sign": tr("Sign"), "upload": tr("Upload"),
|
||||
"install": tr("Install"), "test": tr("Test")}
|
||||
self.steps = [ft.Container(ft.Text(labels[k], size=T.T_SMALL, color=T.TEXT_3, max_lines=1, no_wrap=True,
|
||||
text_align=ft.TextAlign.CENTER),
|
||||
expand=True, alignment=ft.Alignment.CENTER,
|
||||
padding=ft.Padding(0, 0, 0, T.px(3)),
|
||||
border=ft.Border(bottom=ft.BorderSide(2, T.SURFACE_3))) for k in STEPS]
|
||||
steps_row = ft.Row(self.steps, spacing=T.px(3), visible=not compact)
|
||||
self.control = ft.Column([row, info, steps_row], spacing=T.px(4 if compact else 6), tight=True)
|
||||
|
||||
# ------------------------------------------------------------------ layout
|
||||
def _resized(self, e) -> None:
|
||||
if e.width and abs(e.width - self.width) > 0.5:
|
||||
self.width = e.width
|
||||
self._place()
|
||||
update(self.track)
|
||||
|
||||
def _place(self) -> None:
|
||||
"""Riders from the progress head: the portal centred on it, the cover just before it (not overlapping); once
|
||||
done the cover has come out on the far side."""
|
||||
w, pw, cs, gap = self.width, self.portal_w, self.cover_s, T.px(2)
|
||||
if w <= 0:
|
||||
return
|
||||
if self.done:
|
||||
self.fill.width = w
|
||||
self.cover.left = max(0.0, w - cs)
|
||||
self.portal.left = max(0.0, w - cs - gap - pw)
|
||||
return
|
||||
head = min(max(self.frac * w, cs + gap + pw / 2), w - pw / 2)
|
||||
self.fill.width = max(0.0, min(w, self.frac * w))
|
||||
self.portal.left = head - pw / 2
|
||||
self.cover.left = max(0.0, self.portal.left - cs - gap) # just before the portal, not under it
|
||||
|
||||
# ------------------------------------------------------------------ state
|
||||
def set(self, job, title: str = "", dest: str = "", art: str | None = None, icon=None, extra: str = "") -> None:
|
||||
"""Show `job` (ui.jobs.Job). title/dest: "Title → Frame"; art: the cover's asset URL (icon when none);
|
||||
extra: appended to the detail (e.g. " · 2 more queued")."""
|
||||
from .transit import WAITING, Monotonic, job_state
|
||||
|
||||
self._mono = self._mono or Monotonic()
|
||||
s = self._mono.follow(job.id, job_state(job))
|
||||
if job.id != self._job_id: # another job: its cover and destination
|
||||
self._job_id = job.id
|
||||
self.cover.image = ft.DecorationImage(src=art, fit=ft.BoxFit.COVER) if art else None
|
||||
self.cover.content = None if art else as_icon(icon or ft.Icons.VIDEOGAME_ASSET_OUTLINED,
|
||||
self.cover_s * 0.66, T.TEXT_2)
|
||||
self.dest.src = self.parts["monitor" if s.target == "pc" else "headset"]
|
||||
self.dest.width, self.dest.height = self._dest_sizes[s.target]
|
||||
self.frac, self.done = s.fraction, s.label == "done"
|
||||
self.heading.value = f"{title} → {dest}" if dest else title
|
||||
if s.waiting:
|
||||
text, color = tr(WAITING), T.WARN
|
||||
elif s.failed:
|
||||
text, color = (job.error or tr("Failed")), T.ERROR
|
||||
elif job.state == "queued":
|
||||
text, color = tr("Queued"), T.TEXT_3
|
||||
else:
|
||||
text, color = (s.detail or tr("Starting…")), T.TEXT_2
|
||||
self.detail.value, self.detail.color = text + extra, color
|
||||
self.portal.opacity = 0.35 if s.waiting else 1.0
|
||||
if s.failed:
|
||||
self.fill.gradient, self.fill.bgcolor = None, T.ERROR
|
||||
else:
|
||||
self.fill.gradient = portal_gradient() if T.DUAL else None
|
||||
self.fill.bgcolor = None if T.DUAL else T.ACCENT
|
||||
for i, box in enumerate(self.steps):
|
||||
text = box.content
|
||||
current = i == s.step and not self.done
|
||||
finished = i < s.step or self.done
|
||||
text.color = T.ACCENT if current else T.TEXT_2 if finished else T.TEXT_3
|
||||
text.weight = ft.FontWeight.W_600 if current else ft.FontWeight.W_400
|
||||
box.border = ft.Border(bottom=ft.BorderSide(
|
||||
2, (T.ERROR if s.failed else T.WARN if s.waiting else T.ACCENT) if current
|
||||
else (T.SECONDARY if T.DUAL else T.ACCENT) if finished else T.SURFACE_3))
|
||||
self._place()
|
||||
|
||||
|
||||
class MeterBar:
|
||||
"""A thin horizontal bar of coloured segments (per-core load, power split); widths are fractions of the bar."""
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""The sidebar's live Frame card (no Flet): what one monitor sample shows there — the running game (title, how long it
|
||||
has been playing, its frame rate and that rate's level) and the battery ring. The app subscribes the card to the
|
||||
shared MonitorHub (frame/monitor_hub.py) with only the "games" and "battery" modules, every CARD_INTERVAL seconds,
|
||||
while the setting `ui.live_frame_card` is on."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..frame import monitor as M
|
||||
from ..i18n import tr
|
||||
from . import battery as B
|
||||
|
||||
SETTING = "ui.live_frame_card"
|
||||
CARD_INTERVAL = 5.0 # seconds between samples (the agent collects only games + battery for it)
|
||||
CARD_MODULES = ("games", "battery")
|
||||
FPS_WINDOW = 120.0 # the sparkline covers the last 2 minutes
|
||||
FPS_BUCKET = 5.0 # one point per CARD_INTERVAL (faster samples, e.g. with the Monitor open, are averaged)
|
||||
FPS_POINTS = int(FPS_WINDOW // FPS_BUCKET)
|
||||
|
||||
|
||||
def fps_history() -> M.History:
|
||||
return M.History(size=FPS_POINTS, bucket=FPS_BUCKET)
|
||||
|
||||
|
||||
def now_playing(sample: dict | None, fps_series: list | None = None) -> dict | None:
|
||||
"""Display values for the card's "now playing" row, or None when no game runs: package, title, line ("playing ·
|
||||
27 min"), fps text ("72" / "–"), level (ok | warn | error by the fps against its presumed refresh rate) and the
|
||||
fps target (refresh rate) for the sparkline's guide."""
|
||||
games = (sample or {}).get("games") or []
|
||||
if not games:
|
||||
return None
|
||||
g = games[0]
|
||||
pkg = g.get("package") or ""
|
||||
elapsed = g.get("elapsed")
|
||||
line = tr("playing · {t}").format(t=M.fmt_duration(elapsed)) if elapsed is not None else tr("playing")
|
||||
fps = g.get("fps")
|
||||
series = list(fps_series or [])
|
||||
target = M.fps_target(series + ([fps] if fps is not None else []))
|
||||
lvl = M.level("fps_ratio", fps / target if fps is not None and target else None)
|
||||
return {"package": pkg, "title": g.get("title") or pkg, "line": line, "minutes": int(elapsed // 60)
|
||||
if elapsed is not None else None, "fps": fps, "fps_text": f"{fps:.0f}" if fps is not None else "–",
|
||||
"level": lvl if fps is not None else "none", "target": target}
|
||||
|
||||
|
||||
def battery_ring(bat: dict | None) -> dict | None:
|
||||
"""The battery ring: value (0..1), the "76" text and its level (warn when low on battery power, charging when it
|
||||
gains charge, ok otherwise). None without a reading."""
|
||||
if not bat or bat.get("percent") is None:
|
||||
return None
|
||||
pct = max(0, min(100, int(bat.get("percent") or 0)))
|
||||
level = "warn" if B.low(bat) else "charging" if B.charging(bat) else "ok"
|
||||
return {"value": pct / 100.0, "text": f"{pct}", "level": level}
|
||||
|
||||
|
||||
def uploading(job) -> bool:
|
||||
"""A job is sending files to the Frame (installer/files stages "Upload …"): the card pauses its samples meanwhile
|
||||
(the link is busy)."""
|
||||
return bool(job) and (getattr(job, "stage", "") or "").startswith("Upload")
|
||||
@@ -0,0 +1,120 @@
|
||||
"""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
|
||||
|
||||
|
||||
TRANSIT_REV = 1
|
||||
|
||||
# The logo's parts, whole (the logo shows the monitor's left half and the headset's right half): same 64-unit
|
||||
# coordinates, strokes and radii as icons/logo.svg, each cropped to its own viewBox.
|
||||
_MONITOR = ('<svg xmlns="http://www.w3.org/2000/svg" width="39" height="38" viewBox="3 11.5 39 38">'
|
||||
'<path d="M9 15h27a3 3 0 0 1 3 3v20a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3V18a3 3 0 0 1 3-3z" fill="{body}" '
|
||||
'stroke="{line}" stroke-width="3" stroke-linejoin="round"/>'
|
||||
'<rect x="10.5" y="19.5" width="24" height="17" rx="1" fill="{screen}" fill-opacity="0.35"/>'
|
||||
'<path d="M22.5 41v6M15.5 47h14" fill="none" stroke="{line}" stroke-width="3" stroke-linecap="round"/>'
|
||||
'</svg>\n')
|
||||
# the visor mirrored about its nose notch (x 44.25), lenses either side, a strap stub on both ends
|
||||
_HEADSET = ('<svg xmlns="http://www.w3.org/2000/svg" width="34" height="28" viewBox="27.25 17.5 34 28">'
|
||||
'<path d="M30 31.5H32M56.5 31.5H58.5" stroke="{line}" stroke-width="3.4" stroke-linecap="round"/>'
|
||||
'<path d="M39.5 21H49a8 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.7'
|
||||
'a3 3 0 0 1-2.6 1.5H39.5a8 8 0 0 1-8-8v-5a8 8 0 0 1 8-8z" fill="{body}" stroke="{line}" stroke-width="3" '
|
||||
'stroke-linejoin="round"/>'
|
||||
'<ellipse cx="39.5" cy="30.5" rx="4.2" ry="3.8" fill="{lens}"/>'
|
||||
'<ellipse cx="49" cy="30.5" rx="4.2" ry="3.8" fill="{lens}"/></svg>\n')
|
||||
# the portal: dark hole with a keyline, a soft glow and the blue→orange rim, tilted 18°
|
||||
_PORTAL = ('<svg xmlns="http://www.w3.org/2000/svg" width="24" height="50" viewBox="20 7 24 50">'
|
||||
'<defs><linearGradient id="r" gradientUnits="userSpaceOnUse" x1="26" y1="0" x2="38" y2="0">'
|
||||
'<stop offset=".3" stop-color="{near}"/><stop offset=".7" stop-color="{far}"/></linearGradient></defs>'
|
||||
'<g transform="rotate(18 32 32)">'
|
||||
'<ellipse cx="32" cy="32" rx="4.5" ry="22" fill="{hole}" stroke="{hole}" stroke-width="5.8"/>'
|
||||
'<ellipse cx="32" cy="32" rx="4.5" ry="22" fill="none" stroke="url(#r)" stroke-opacity=".25" '
|
||||
'stroke-width="6.46"/>'
|
||||
'<ellipse cx="32" cy="32" rx="4.5" ry="22" fill="url(#r)" fill-opacity="0.14" stroke="url(#r)" '
|
||||
'stroke-width="3.4"/></g></svg>\n')
|
||||
TRANSIT_SIZES = {"monitor": (39, 38), "headset": (34, 28), "portal": (24, 50)} # viewBox sizes (aspect ratios)
|
||||
|
||||
|
||||
def transit_parts(assets: Path, colors: dict) -> dict[str, Path]:
|
||||
"""The install transit's monitor, portal and headset: the logo's own shapes, whole, in a theme's colours.
|
||||
colors: line (strokes), body (inside the outlines, the logo's tile), screen (the monitor's screen), lens (the
|
||||
visor's lenses), near/far (the portal rim's PC and Frame sides), hole (the portal's inside). Written once per
|
||||
colour set; returns {"monitor", "portal", "headset"} → file."""
|
||||
c = {k: str(v).upper() for k, v in colors.items()}
|
||||
out = {}
|
||||
for name, svg, keys in (("monitor", _MONITOR, ("line", "body", "screen")),
|
||||
("headset", _HEADSET, ("line", "body", "lens")),
|
||||
("portal", _PORTAL, ("near", "far", "hole"))):
|
||||
tag = "-".join(c[k].lstrip("#") for k in keys)
|
||||
dest = assets / FOLDER / f"transit{TRANSIT_REV}-{name}-{tag}.svg" # REV: the drawing changed
|
||||
if not dest.is_file():
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(svg.format(**{k: c[k] for k in keys}), encoding="utf-8")
|
||||
out[name] = dest
|
||||
return out
|
||||
|
||||
|
||||
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"
|
||||
@@ -25,11 +25,11 @@ HELP: dict[str, str] = _Translated({
|
||||
"update_ready": "The copy on your Frame differs from what FramePort would install now (the recipe or your game "
|
||||
"files changed). Update to install the new build; saves are kept.",
|
||||
"check_exe": "This game's folder has several programs and FramePort isn't sure which one starts the game. Open the "
|
||||
"game (or right-click → Change executable) to pick it.",
|
||||
"game (or right-click → Change program…) to pick it.",
|
||||
"platform_quest": "A Meta Quest game (APK). It's rebuilt for the Frame and runs in Lepton, Valve's Android "
|
||||
"container.",
|
||||
"platform_windows": "A Windows game without VR, added with \"Add one game folder…\". On the Frame, Proton runs it "
|
||||
"as a window (like Steam's own Windows games); it's in the Frame's Steam library.",
|
||||
"platform_windows": "A Windows game without VR, added with \"Add a PC game folder…\". On the Frame, Proton "
|
||||
"runs it as a window (like Steam's own Windows games); it's in the Frame's Steam library.",
|
||||
"platform_pcvr": "A Windows PC VR game (OpenXR, SteamVR or Oculus). It runs on this PC with SteamVR, or on the "
|
||||
"Frame with Proton. Oculus-only games also need Revive (Oculus → OpenXR).",
|
||||
"platform_android": "An ordinary Android app or game (no VR). It's installed unchanged and runs in Lepton, Valve's "
|
||||
@@ -39,8 +39,9 @@ HELP: dict[str, str] = _Translated({
|
||||
"platform_linux": "A native Linux program built for arm64 (aarch64), like the Frame's own CPU. It's installed "
|
||||
"unchanged and runs directly on SteamOS (no Android container, no Proton), started from the "
|
||||
"Frame's Steam library. VR apps use the Frame's OpenXR runtime.",
|
||||
"linux_app": "An AppImage, a folder or a .zip/.tar archive with a Linux program built for arm64 (aarch64). x86_64 "
|
||||
"builds can't run on the Frame. The app must bring the libraries SteamOS doesn't have.",
|
||||
"linux_app": "An AppImage, a folder or a .zip/.tar archive with a Linux program, best built for arm64 (aarch64). "
|
||||
"x86_64 builds run through FEX, Valve's x86 translator, but slower. The app must bring the "
|
||||
"libraries SteamOS doesn't have.",
|
||||
"appimage": "An AppImage is a Linux app packed into one file. FramePort unpacks it on the Frame once, so it starts "
|
||||
"without FUSE.",
|
||||
"select": "Pick several games and install them in one go. FramePort asks every question first, then works through "
|
||||
@@ -69,7 +70,7 @@ HELP: dict[str, str] = _Translated({
|
||||
"FrameDrop sideloader). FramePort reads the same links: it shows what the link offers, asks, "
|
||||
"downloads it, adds it to your library and installs it on the Frame. Only https links to "
|
||||
"public servers are used. Windows and Linux open these links in FramePort; on macOS, paste the "
|
||||
"link into Add games → Install from a link….",
|
||||
"link into Add games → Add from a link….",
|
||||
"linux_x86": "The Frame's CPU is arm64. Programs built for x86_64 PCs run through FEX, Valve's x86 translator, "
|
||||
"with the x86 libraries SteamOS ships for it (glibc, Mesa) - the way Steam on the Frame runs x86 "
|
||||
"Linux games. That works for many programs but is slower: use an arm64 build when there is one.",
|
||||
@@ -96,23 +97,27 @@ HELP: dict[str, str] = _Translated({
|
||||
"starts.",
|
||||
"cat_device": "Files and environment variables placed next to the game on the Frame.",
|
||||
# ---- Frame
|
||||
"developer_mode": "Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on "
|
||||
"your network and connect to it over SSH. The first-time setup command turns it on for you.",
|
||||
"developer_mode": "Developer Mode (on the Frame: Settings → System → Enable Developer Mode) lets FramePort find "
|
||||
"the Frame on your network and connect to it over SSH. The first-time setup command turns it on "
|
||||
"for you. With Developer Mode on you can also pair without the setup command: on the Frame open "
|
||||
"Settings → Developer → Pair new host, click Connect there and approve FramePort. That way needs "
|
||||
"no connection from the Frame into this PC.",
|
||||
"first_time_setup": "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.",
|
||||
"Turning on Developer Mode closes the Frame's desktop; the setup finishes on its own and "
|
||||
"FramePort connects by itself. No password needed.",
|
||||
"password": "The Frame's desktop password, only needed the first time so FramePort can add its own key. After "
|
||||
"that it connects with the key.",
|
||||
"lepton": "Lepton is Valve's Android container on the Frame. Quest games run inside it, one container per game. "
|
||||
"It needs Developer Mode.",
|
||||
"proton": "Proton is Valve's Windows compatibility layer. On the Frame it runs PC VR games. "
|
||||
"Its ARM64 build isn't installed by default; FramePort can install it (Steam restarts once).",
|
||||
"Its arm64 build isn't installed by default; FramePort can install it (Steam restarts once).",
|
||||
"openxr": "The VR runtime games talk to. On the Frame that's SteamVR.",
|
||||
"kernel_keys": "Every game start on the Frame used to leak one kernel key, and after about 200 launches every game "
|
||||
"fails to start. FramePort switches that leak off, but keys already used only come back after a "
|
||||
"restart of the Frame.",
|
||||
"ui_scale": "Size of text and layout. Automatic follows Windows' display scaling halfway "
|
||||
"(150 % in Windows → 125 %) when FramePort runs under WSL, where the window doesn't get it from "
|
||||
"(150% in Windows → 125%) when FramePort runs under WSL, where the window doesn't get it from "
|
||||
"Windows itself.",
|
||||
"app_updates": "FramePort checks GitHub for a new release every few hours and shows it in the sidebar and the "
|
||||
"Library. \"Update now\" downloads it, checks its checksum (and on Windows its signature), closes "
|
||||
@@ -125,8 +130,15 @@ HELP: dict[str, str] = _Translated({
|
||||
"files": "Browse and manage files on the Frame (Files tab): upload from this PC, download, rename, delete. "
|
||||
"Videos, Downloads and Documents are shared by every Quest game (/sdcard/Movies, /sdcard/Download, "
|
||||
"/sdcard/Documents); each installed game also has its own storage (its /sdcard). Apps find files "
|
||||
"by browsing folders. Video players that list only their own folder (e.g. 4XVR's Internal "
|
||||
"by browsing folders. Video players that list only their own folder (for example 4XVR's Internal "
|
||||
"Storage = 4XPlayer): upload into that folder in the game's storage.",
|
||||
"live_view": "The picture opens in your default web browser, where it plays smoothly and can go full screen. "
|
||||
"It shows the headset's view with its sound (click Sound on in the player) whatever is running: "
|
||||
"Steam's menus, SteamVR or a game; while the headset sleeps the picture is black and updates about "
|
||||
"once a second. The Frame's hardware video encoder does the work (if it isn't available, the "
|
||||
"processor does, which costs a game more): stop the stream when you're done.",
|
||||
"type_on_frame": "This PC's keyboard works as a keyboard on your Frame while this tab is open. In the headset, "
|
||||
"select a text field (in a game or app, in Steam or on the desktop), then type here.",
|
||||
"free_space": "Deletes the rollback copies kept from each game's previous install and leftover uploads. The "
|
||||
"games and saves stay.",
|
||||
"adapter_settings": "Sharpness, refresh rate, controllers, menus and more for this game. Changes are used the "
|
||||
@@ -149,9 +161,9 @@ HELP: dict[str, str] = _Translated({
|
||||
"no GitHub token and no game files are involved.",
|
||||
"diag_bundle": "A zip with FramePort's logs, the game's recipe and analysis, launch-test logs and the Frame's "
|
||||
"runtime details — enough to debug without the game files. IP addresses, host and user names, "
|
||||
"home folders and Steam ids are replaced by placeholders. Attach it to a GitHub issue.",
|
||||
"home folders and Steam IDs are replaced by placeholders. Attach it to a GitHub issue.",
|
||||
"monitor_gpu": "How much of the time the Frame's graphics chip was busy, added up from every program that draws "
|
||||
"(the game, SteamVR's compositor, Steam). Near 100 % the game can't keep its frame rate.",
|
||||
"(the game, SteamVR's compositor, Steam). Near 100% the game can't keep its frame rate.",
|
||||
"monitor_pressure": "Memory in use by everything on the Frame. \"Waiting\" is the share of time programs had to "
|
||||
"wait for memory in the last 10 s: when it climbs, the Frame is running out and may freeze or "
|
||||
"close a game.",
|
||||
|
||||
@@ -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 |
@@ -0,0 +1,125 @@
|
||||
"""A game's menu (Library right-click, game page "…") as sections: a pure builder, no controls and no app.
|
||||
|
||||
Entries: (label, icon, handler) | Header(label) | None (divider). C.menu_items renders them (headers as small caps,
|
||||
compact rows). Flet 1.0's PopupMenuItem can't nest, so groups get a header instead of a submenu."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import NamedTuple
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ..i18n import tr
|
||||
from . import glyphs as G
|
||||
|
||||
|
||||
class Header(NamedTuple):
|
||||
"""A group heading inside a menu (not clickable)."""
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuState:
|
||||
"""What the menu depends on, gathered by the app (FramePortApp.game_actions)."""
|
||||
quick: bool = True # True: Library right-click; False: the game page's "…" (hero/cards show the rest)
|
||||
connected: bool = False # a Frame is connected
|
||||
on_frame: bool = False # installed on the Frame (current or outdated)
|
||||
on_pc: bool = False # a PC VR game installed on this PC
|
||||
job: bool = False # a job runs (or waits) for this game
|
||||
plays: list = field(default_factory=list) # where it can be started now [(label, icon, handler)]
|
||||
installs: list = field(default_factory=list) # install/update/reinstall options [(label, icon, handler)]
|
||||
settings: bool = False # has Game settings
|
||||
programs: bool = False # "Change program…" makes sense
|
||||
selectable: bool = False # the Library view exists (Select)
|
||||
media_button: bool = False # the game page shows "Add videos" already
|
||||
|
||||
|
||||
def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> list:
|
||||
"""The game's menu. `act(key)` gives the handler for an action key (tests pass `lambda k: k`)."""
|
||||
rift = g.get("kind") == "rift"
|
||||
linux = g.get("kind") == "linux" # installed as it is: nothing to analyze, convert or share as a recipe
|
||||
quick, job = st.quick, st.job
|
||||
top: list = []
|
||||
if job and quick: # the game page shows progress + Cancel in its hero
|
||||
top += [(tr("Show progress"), ft.Icons.SYNC_ROUNDED, act("progress")),
|
||||
(tr("Cancel"), ft.Icons.CLOSE_ROUNDED, act("cancel"))]
|
||||
if quick and not job:
|
||||
top += st.plays
|
||||
if quick:
|
||||
top.append((tr("Open game page"), ft.Icons.OPEN_IN_NEW_ROUNDED, act("open")))
|
||||
if st.selectable:
|
||||
top.append((tr("Select"), ft.Icons.CHECKLIST_ROUNDED, act("select")))
|
||||
if not job:
|
||||
top += st.installs
|
||||
if st.settings and not job:
|
||||
top.append((tr("Game settings…"), ft.Icons.TUNE_ROUNDED, act("settings")))
|
||||
if st.connected:
|
||||
top.append((tr("Screenshots"), G.SHOT, act("screenshots")))
|
||||
if st.on_frame and not rift and not linux and not job and (quick or not st.media_button):
|
||||
top.append((tr("Add videos and files"), ft.Icons.VIDEO_LIBRARY_OUTLINED, act("files")))
|
||||
|
||||
artwork = [(tr("Find artwork…"), ft.Icons.IMAGE_SEARCH_ROUNDED, act("find_art")),
|
||||
(tr("Use your own artwork…"), ft.Icons.UPLOAD_FILE_OUTLINED, act("custom_art"))]
|
||||
if not job and st.on_frame:
|
||||
artwork.append((tr("Update Steam art on Frame"), ft.Icons.WALLPAPER_ROUNDED, act("steam_art")))
|
||||
|
||||
recipe = [] if linux else [
|
||||
(tr("Reset to suggested recipe…"), ft.Icons.SETTINGS_BACKUP_RESTORE_ROUNDED, act("reset_recipe")),
|
||||
(tr("Save as known-good recipe"), G.RECIPE, act("save_recipe")),
|
||||
(tr("Share working recipe…"), ft.Icons.SHARE_ROUNDED, act("share"))]
|
||||
|
||||
trouble: list = []
|
||||
if quick and not job and st.on_frame: # the game page has these on its "Where it's installed" cards
|
||||
trouble.append((tr("Run launch test on Frame"), G.TEST, act("test_frame")))
|
||||
if quick and not job and st.on_pc:
|
||||
trouble.append((tr("Run launch test on this PC"), G.TEST, act("test_pc")))
|
||||
if not job and not rift and not linux:
|
||||
trouble.append((tr("Analyze again"), ft.Icons.MANAGE_SEARCH_ROUNDED, act("analyze")))
|
||||
if not job and not linux:
|
||||
trouble.append((tr("Check game files") if rift else tr("Rebuild only (no install)"), G.PORT, act("build")))
|
||||
if st.programs:
|
||||
trouble.append((tr("Change program…"), ft.Icons.SWAP_HORIZ_ROUNDED, act("program")))
|
||||
trouble += [(tr("Refresh store details"), ft.Icons.PUBLISHED_WITH_CHANGES_ROUNDED, act("details")),
|
||||
(tr("Collect logs"), ft.Icons.FOLDER_ZIP_OUTLINED, act("logs")),
|
||||
(tr("Report a problem…"), ft.Icons.BUG_REPORT_OUTLINED, act("report"))]
|
||||
|
||||
danger: list = []
|
||||
if quick and not job and st.on_frame:
|
||||
danger.append((tr("Uninstall from Frame…"), ft.Icons.DELETE_OUTLINE_ROUNDED, act("uninstall_frame")))
|
||||
if quick and not job and st.on_pc:
|
||||
danger.append((tr("Uninstall from this PC…"), ft.Icons.DELETE_OUTLINE_ROUNDED, act("uninstall_pc")))
|
||||
danger.append((tr("Remove from library…"), ft.Icons.REMOVE_CIRCLE_OUTLINE_ROUNDED, act("remove")))
|
||||
|
||||
out: list = list(top)
|
||||
for head, items in ((tr("Artwork"), artwork), (tr("Recipe"), recipe), (tr("Troubleshoot"), trouble)):
|
||||
if items:
|
||||
out += [None, Header(head), *items]
|
||||
return out + [None, *danger]
|
||||
|
||||
|
||||
def process_menu(p: dict, act: Callable[[str], Callable]) -> list:
|
||||
"""Monitor's right-click menu for a process row or a group row (`p` = the row). Harmless items first, then
|
||||
End…/Force quit…, End game… last; a process SteamOS protects shows that instead of the End items."""
|
||||
out: list = []
|
||||
if p.get("kind") == "group":
|
||||
n = p.get("count", 0)
|
||||
out += [(tr("Hide its processes") if p.get("expanded") else tr("Show its processes"),
|
||||
ft.Icons.UNFOLD_MORE_ROUNDED, act("toggle")),
|
||||
(tr("Copy name"), ft.Icons.CONTENT_COPY_ROUNDED, act("copy_name")), None]
|
||||
if p.get("locked"):
|
||||
out.append((tr("Protected by SteamOS"), ft.Icons.LOCK_OUTLINE_ROUNDED, None))
|
||||
else:
|
||||
out += [(tr("End all {n}…").format(n=n), ft.Icons.LOGOUT_ROUNDED, act("term")),
|
||||
(tr("Force quit all {n}…").format(n=n), ft.Icons.DANGEROUS_OUTLINED, act("kill"))]
|
||||
else:
|
||||
out += [(tr("Copy name"), ft.Icons.CONTENT_COPY_ROUNDED, act("copy_name")),
|
||||
(tr("Copy PID {pid}").format(pid=p.get("pid")), ft.Icons.CONTENT_COPY_ROUNDED, act("copy_pid")), None]
|
||||
if p.get("locked"):
|
||||
out.append((tr("Protected by SteamOS"), ft.Icons.LOCK_OUTLINE_ROUNDED, None))
|
||||
else:
|
||||
out += [(tr("End…"), ft.Icons.LOGOUT_ROUNDED, act("term")),
|
||||
(tr("Force quit…"), ft.Icons.DANGEROUS_OUTLINED, act("kill"))]
|
||||
if p.get("game"):
|
||||
out += [None, (tr("End game…"), ft.Icons.STOP_CIRCLE_OUTLINED, act("end_game"))]
|
||||
return out
|
||||
@@ -1,40 +1,223 @@
|
||||
"""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 color 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 color \"{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,
|
||||
"T_TITLE": 28, "T_H2": 16, "T_BODY": 13, "T_META": 12, "T_SMALL": 11}
|
||||
# RADIUS cards/dialogs, RADIUS_SM buttons/inputs/thumbnails, RADIUS_XS small chips/tooltips; ICON_S/M/L icon sizes;
|
||||
# DIALOG_S/M/L dialog content widths; T_DISPLAY big headings (empty states, setup)
|
||||
_BASE = {"S1": 4, "S2": 8, "S3": 12, "S4": 16, "S5": 24, "S6": 32, "RADIUS": 12, "RADIUS_SM": 8, "RADIUS_XS": 6,
|
||||
"ICON_S": 16, "ICON_M": 20, "ICON_L": 24, "DIALOG_S": 440, "DIALOG_M": 600, "DIALOG_L": 880,
|
||||
"T_DISPLAY": 22, "T_TITLE": 28, "T_H2": 16, "T_BODY": 13, "T_META": 12, "T_SMALL": 11}
|
||||
S1, S2, S3, S4, S5, S6 = 4, 8, 12, 16, 24, 32
|
||||
RADIUS = 12
|
||||
RADIUS_SM = 8
|
||||
T_TITLE, T_H2, T_BODY, T_META, T_SMALL = 28, 16, 13, 12, 11
|
||||
RADIUS_XS = 6
|
||||
ICON_S, ICON_M, ICON_L = 16, 20, 24
|
||||
DIALOG_S, DIALOG_M, DIALOG_L = 440, 600, 880
|
||||
T_DISPLAY, T_TITLE, T_H2, T_BODY, T_META, T_SMALL = 22, 28, 16, 13, 12, 11
|
||||
SCALE = 1.0
|
||||
|
||||
|
||||
@@ -91,7 +274,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,
|
||||
@@ -108,13 +291,19 @@ def apply(page: ft.Page) -> None:
|
||||
text_theme=text_theme,
|
||||
divider_theme=ft.DividerTheme(color=BORDER, thickness=1, space=1),
|
||||
tooltip_theme=ft.TooltipTheme(
|
||||
decoration=ft.BoxDecoration(bgcolor=SURFACE_3, border_radius=6,
|
||||
decoration=ft.BoxDecoration(bgcolor=SURFACE_3, border_radius=RADIUS_XS,
|
||||
border=ft.Border.all(1, BORDER)),
|
||||
text_style=ft.TextStyle(color=TEXT, size=px(12)), wait_duration=400),
|
||||
expansion_tile_theme=ft.ExpansionTileTheme(
|
||||
shape=ft.RoundedRectangleBorder(radius=RADIUS), collapsed_shape=ft.RoundedRectangleBorder(
|
||||
radius=RADIUS), icon_color=TEXT_2, collapsed_icon_color=TEXT_3, text_color=TEXT,
|
||||
collapsed_text_color=TEXT),
|
||||
# menus lift off the dark page: border + shadow (right-click ContextMenus only get this theme)
|
||||
popup_menu_theme=ft.PopupMenuTheme(
|
||||
color=SURFACE_2, elevation=8, shadow_color="#000000",
|
||||
shape=ft.RoundedRectangleBorder(radius=RADIUS_SM, side=ft.BorderSide(1, BORDER_STRONG)),
|
||||
menu_padding=ft.Padding(0, px(4), 0, px(4)),
|
||||
label_text_style=ft.TextStyle(size=T_BODY, color=TEXT)),
|
||||
scaffold_bgcolor=BG, card_bgcolor=SURFACE, canvas_color=SURFACE_2)
|
||||
page.theme = page.dark_theme = theme
|
||||
page.theme_mode = ft.ThemeMode.DARK
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Install progress as a transit (no Flet): a job's stage names → one of six steps and an overall fraction, so the GUI
|
||||
can show the game crossing the portal from the PC to the Frame (components.Transit).
|
||||
|
||||
The stage names come from the install job (app._submit_install), build.py, pipeline.py and the installers; they are
|
||||
intentionally not translated so they can be mapped here."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
# step keys (stable) and their labels (translated when shown)
|
||||
STEPS = ("analyze", "patch", "sign", "upload", "install", "test")
|
||||
LABELS = {"analyze": "Analyze", "patch": "Patch", "sign": "Sign", "upload": "Upload", "install": "Install",
|
||||
"test": "Test"}
|
||||
|
||||
# a run starts over with these (a job that lost the Frame runs again from the beginning)
|
||||
_START = ("patching the game", "checking the game")
|
||||
# lower-case stage prefix → step index; the first match wins
|
||||
_PREFIXES = (
|
||||
("patching", 0), ("checking", 0), ("check game files", 0),
|
||||
("ovrport", 1), ("frame fixes", 1), ("revive", 1),
|
||||
("sign", 2), ("validate", 2),
|
||||
("prepare", 3), ("upload", 3),
|
||||
("artwork", 4), ("finalize", 4), ("add to steam", 4),
|
||||
("launch test", 5), ("starting steamvr", 5),
|
||||
)
|
||||
_DONE = ("installed",)
|
||||
WAITING = "Waiting for the Frame"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransitState:
|
||||
step: int # index into STEPS (len(STEPS) = everything done)
|
||||
fraction: float # overall progress 0..1
|
||||
label: str # the step's key ("upload" …), "done" when finished
|
||||
detail: str # what happens right now: the stage, or the upload's % and speed
|
||||
waiting: bool # the queue waits for the Frame
|
||||
failed: bool
|
||||
target: str # "frame" | "pc"
|
||||
|
||||
|
||||
def step_of(stage: str) -> int | None:
|
||||
"""The step a stage name belongs to (None: unknown, keeps the previous step)."""
|
||||
low = (stage or "").strip().lower()
|
||||
if low.startswith(_DONE):
|
||||
return len(STEPS)
|
||||
for prefix, index in _PREFIXES:
|
||||
if low.startswith(prefix):
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def _is_waiting(stage: str) -> bool:
|
||||
from ..i18n import tr
|
||||
|
||||
return bool(stage) and stage in (WAITING, tr(WAITING))
|
||||
|
||||
|
||||
def transit_state(state: str = "running", stage: str = "", stages: list[str] | None = None,
|
||||
fraction: float | None = None, speed: str = "", message: str = "", kind: str = "install",
|
||||
to: str = "frame") -> TransitState:
|
||||
"""Where a job is on its way: the furthest step of the current run (a second build variant's OVRPort after the
|
||||
first one's validate doesn't move it back), the upload's own fraction inside the Upload step, and 1.0 when the
|
||||
job is done (also when the launch test is off and it ends at "Installed")."""
|
||||
stages = list(stages or [])
|
||||
waiting = _is_waiting(stage)
|
||||
step = 0
|
||||
for s in stages:
|
||||
if s.strip().lower().startswith(_START):
|
||||
step = 0 # a new run
|
||||
index = step_of(s)
|
||||
if index is not None:
|
||||
step = max(step, index)
|
||||
if kind == "test" and not any(step_of(s) is not None for s in stages):
|
||||
step = 5 # a launch test on its own: everything before it is done
|
||||
current = None if waiting else step_of(stage)
|
||||
within = 0.0
|
||||
if fraction is not None and current == step and step < len(STEPS):
|
||||
within = min(1.0, max(0.0, float(fraction)))
|
||||
done = state == "done" or step >= len(STEPS)
|
||||
if done:
|
||||
step, within = len(STEPS), 0.0
|
||||
overall = 1.0 if done else (step + within) / len(STEPS)
|
||||
if waiting:
|
||||
detail = WAITING
|
||||
elif current == 3 and fraction is not None:
|
||||
detail = " · ".join(x for x in (f"{fraction:.0%}", speed) if x)
|
||||
else:
|
||||
detail = stage or message
|
||||
return TransitState(step=step, fraction=overall, label="done" if done else STEPS[step], detail=detail,
|
||||
waiting=waiting, failed=state == "failed", target="pc" if to == "pc" else "frame")
|
||||
|
||||
|
||||
def job_state(job) -> TransitState:
|
||||
"""transit_state() for a ui.jobs.Job."""
|
||||
return transit_state(job.state, job.stage, job.stages, job.fraction, job.speed, job.message, job.kind,
|
||||
getattr(job, "to", "frame"))
|
||||
|
||||
|
||||
class Monotonic:
|
||||
"""Keeps one place's bar from moving back within a run: the installer reports the upload's fraction over all its
|
||||
files (APK, then data), but a new stage clears the job's fraction until the first tick ("Upload data" started at
|
||||
the Upload step's 0 % after the APK had filled it). The fraction never decreases for the same job unless the step
|
||||
goes back (the job started over, e.g. after the Frame was lost)."""
|
||||
|
||||
def __init__(self):
|
||||
self.key = None
|
||||
self.step = -1
|
||||
self.best = 0.0
|
||||
|
||||
def follow(self, key, state: TransitState) -> TransitState:
|
||||
if key != self.key or state.step < self.step:
|
||||
self.key, self.best = key, 0.0
|
||||
self.step = state.step
|
||||
self.best = max(self.best, state.fraction)
|
||||
return state if state.fraction == self.best else replace(state, fraction=self.best)
|
||||
@@ -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
|
||||
@@ -93,13 +99,13 @@ class Updater:
|
||||
return
|
||||
page = self.app.page
|
||||
kind = updates.install_kind()
|
||||
how = {"bundle": "FramePort downloads it, checks it, closes and opens again as the new version. Your games, "
|
||||
"settings and Frame connection stay as they are.",
|
||||
"source": "Updates this source checkout (git pull + uv sync), then restarts FramePort.",
|
||||
"wheel": "Reinstalls FramePort from the release, then restarts it."}[kind]
|
||||
how = {"bundle": tr("FramePort downloads it, checks it, closes and opens again as the new version. Your "
|
||||
"games, settings and Frame connection stay as they are."),
|
||||
"source": tr("Updates this source checkout (git pull + uv sync), then restarts FramePort."),
|
||||
"wheel": tr("Reinstalls FramePort from the release, then restarts it.")}[kind]
|
||||
notes = up.notes.strip() or tr("No release notes.")
|
||||
|
||||
def later(e):
|
||||
def skip(e):
|
||||
updates.skip(up.version)
|
||||
self._set(None)
|
||||
page.pop_dialog()
|
||||
@@ -107,11 +113,9 @@ class Updater:
|
||||
def go(e):
|
||||
page.pop_dialog()
|
||||
self.install()
|
||||
page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(tr("FramePort {version} is available").format(version=up.version), color=T.TEXT,
|
||||
weight=ft.FontWeight.W_600),
|
||||
bgcolor=T.SURFACE_2,
|
||||
content=ft.Container(ft.Column([
|
||||
page.show_dialog(C.dialog(
|
||||
tr("FramePort {version} is available").format(version=up.version),
|
||||
ft.Column([
|
||||
C.body(tr("You have {version}. {how}").format(version=__version__, how=how), T.TEXT_2),
|
||||
ft.Container(ft.Markdown(notes, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
|
||||
md_style_sheet=_notes_style(),
|
||||
@@ -119,8 +123,8 @@ class Updater:
|
||||
padding=T.S3, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE,
|
||||
border=ft.Border.all(1, T.BORDER)),
|
||||
], spacing=T.S3, scroll=ft.ScrollMode.AUTO, tight=True, horizontal_alignment=ft.CrossAxisAlignment.STRETCH),
|
||||
width=T.px(560), height=T.px(min(420, 130 + 26 * len(notes.splitlines())))),
|
||||
actions=[C.ghost(tr("Skip this version"), on_click=later),
|
||||
height=T.px(min(420, 130 + 26 * len(notes.splitlines()))),
|
||||
actions=[C.ghost(tr("Skip this version"), on_click=skip),
|
||||
C.ghost(tr("Release page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
|
||||
C.primary(tr("Update now"), ft.Icons.SYSTEM_UPDATE_ROUNDED, go)]))
|
||||
|
||||
@@ -147,11 +151,9 @@ class Updater:
|
||||
page.pop_dialog()
|
||||
self.found = up
|
||||
self.install()
|
||||
page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(tr("Install dev build {version}?").format(version=up.version), color=T.TEXT,
|
||||
weight=ft.FontWeight.W_600),
|
||||
bgcolor=T.SURFACE_2,
|
||||
content=ft.Container(ft.Column([
|
||||
page.show_dialog(C.dialog(
|
||||
tr("Install dev build {version}?").format(version=up.version),
|
||||
ft.Column([
|
||||
C.callout(C.body(tr("Dev builds let you test fixes before they're released. They're less tested "
|
||||
"than releases and may have bugs. You get the next release as a normal update "
|
||||
"(you have {version}).").format(version=__version__), T.TEXT), "warn"),
|
||||
@@ -161,10 +163,10 @@ class Updater:
|
||||
padding=T.S3, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE,
|
||||
border=ft.Border.all(1, T.BORDER)),
|
||||
], spacing=T.S3, scroll=ft.ScrollMode.AUTO, tight=True, horizontal_alignment=ft.CrossAxisAlignment.STRETCH),
|
||||
width=T.px(560), height=T.px(min(440, 190 + 26 * len(notes.splitlines())))),
|
||||
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:
|
||||
@@ -209,7 +211,7 @@ class Updater:
|
||||
raise RuntimeError(tr("{value} failed ({returncode})")
|
||||
.format(value=cmd[0], returncode=out.returncode))
|
||||
self.restart = self._restart_process
|
||||
rep.stage("Restarting")
|
||||
rep.stage(tr("Restarting"))
|
||||
return tr("FramePort {version} is ready: restarting").format(version=up.version)
|
||||
self.app.submit(tr("Update FramePort to {version}").format(version=up.version), run, None, kind="app-update",
|
||||
open_panel=True)
|
||||
@@ -268,8 +270,8 @@ def library_bar(app: FramePortApp) -> ft.Control | None:
|
||||
C.body(tr("FramePort {version} is available (you have {version2}).")
|
||||
.format(version=up.version, version2=__version__), T.TEXT, expand=True),
|
||||
C.primary(tr("Update now"), ft.Icons.SYSTEM_UPDATE_ROUNDED, lambda e: app.updater.install()),
|
||||
C.ghost(tr("What's new"), on_click=lambda e: app.updater.show_dialog()),
|
||||
C.ghost(tr("Later"), on_click=lambda e: (updates.skip(up.version), app.updater._set(None))),
|
||||
C.ghost(tr("What's new…"), on_click=lambda e: app.updater.show_dialog()),
|
||||
C.ghost(tr("Not now"), on_click=lambda e: (updates.snooze(up.version), app.updater._set(None))),
|
||||
], spacing=T.S3), "info", ft.Icons.SYSTEM_UPDATE_ROUNDED)
|
||||
|
||||
|
||||
|
||||
@@ -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("Canceled"))})
|
||||
|
||||
|
||||
T.on_change(_fill_styles)
|
||||
|
||||
|
||||
def _dur(job: Job) -> str:
|
||||
@@ -31,18 +40,14 @@ def _dur(job: Job) -> str:
|
||||
return f"{s // 60}:{s % 60:02d}"
|
||||
|
||||
|
||||
|
||||
def _progress_text(job: Job) -> str:
|
||||
"""Speed first: the line is ellipsized, and the file name matters least."""
|
||||
return " · ".join(x for x in (job.speed, job.message) if x)
|
||||
|
||||
class ActivityPanel:
|
||||
def __init__(self, app: FramePortApp):
|
||||
self.app = app
|
||||
self.expanded: set[int] = set()
|
||||
self.logs: set[int] = set()
|
||||
self._tiles: dict[int, tuple[tuple, ft.Control]] = {} # job id -> (state key, tile)
|
||||
self._live: dict[int, tuple] = {} # running job id -> (progress bar, message, stage text, log text or None)
|
||||
self._live: dict[int, tuple] = {} # running job id -> (transit, log text or None)
|
||||
self._transits: dict[int, C.Transit] = {} # running job id -> its transit, kept across tile rebuilds
|
||||
# job id -> (log text, its scrolling column): kept across tile rebuilds so reading the log doesn't jump
|
||||
self._logviews: dict[int, tuple[ft.Text, ft.Column]] = {}
|
||||
self._lock = threading.Lock() # refresh() runs from job threads and the UI thread
|
||||
@@ -95,11 +100,9 @@ class ActivityPanel:
|
||||
self._tiles[j.id] = cached
|
||||
rebuilt = True
|
||||
elif running and j.id in self._live:
|
||||
bar, msg, meta, log = self._live[j.id]
|
||||
bar.value = j.fraction
|
||||
msg.value = _progress_text(j)
|
||||
meta.value = j.stage or ""
|
||||
live += [bar, msg, meta]
|
||||
transit, log = self._live[j.id]
|
||||
transit.set(j, **self.app.transit_info(j))
|
||||
live.append(transit.control)
|
||||
if log is not None and len(j.log) != getattr(log, "_lines", -1): # only when it grew
|
||||
log.value = "\n".join(j.log[-400:])
|
||||
log._lines = len(j.log)
|
||||
@@ -119,6 +122,8 @@ class ActivityPanel:
|
||||
return
|
||||
self._layout = layout
|
||||
self._tiles = {j.id: self._tiles[j.id] for j in jobs}
|
||||
self._transits = {jid: t for jid, t in self._transits.items()
|
||||
if any(j.id == jid and j.state == "running" for j in jobs)}
|
||||
self._logviews = {jid: v for jid, v in self._logviews.items() if jid in self._tiles}
|
||||
if self.app.jobs.paused == "frame":
|
||||
pinned.insert(0, self._paused_notice())
|
||||
@@ -161,11 +166,10 @@ class ActivityPanel:
|
||||
icon, color, word = ft.Icons.WARNING_AMBER_ROUNDED, T.WARN, tr("Installed · launch test failed")
|
||||
running = job.state == "running"
|
||||
expanded = running or job.id in self.expanded
|
||||
meta = C.meta(job.stage if running and job.stage else
|
||||
(job.error or word) + (f" · {_dur(job)}" if job.finished else ""),
|
||||
T.ERROR if job.state == "failed" else T.TEXT_2, max_lines=2)
|
||||
meta = C.meta((job.error or word) + (f" · {_dur(job)}" if job.finished else ""),
|
||||
T.ERROR if job.state == "failed" else T.TEXT_2, max_lines=2, visible=not running)
|
||||
head = ft.Row([
|
||||
ft.ProgressRing(width=T.px(18), height=T.px(18), stroke_width=T.px(2), color=T.ACCENT) if running
|
||||
C.spinner() if running
|
||||
else ft.Icon(icon, color=color, size=T.px(20)),
|
||||
ft.Column([C.body(job.title, T.TEXT, weight=ft.FontWeight.W_600, max_lines=2,
|
||||
overflow=ft.TextOverflow.ELLIPSIS), meta],
|
||||
@@ -174,11 +178,13 @@ class ActivityPanel:
|
||||
if job.active else []),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.START, spacing=T.S3)
|
||||
parts: list[ft.Control] = [head]
|
||||
if running:
|
||||
bar = C.progress_bar(job.fraction)
|
||||
msg = C.meta(_progress_text(job), max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
|
||||
parts += [bar, msg]
|
||||
self._live[job.id] = (bar, msg, meta, None)
|
||||
if running: # the game on its way through the portal, with the six steps
|
||||
transit = self._transits.get(job.id)
|
||||
if transit is None:
|
||||
transit = self._transits[job.id] = C.Transit(heading=False)
|
||||
transit.set(job, **self.app.transit_info(job))
|
||||
parts.append(transit.control)
|
||||
self._live[job.id] = (transit, None)
|
||||
if expanded:
|
||||
if job.stages:
|
||||
parts.append(ft.Column([
|
||||
@@ -202,21 +208,22 @@ class ActivityPanel:
|
||||
C.ghost(tr("Hide log") if show_log else tr("Show log"), ft.Icons.TERMINAL_ROUNDED,
|
||||
lambda e: self._toggle(self.logs, job.id)),
|
||||
C.ghost(tr("Copy log"), ft.Icons.CONTENT_COPY_ROUNDED, lambda e: self.app.copy(self.job_text(job))),
|
||||
*([C.ghost(tr("Full launch log"), ft.Icons.DESCRIPTION_ROUNDED,
|
||||
*([C.ghost(tr("Full launch log"), ft.Icons.DESCRIPTION_OUTLINED,
|
||||
lambda e: self.app.show_log_file(job.log_path, job.title))] if job.log_path else []),
|
||||
], spacing=0, wrap=True))
|
||||
if show_log:
|
||||
log, col = self._log_view(job)
|
||||
log.value = "\n".join(job.log[-400:])
|
||||
if running:
|
||||
self._live[job.id] = (*self._live[job.id][:3], log)
|
||||
self._live[job.id] = (self._live[job.id][0], log)
|
||||
else:
|
||||
col.auto_scroll = False
|
||||
parts.append(ft.Container(col, bgcolor=T.BG, border_radius=T.RADIUS_SM, padding=T.S2,
|
||||
height=T.px(240)))
|
||||
return ft.Container(ft.Column(parts, spacing=T.S2), bgcolor=T.SURFACE, border_radius=T.RADIUS,
|
||||
border=ft.Border.all(1, T.ACCENT if running else T.BORDER), padding=T.S3,
|
||||
on_click=None if running else lambda e: self._toggle(self.expanded, job.id))
|
||||
on_click=None if running else lambda e: self._toggle(self.expanded, job.id),
|
||||
ink=not running)
|
||||
|
||||
def _log_view(self, job: Job) -> tuple[ft.Text, ft.Column]:
|
||||
"""The job's log view, the same one for the job's whole life: rebuilding it on every new stage or check reset
|
||||
|
||||
@@ -123,7 +123,7 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None
|
||||
choices = list(spec[0])
|
||||
if v not in [c for c, _ in choices]:
|
||||
choices.append((v, str(v))) # a value set elsewhere (CLI, catalog) stays selectable
|
||||
return ft.Dropdown(value=str(v), width=T.px(220), dense=True, border_color=T.BORDER,
|
||||
return C.dropdown(value=str(v), width=T.px(220),
|
||||
options=[ft.DropdownOption(key=str(c), text=tr(label)) for c, label in choices],
|
||||
on_select=lambda e: set_value(key, e.control.value))
|
||||
|
||||
@@ -201,12 +201,8 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None
|
||||
if show_adv else []),
|
||||
], spacing=T.S3, scroll=ft.ScrollMode.AUTO, tight=True)
|
||||
title = tr("Game settings · {title}").format(title=app._title(package) if g else package)
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(title, weight=ft.FontWeight.W_600), bgcolor=T.SURFACE_2,
|
||||
shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
content=ft.Container(content, width=T.px(640), height=T.px(560)),
|
||||
actions=[C.ghost(tr("Reset to recommended"), ft.Icons.RESTART_ALT_ROUNDED, reset),
|
||||
C.ghost(tr("Cancel"), on_click=lambda e: app.page.pop_dialog()),
|
||||
C.primary(tr("Save"), on_click=save)],
|
||||
actions_alignment=ft.MainAxisAlignment.END))
|
||||
app.page.show_dialog(C.dialog(
|
||||
title, content, [C.ghost(tr("Reset to recommended"), ft.Icons.RESTART_ALT_ROUNDED, reset),
|
||||
C.ghost(tr("Cancel"), on_click=lambda e: app.page.pop_dialog()),
|
||||
C.primary(tr("Save"), on_click=save)], height=T.px(560)))
|
||||
apply_visibility()
|
||||
@@ -19,9 +19,7 @@ if TYPE_CHECKING:
|
||||
|
||||
def show_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
g = library.game(package)
|
||||
term = ft.TextField(value=g.get("title") or package, dense=True, expand=True, border_radius=T.RADIUS_SM,
|
||||
bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT, focused_border_color=T.ACCENT,
|
||||
content_padding=ft.Padding(T.px(12), T.px(8), T.px(12), T.px(8)), text_size=T.T_BODY)
|
||||
term = C.search(value=g.get("title") or package, expand=True)
|
||||
results = ft.GridView(max_extent=T.px(190), child_aspect_ratio=0.8, spacing=T.S3, run_spacing=T.S3,
|
||||
height=T.px(400))
|
||||
status = C.meta("")
|
||||
@@ -43,7 +41,7 @@ def show_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
if installed:
|
||||
app.toast(tr("Artwork updated for {get}. The Frame's Steam library still shows the old art.")
|
||||
.format(get=g.get('title')),
|
||||
action=tr("Update on Frame"), on_action=lambda e: app.update_steam_art(package))
|
||||
action=tr("Update Steam art on Frame"), on_action=lambda e: app.update_steam_art(package))
|
||||
else:
|
||||
app.toast(tr("Artwork updated for {get}").format(get=g.get('title')))
|
||||
app.run_bg(work)
|
||||
@@ -56,7 +54,7 @@ def show_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
def work():
|
||||
found = sources.search(term.value.strip())
|
||||
results.controls = [ft.Container(ft.Column([
|
||||
ft.Container(C.art_fill(r["preview"], radius=T.px(8), height=T.px(120)), height=T.px(120)),
|
||||
ft.Container(C.art_fill(r["preview"], radius=T.RADIUS_SM, height=T.px(120)), height=T.px(120)),
|
||||
C.body(r["name"] or "", T.TEXT, size=T.T_META, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS),
|
||||
C.meta(r["source"]),
|
||||
], spacing=T.px(4)), padding=T.S2, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE, ink=True,
|
||||
@@ -80,20 +78,19 @@ def show_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
if src and src != "none" and installed:
|
||||
app.toast(tr("Artwork updated ({src}). The Frame's Steam library still shows the old art.")
|
||||
.format(src=src),
|
||||
action=tr("Update on Frame"), on_action=lambda e: app.update_steam_art(package))
|
||||
action=tr("Update Steam art on Frame"), on_action=lambda e: app.update_steam_art(package))
|
||||
else:
|
||||
app.toast(tr("Artwork: {value}").format(value=src or 'none found'))
|
||||
app.run_bg(work)
|
||||
|
||||
term.on_submit = search
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(tr("Artwork for {get}").format(get=g.get('title')), weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(ft.Column([
|
||||
app.page.show_dialog(C.dialog(
|
||||
tr("Artwork for {get}").format(get=g.get('title')),
|
||||
ft.Column([
|
||||
ft.Row([term, C.secondary(tr("Search"), ft.Icons.SEARCH_ROUNDED, search)], spacing=T.S2),
|
||||
status, results,
|
||||
], spacing=T.S3, tight=True), width=T.px(660)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[C.ghost(tr("Use your own images…"), ft.Icons.UPLOAD_FILE_ROUNDED,
|
||||
], spacing=T.S3, tight=True),
|
||||
actions=[C.ghost(tr("Use your own images…"), ft.Icons.UPLOAD_FILE_OUTLINED,
|
||||
lambda e: (app.page.pop_dialog(), show_custom_art_dialog(app, package))),
|
||||
C.ghost(tr("Find automatically"), ft.Icons.AUTO_AWESOME_ROUNDED, auto),
|
||||
C.ghost(tr("Close"), on_click=lambda e: app.page.pop_dialog())]))
|
||||
@@ -125,7 +122,7 @@ def show_custom_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
app.refresh_view()
|
||||
if installed:
|
||||
app.toast(tr("{message} The Frame's Steam library still shows the old art.").format(message=message),
|
||||
action=tr("Update on Frame"), on_action=lambda e: app.update_steam_art(package))
|
||||
action=tr("Update Steam art on Frame"), on_action=lambda e: app.update_steam_art(package))
|
||||
else:
|
||||
app.toast(message)
|
||||
|
||||
@@ -155,16 +152,16 @@ def show_custom_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
art = thumbs.url(package, (kind,), 400)
|
||||
if art and kind in ("logo", "icon"): # shown whole (a logo is often very wide, an icon small)
|
||||
box = ft.Container(ft.Image(src=art, fit=ft.BoxFit.CONTAIN), height=T.px(110), padding=T.S2,
|
||||
border_radius=T.px(8), bgcolor=T.SURFACE_3, alignment=ft.Alignment.CENTER)
|
||||
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, alignment=ft.Alignment.CENTER)
|
||||
else:
|
||||
box = (C.art_fill(art, radius=T.px(8), height=T.px(110)) if art else
|
||||
box = (C.art_fill(art, radius=T.RADIUS_SM, height=T.px(110)) if art else
|
||||
ft.Container(C.meta(tr("None")), height=T.px(110), alignment=ft.Alignment.CENTER,
|
||||
border_radius=T.px(8), bgcolor=T.SURFACE_3))
|
||||
buttons = [C.ghost(tr("Choose…"), ft.Icons.UPLOAD_FILE_ROUNDED,
|
||||
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3))
|
||||
buttons = [C.ghost(tr("Choose…"), ft.Icons.UPLOAD_FILE_OUTLINED,
|
||||
lambda e, k=kind, lb=label: app.page.run_task(choose, k, lb))]
|
||||
if art:
|
||||
buttons.append(ft.IconButton(ft.Icons.DELETE_OUTLINE_ROUNDED, icon_size=T.px(18), icon_color=T.TEXT_3,
|
||||
tooltip=tr("Remove"), on_click=lambda e, k=kind, lb=label: remove(k, lb)))
|
||||
buttons.append(C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Remove"),
|
||||
lambda e, k=kind, lb=label: remove(k, lb), color=T.TEXT_3))
|
||||
return ft.Container(ft.Column([
|
||||
box,
|
||||
C.body(label, T.TEXT, size=T.T_META, weight=ft.FontWeight.W_600),
|
||||
@@ -178,14 +175,13 @@ def show_custom_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
C.update(grid)
|
||||
|
||||
grid.controls = [slot(*s) for s in custom_slots()]
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(tr("Your own artwork for {title}").format(title=g.get("title")), weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(ft.Column([
|
||||
app.page.show_dialog(C.dialog(
|
||||
tr("Your own artwork for {title}").format(title=g.get("title")),
|
||||
ft.Column([
|
||||
C.body(tr("Choose PNG, JPEG or WebP images. Steam fills any missing shape from the ones you choose; "
|
||||
"FramePort never replaces your images automatically.")),
|
||||
grid,
|
||||
], spacing=T.S3, tight=True), width=T.px(660)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
], spacing=T.S3, tight=True),
|
||||
actions=[C.ghost(tr("Search the stores instead"), ft.Icons.IMAGE_SEARCH_ROUNDED,
|
||||
lambda e: (app.page.pop_dialog(), show_art_dialog(app, package))),
|
||||
C.ghost(tr("Close"), on_click=lambda e: app.page.pop_dialog())]))
|
||||
@@ -42,6 +42,18 @@ def show_exe_dialog(app: FramePortApp, package: str, remaining: int = 0,
|
||||
cands = extra.get("exe_candidates") or [{"path": g.get("exe"), "score": 0, "reasons": [], "size": 0}]
|
||||
current = g.get("exe") or cands[0]["path"]
|
||||
group = ft.RadioGroup(value=current, content=ft.Column(spacing=T.S2))
|
||||
rows: dict[str, ft.Container] = {}
|
||||
|
||||
def paint(e=None):
|
||||
for path, row in rows.items():
|
||||
C.selected_style(row, path == group.value, idle_bg=T.SURFACE)
|
||||
if e is not None:
|
||||
C.update(group)
|
||||
|
||||
def choose(path):
|
||||
group.value = path
|
||||
paint(True)
|
||||
group.on_change = paint
|
||||
for i, c in enumerate(cands):
|
||||
tags = [C.pill(r, T.OK if r in ("Unreal game build", "Unity game (next to its data)", "name matches the game",
|
||||
"named in the Oculus manifest") else
|
||||
@@ -50,7 +62,7 @@ def show_exe_dialog(app: FramePortApp, package: str, remaining: int = 0,
|
||||
if i == 0:
|
||||
tags.insert(0, C.pill(tr("Best guess"), T.ACCENT, ft.Icons.AUTO_AWESOME_ROUNDED))
|
||||
folder, _, name = c["path"].rpartition("/")
|
||||
group.content.controls.append(ft.Container(ft.Row([
|
||||
rows[c["path"]] = row = C.hoverable(ft.Container(ft.Row([
|
||||
ft.Radio(value=c["path"], active_color=T.ACCENT),
|
||||
ft.Column([
|
||||
ft.Row([C.body(name, T.TEXT, weight=ft.FontWeight.W_600), C.meta(_size(c.get("size") or 0))],
|
||||
@@ -59,8 +71,9 @@ def show_exe_dialog(app: FramePortApp, package: str, remaining: int = 0,
|
||||
ft.Row(tags, spacing=T.px(6), wrap=True) if tags else ft.Container(),
|
||||
], spacing=T.px(4), expand=True),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.START), padding=ft.Padding(T.S2, T.S2, T.S3, T.S2),
|
||||
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE if c["path"] != current else T.ACCENT_SOFT,
|
||||
on_click=lambda e, p=c["path"]: (setattr(group, "value", p), group.update())))
|
||||
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE, on_click=lambda e, p=c["path"]: choose(p)))
|
||||
group.content.controls.append(row)
|
||||
paint()
|
||||
|
||||
def use(e):
|
||||
choice = group.value
|
||||
@@ -88,12 +101,10 @@ def show_exe_dialog(app: FramePortApp, package: str, remaining: int = 0,
|
||||
"as crash reporters or updaters are the wrong choice).") if linux else
|
||||
tr("FramePort found more than one program that could start this game. Pick the one you'd double-click to "
|
||||
"play it. Oculus builds usually work better with Revive than Steam builds."))
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Row([ft.Text(title, weight=ft.FontWeight.W_600, expand=True)]
|
||||
+ ([C.meta(tr("{remaining} more after this").format(remaining=remaining))] if remaining else [])),
|
||||
content=ft.Container(ft.Column([C.body(lead), group], spacing=T.S4, scroll=ft.ScrollMode.AUTO, tight=True),
|
||||
width=T.px(620), height=min(120 + 96 * len(cands), 520)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
app.page.show_dialog(C.dialog(
|
||||
title, ft.Column([C.body(lead), group], spacing=T.S4, scroll=ft.ScrollMode.AUTO, tight=True),
|
||||
title_actions=[C.meta(tr("{remaining} more after this").format(remaining=remaining))] if remaining else None,
|
||||
height=T.px(min(120 + 96 * len(cands), 520)),
|
||||
modal=True, on_dismiss=pick(lambda e: on_done() if on_done else None), # closed (Esc) = decide later
|
||||
actions=[C.ghost(tr("Decide later"), on_click=pick(later)),
|
||||
C.primary(tr("Use this program"), on_click=pick(use))]))
|
||||
@@ -23,7 +23,7 @@ from .files_dialog import human
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
SHARED_ICONS = {"videos": ft.Icons.MOVIE_OUTLINED, "downloads": ft.Icons.DOWNLOAD_OUTLINED,
|
||||
SHARED_ICONS = {"videos": ft.Icons.MOVIE_OUTLINED, "downloads": ft.Icons.DOWNLOAD_ROUNDED,
|
||||
"documents": ft.Icons.DESCRIPTION_OUTLINED}
|
||||
VIDEO_EXT = {".mp4", ".mkv", ".webm", ".mov", ".avi", ".m4v", ".ts"}
|
||||
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
|
||||
@@ -47,7 +47,7 @@ def dropzone_available() -> bool:
|
||||
|
||||
def file_icon(name: str, is_dir: bool) -> str:
|
||||
if is_dir:
|
||||
return ft.Icons.FOLDER_ROUNDED
|
||||
return ft.Icons.FOLDER_OUTLINED
|
||||
ext = Path(name).suffix.lower()
|
||||
if ext in VIDEO_EXT:
|
||||
return ft.Icons.MOVIE_OUTLINED
|
||||
@@ -84,21 +84,24 @@ class FilesView:
|
||||
self.listing = ft.Column(spacing=0, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
self.status = C.meta("")
|
||||
self.toolbar = ft.Row([
|
||||
C.primary(tr("Upload files"), ft.Icons.UPLOAD_FILE_ROUNDED, self.upload_files),
|
||||
C.secondary(tr("Upload folder"), ft.Icons.DRIVE_FOLDER_UPLOAD_ROUNDED, self.upload_folder),
|
||||
C.ghost(tr("New folder"), ft.Icons.CREATE_NEW_FOLDER_OUTLINED, lambda e: self.new_folder()),
|
||||
C.primary(tr("Upload files…"), ft.Icons.UPLOAD_FILE_OUTLINED, self.upload_files),
|
||||
C.secondary(tr("Upload folder…"), ft.Icons.DRIVE_FOLDER_UPLOAD_OUTLINED, self.upload_folder),
|
||||
C.ghost(tr("New folder…"), ft.Icons.CREATE_NEW_FOLDER_OUTLINED, lambda e: self.new_folder()),
|
||||
C.icon_btn(ft.Icons.REFRESH_ROUNDED, tr("Refresh"), lambda e: self.load()),
|
||||
], spacing=T.S2, wrap=True, run_spacing=T.S2)
|
||||
self.hidden_switch = C.switch(tr("Show hidden files"), wrap=False, value=False, on_change=self._toggle_hidden)
|
||||
self.selected: set[str] = set() # paths of checked entries in the current folder
|
||||
self.checks: dict[str, ft.Checkbox] = {}
|
||||
self.rows: dict[str, tuple] = {} # path → (row, quiet checkbox, quiet actions)
|
||||
self.hot: set[str] = set() # rows under the mouse or with keyboard focus inside
|
||||
self._select_mode = False
|
||||
self.select_all = ft.Checkbox(value=False, active_color=T.ACCENT, check_color=T.ON_ACCENT,
|
||||
tooltip=tr("Select all"), on_change=self._toggle_all)
|
||||
self.sel_label = C.body("", T.TEXT, weight=ft.FontWeight.W_500)
|
||||
self.sel_bar = ft.Container(ft.Row([
|
||||
self.sel_label, ft.Container(expand=True),
|
||||
C.secondary(tr("Download"), ft.Icons.DOWNLOAD_ROUNDED, self._download_selected),
|
||||
C.ghost(tr("Delete"), ft.Icons.DELETE_OUTLINE_ROUNDED, lambda e: self._delete_selected()),
|
||||
C.secondary(tr("Download…"), ft.Icons.DOWNLOAD_ROUNDED, self._download_selected),
|
||||
C.ghost(tr("Delete…"), ft.Icons.DELETE_OUTLINE_ROUNDED, lambda e: self._delete_selected()),
|
||||
C.ghost(tr("Clear"), ft.Icons.CLOSE_ROUNDED, lambda e: self._clear_selection()),
|
||||
], spacing=T.S2), padding=ft.Padding(T.S3, T.px(6), T.S2, T.px(6)), border_radius=T.RADIUS_SM,
|
||||
bgcolor=T.ACCENT_SOFT, visible=False)
|
||||
@@ -175,12 +178,14 @@ class FilesView:
|
||||
|
||||
def _location_row(self, loc: dict, icon: str, sub: str = "") -> ft.Control:
|
||||
selected = self.loc is not None and self.loc["id"] == loc["id"]
|
||||
return ft.Container(
|
||||
ft.Row([ft.Icon(icon, size=T.px(18), color=T.ACCENT if selected else T.TEXT_2),
|
||||
ft.Column([C.body(loc["label"], T.TEXT if selected else T.TEXT_2, weight=ft.FontWeight.W_500),
|
||||
*([C.meta(sub)] if sub else [])], spacing=0, expand=True)], spacing=T.S2),
|
||||
ic = ft.Icon(icon, size=T.px(18))
|
||||
label = C.body(loc["label"], weight=ft.FontWeight.W_500)
|
||||
box = ft.Container(
|
||||
ft.Row([ic, ft.Column([label, *([C.meta(sub)] if sub else [])], spacing=0, expand=True)], spacing=T.S2),
|
||||
padding=ft.Padding(T.S2, T.px(6), T.S2, T.px(6)), border_radius=T.RADIUS_SM, ink=True,
|
||||
bgcolor=T.ACCENT_SOFT if selected else None, on_click=lambda e, loc_=loc: self.open_location(loc_))
|
||||
on_click=lambda e, loc_=loc: self.open_location(loc_))
|
||||
C.selected_style(box, selected, ic, label) # like the sidebar: the portal fade with a white icon
|
||||
return box
|
||||
|
||||
def _render_locations(self) -> None:
|
||||
rows = [self._location_row(loc_, SHARED_ICONS.get(loc_["id"], ft.Icons.FOLDER_OUTLINED), loc_["android"])
|
||||
@@ -279,16 +284,15 @@ class FilesView:
|
||||
for i, (label, p) in enumerate(crumbs(loc["path"], self.path, loc["label"])):
|
||||
if i:
|
||||
self.crumb_row.controls.append(ft.Icon(ft.Icons.CHEVRON_RIGHT_ROUNDED, size=T.px(16), color=T.TEXT_3))
|
||||
self.crumb_row.controls.append(ft.TextButton(label, on_click=lambda e, p=p: self.cd(p),
|
||||
style=ft.ButtonStyle(color=T.TEXT if p == self.path
|
||||
else T.TEXT_2)))
|
||||
self.crumb_row.controls.append(C.ghost(label, on_click=lambda e, p=p: self.cd(p),
|
||||
color=T.TEXT if p == self.path else T.TEXT_2))
|
||||
android = loc.get("android")
|
||||
rel = posixpath.relpath(self.path, loc["path"]) if self.path != loc["path"] else ""
|
||||
self.where.value = (tr("Games see this folder as {value}")
|
||||
.format(value=posixpath.join(android, rel) if rel else android)
|
||||
if android else self.path)
|
||||
rows = []
|
||||
self.checks = {}
|
||||
self.checks, self.rows, self.hot = {}, {}, set()
|
||||
if self.path != loc["path"]:
|
||||
rows.append(self._row(None))
|
||||
rows += [self._row(e) for e in self.entries]
|
||||
@@ -311,27 +315,34 @@ class FilesView:
|
||||
padding=ft.Padding(T.S3, T.px(8), T.S3, T.px(8)), border_radius=T.RADIUS_SM, ink=True,
|
||||
on_click=lambda ev: self.cd(posixpath.dirname(self.path)))
|
||||
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(e.mtime)) if e.mtime else ""
|
||||
info = (("folder" if e.is_dir else human(e.size)) + (tr(" · link") if e.link else "")
|
||||
info = ((tr("folder") if e.is_dir else human(e.size)) + (tr(" · link") if e.link else "")
|
||||
+ (f" · {when}" if when else ""))
|
||||
actions = [C.icon_btn(ft.Icons.DOWNLOAD_ROUNDED, tr("Download to this PC"),
|
||||
actions = [C.icon_btn(ft.Icons.DOWNLOAD_ROUNDED, tr("Download to this PC…"),
|
||||
lambda ev, x=e: self._download_later([x]))]
|
||||
if not self._protected(e):
|
||||
actions += [C.icon_btn(ft.Icons.DRIVE_FILE_RENAME_OUTLINE_ROUNDED, tr("Rename"),
|
||||
actions += [C.icon_btn(ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, tr("Rename…"),
|
||||
lambda ev, x=e: self.rename(x)),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Delete"), lambda ev, x=e: self.delete([x]))]
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Delete…"), lambda ev, x=e: self.delete([x]))]
|
||||
check = ft.Checkbox(value=e.path in self.selected, active_color=T.ACCENT, check_color=T.ON_ACCENT,
|
||||
on_change=lambda ev, p=e.path: self._toggle(p, ev.control.value),
|
||||
disabled=self._protected(e))
|
||||
self.checks[e.path] = check
|
||||
# quiet rows: the checkbox and the actions show on hover, keyboard focus or when something is selected
|
||||
for c in (check, *actions):
|
||||
c.on_focus = lambda ev, p=e.path: self._focus(p, True)
|
||||
c.on_blur = lambda ev, p=e.path: self._focus(p, False)
|
||||
quiet_check, quiet_actions = C.quiet(check), C.quiet(*actions)
|
||||
row = ft.Container(
|
||||
ft.Row([check,
|
||||
ft.Row([quiet_check,
|
||||
ft.Icon(file_icon(e.name, e.is_dir), size=T.px(20), color=T.ACCENT if e.is_dir else T.TEXT_2),
|
||||
ft.Column([C.body(e.name, T.TEXT, weight=ft.FontWeight.W_500, max_lines=1,
|
||||
overflow=ft.TextOverflow.ELLIPSIS), C.meta(info)], spacing=0, expand=True),
|
||||
*actions], spacing=T.S3),
|
||||
quiet_actions], spacing=T.S3),
|
||||
padding=ft.Padding(T.S3, T.px(6), T.S2, T.px(6)), border_radius=T.RADIUS_SM, ink=e.is_dir,
|
||||
on_click=(lambda ev, p=e.path: self.cd(p)) if e.is_dir else None,
|
||||
on_hover=lambda ev, p=e.path: self.drag.hover(p, ev.data in (True, "true")))
|
||||
on_hover=lambda ev, p=e.path: self._hover(p, ev.data in (True, "true")))
|
||||
self.rows[e.path] = (row, quiet_check, quiet_actions)
|
||||
self._paint_row(e.path)
|
||||
return ft.GestureDetector(content=row, on_secondary_tap_down=lambda ev, x=e: self.open_menu(x,
|
||||
ev.global_position))
|
||||
|
||||
@@ -340,8 +351,8 @@ class FilesView:
|
||||
"""[(label, icon, handler) | None] for a right-click on entry `e` (None = on the folder's empty space). With
|
||||
several entries selected and `e` among them, the actions act on the whole selection."""
|
||||
if e is None:
|
||||
return [(tr("Upload files…"), ft.Icons.UPLOAD_FILE_ROUNDED, self.upload_files),
|
||||
(tr("Upload folder…"), ft.Icons.DRIVE_FOLDER_UPLOAD_ROUNDED, self.upload_folder),
|
||||
return [(tr("Upload files…"), ft.Icons.UPLOAD_FILE_OUTLINED, self.upload_files),
|
||||
(tr("Upload folder…"), ft.Icons.DRIVE_FOLDER_UPLOAD_OUTLINED, self.upload_folder),
|
||||
(tr("New folder…"), ft.Icons.CREATE_NEW_FOLDER_OUTLINED, lambda ev: self.new_folder()),
|
||||
None,
|
||||
*([(tr("Select all"), ft.Icons.SELECT_ALL_ROUNDED, lambda ev: self._select_all())]
|
||||
@@ -360,7 +371,7 @@ class FilesView:
|
||||
else tr("Download to this PC…"), ft.Icons.DOWNLOAD_ROUNDED,
|
||||
lambda ev: self._download_later(items)))
|
||||
if not several and not self._protected(e):
|
||||
out.append((tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_ROUNDED, lambda ev: self.rename(e)))
|
||||
out.append((tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, lambda ev: self.rename(e)))
|
||||
if not several:
|
||||
out.append((tr("Copy path"), ft.Icons.CONTENT_COPY_ROUNDED, lambda ev: self.app.copy(self._shown_path(
|
||||
e.path))))
|
||||
@@ -408,8 +419,35 @@ class FilesView:
|
||||
if check is not None and check.value != on:
|
||||
check.value = on
|
||||
C.update(check)
|
||||
if self._paint_row(path):
|
||||
C.update(self.rows[path][0])
|
||||
self._update_selection()
|
||||
|
||||
# ---------------------------------------------------------------- quiet rows
|
||||
def _hover(self, path: str, inside: bool) -> None:
|
||||
self.drag.hover(path, inside)
|
||||
self._set_hot(path, inside)
|
||||
|
||||
def _focus(self, path: str, on: bool) -> None:
|
||||
self._set_hot(path, on)
|
||||
|
||||
def _set_hot(self, path: str, on: bool) -> None:
|
||||
(self.hot.add if on else self.hot.discard)(path)
|
||||
if path in self.rows and self._paint_row(path):
|
||||
C.update(self.rows[path][0])
|
||||
|
||||
def _paint_row(self, path: str) -> bool:
|
||||
"""Properties only: the selected look and whether the checkbox/actions show. True when something changed."""
|
||||
row, quiet_check, quiet_actions = self.rows[path]
|
||||
on = path in self.selected
|
||||
before = (row.bgcolor, row.gradient is not None)
|
||||
C.selected_style(row, on)
|
||||
changed = before != (row.bgcolor, row.gradient is not None)
|
||||
show = on or path in self.hot or bool(self.selected)
|
||||
changed |= C.reveal(quiet_check, show)
|
||||
changed |= C.reveal(quiet_actions, on or path in self.hot)
|
||||
return changed
|
||||
|
||||
def _toggle_all(self, e) -> None:
|
||||
self.selected = {x.path for x in self.entries if self._selectable(x)} if e.control.value else set()
|
||||
self._render_listing()
|
||||
@@ -435,6 +473,11 @@ class FilesView:
|
||||
self.sel_label.value = (tr("{n} selected").format(n=n)
|
||||
+ (tr(" · {human} in files").format(human=human(size)) if size else ""))
|
||||
self.sel_bar.visible = bool(n)
|
||||
if bool(n) != self._select_mode: # select mode starts/ends: every row's checkbox shows/hides
|
||||
self._select_mode = bool(n)
|
||||
changed = [path for path in self.rows if self._paint_row(path)]
|
||||
if render and changed:
|
||||
C.update(self.listing)
|
||||
self.select_all.value = bool(self.entries) and n == len([x for x in self.entries if self._selectable(x)])
|
||||
if render:
|
||||
for c in (self.sel_bar, self.select_all):
|
||||
@@ -509,7 +552,7 @@ class FilesView:
|
||||
|
||||
def delete(self, items: list) -> None:
|
||||
names = ", ".join(x.name for x in items[:3]) + (" …" if len(items) > 3 else "")
|
||||
what = tr("folder and everything in it") if any(x.is_dir for x in items) else "file"
|
||||
what = tr("folder and everything in it") if any(x.is_dir for x in items) else tr("file")
|
||||
C.confirm(self.app.page, tr("Delete {names}?").format(names=names),
|
||||
tr("This deletes the {what} on the Frame. It can't be undone.").format(what=what),
|
||||
tr("Delete"), lambda: (self.selected.difference_update(x.path for x in items),
|
||||
@@ -531,14 +574,12 @@ class FilesView:
|
||||
|
||||
def _ask_name(self, heading: str, label: str, value: str, ok: str, on_ok) -> None:
|
||||
page = self.app.page
|
||||
field = ft.TextField(label=label, value=value, autofocus=True, width=T.px(380))
|
||||
field = C.field(label=label, value=value, autofocus=True, width=T.DIALOG_S)
|
||||
|
||||
def go(e=None):
|
||||
page.pop_dialog()
|
||||
if (field.value or "").strip():
|
||||
on_ok(field.value.strip())
|
||||
field.on_submit = go
|
||||
page.show_dialog(ft.AlertDialog(title=ft.Text(heading, color=T.TEXT, weight=ft.FontWeight.W_600),
|
||||
bgcolor=T.SURFACE_2, content=field,
|
||||
actions=[C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
C.primary(ok, on_click=go)]))
|
||||
page.show_dialog(C.dialog(heading, field, [C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
C.primary(ok, on_click=go)], size="s"))
|
||||
@@ -96,14 +96,11 @@ def human(n: int) -> str:
|
||||
|
||||
def show_files_dialog(app: FramePortApp, package: str, title: str) -> None:
|
||||
status = C.meta(tr("Reading the file list from the Frame…"))
|
||||
ring = ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT)
|
||||
ring = C.spinner()
|
||||
body = ft.Column([ft.Row([ring, status],
|
||||
spacing=T.S2)], spacing=T.S3, expand=True)
|
||||
dialog = ft.AlertDialog(
|
||||
title=ft.Text(tr("Files on the Frame — {title}").format(title=title), weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(body, width=T.px(760), height=T.px(560)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[C.ghost(tr("Close"), on_click=lambda e: app.page.pop_dialog())])
|
||||
dialog = C.dialog(tr("Files on the Frame — {title}").format(title=title), body,
|
||||
[C.ghost(tr("Close"), on_click=lambda e: app.page.pop_dialog())], size="l", height=T.px(560))
|
||||
app.page.show_dialog(dialog)
|
||||
|
||||
def load():
|
||||
@@ -113,19 +110,15 @@ def show_files_dialog(app: FramePortApp, package: str, title: str) -> None:
|
||||
trees = [(r, build_tree(r["name"], r["files"])) for r in result["roots"]]
|
||||
expanded: set[str] = set()
|
||||
rows = ft.ListView(spacing=0, expand=True)
|
||||
search = ft.TextField(hint_text=tr("Filter files (e.g. .pak, Binaries)"), dense=True, expand=True,
|
||||
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
|
||||
focused_border_color=T.ACCENT,
|
||||
content_padding=ft.Padding(T.px(12), T.px(8), T.px(12), T.px(8)),
|
||||
text_size=T.T_BODY, prefix_icon=ft.Icons.SEARCH_ROUNDED)
|
||||
search = C.search(hint_text=tr("Filter files (for example .pak, Binaries)"), expand=True)
|
||||
|
||||
def row(depth: int, node: Node | int, root_key: str) -> ft.Control:
|
||||
pad = ft.Padding(8 + depth * 18, T.px(3), T.px(8), T.px(3))
|
||||
pad = ft.Padding(T.px(8 + depth * 18), T.px(3), T.px(8), T.px(3))
|
||||
if isinstance(node, int):
|
||||
return ft.Container(C.meta(tr("… {node} more (use the filter to find them)").format(node=node)),
|
||||
padding=pad)
|
||||
key = f"{root_key}\0{node.path}"
|
||||
icon = (ft.Icons.FOLDER_OPEN_ROUNDED if key in expanded else ft.Icons.FOLDER_ROUNDED) if node.is_dir \
|
||||
icon = (ft.Icons.FOLDER_OPEN_ROUNDED if key in expanded else ft.Icons.FOLDER_OUTLINED) if node.is_dir \
|
||||
else ft.Icons.INSERT_DRIVE_FILE_OUTLINED
|
||||
chevron = ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED if key in expanded else ft.Icons.CHEVRON_RIGHT_ROUNDED,
|
||||
size=T.px(16), color=T.TEXT_3) if node.is_dir else ft.Container(width=T.px(16))
|
||||
@@ -138,7 +131,7 @@ def show_files_dialog(app: FramePortApp, package: str, title: str) -> None:
|
||||
ft.Container(C.meta(note, T.TEXT_3, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS),
|
||||
tooltip=note or None, expand=True),
|
||||
C.meta(info),
|
||||
], spacing=T.px(6)), padding=pad, border_radius=T.px(6), ink=node.is_dir,
|
||||
], spacing=T.px(6)), padding=pad, border_radius=T.RADIUS_XS, ink=node.is_dir,
|
||||
on_click=(lambda e, k=key: toggle(k)) if node.is_dir else None)
|
||||
|
||||
def render():
|
||||
|
||||
@@ -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,11 +28,11 @@ 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),
|
||||
height=T.px(72), border_radius=T.px(18), bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
|
||||
ft.Container(C.as_icon(G.FRAME, T.px(34), T.ACCENT), width=T.px(72),
|
||||
height=T.px(72), border_radius=T.RADIUS, 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)],
|
||||
spacing=T.S3),
|
||||
ft.Row([C.title(info.get("hostname") or t.label, T.T_DISPLAY),
|
||||
C.pill(tr("Connected"), T.OK, ft.Icons.CIRCLE)], spacing=T.S3),
|
||||
C.body(tr("{user}@{host} · {get} {get2} (build {get3})")
|
||||
.format(user=t.target.user, host=t.target.host, get=info.get('os'),
|
||||
get2=info.get('os_version'), get3=info.get('build_id'))),
|
||||
@@ -42,45 +43,78 @@ 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(),
|
||||
tooltip=tr("Use this computer's keyboard on the Frame")),
|
||||
C.ghost(tr("Switch Frame…"), ft.Icons.SWAP_HORIZ_ROUNDED, lambda e: app.disconnect()),
|
||||
C.secondary(tr("Type on Frame"), G.KEYS, lambda e: app.type_on_frame(),
|
||||
tooltip=tr("Use this PC's keyboard on the Frame")),
|
||||
C.ghost(tr("Switch Frame…"), ft.Icons.SWAP_HORIZ_ROUNDED, lambda e: self.switch_frame()),
|
||||
C.ghost(tr("Disconnect"), ft.Icons.LINK_OFF_ROUNDED, lambda e: app.disconnect()),
|
||||
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.END),
|
||||
], spacing=T.S4), padding=T.S5)
|
||||
|
||||
def switch_frame(self) -> None:
|
||||
"""A small picker of the remembered Frames (click one to connect), or "Find another Frame…" = the setup and
|
||||
discovery page."""
|
||||
from ...frame.connection import saved_targets
|
||||
|
||||
app = self.app
|
||||
page = app.page
|
||||
current = getattr(getattr(app.target, "target", None), "host", None)
|
||||
|
||||
def pick(target):
|
||||
page.pop_dialog()
|
||||
app.connect(target)
|
||||
|
||||
def find(e):
|
||||
page.pop_dialog()
|
||||
app.disconnect()
|
||||
rows = []
|
||||
for f in saved_targets():
|
||||
here = f.host == current
|
||||
rows.append(ft.Container(ft.Row([
|
||||
C.as_icon(G.FRAME, T.px(22), T.ACCENT),
|
||||
ft.Column([C.body(f.label, T.TEXT, weight=ft.FontWeight.W_500),
|
||||
C.meta(f"{f.user}@{f.host}")], spacing=T.px(2), expand=True),
|
||||
C.pill(tr("Connected"), T.OK, ft.Icons.CIRCLE) if here else
|
||||
C.secondary(tr("Connect"), on_click=lambda e, f=f: pick(f)),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S3, T.px(8), T.S2, T.px(8)), bgcolor=T.SURFACE_3,
|
||||
border_radius=T.RADIUS_SM))
|
||||
if not rows:
|
||||
rows = [C.body(tr("No other Frames remembered yet."))]
|
||||
page.show_dialog(C.dialog(
|
||||
tr("Switch Frame"), ft.Column(rows, spacing=T.S2, tight=True, scroll=ft.ScrollMode.AUTO),
|
||||
[C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
C.secondary(tr("Find another Frame…"), ft.Icons.SEARCH_ROUNDED, find)], size="s"))
|
||||
|
||||
def readiness(self, info: dict) -> ft.Control:
|
||||
app = self.app
|
||||
rows = []
|
||||
items = []
|
||||
lepton = info.get("lepton")
|
||||
rows.append(C.status_row(bool(lepton), tr("Quest games (Lepton)"),
|
||||
tr("Ready") if lepton
|
||||
else tr("Valve's Android runtime isn't installed (needs Developer Mode)"),
|
||||
None if lepton else C.secondary(tr("Install"), ft.Icons.DOWNLOAD_ROUNDED,
|
||||
lambda e: app.install_lepton()),
|
||||
help="lepton"))
|
||||
items.append(C.Check(bool(lepton), tr("Quest games (Lepton)"),
|
||||
tr("Ready") if lepton
|
||||
else tr("Valve's Android runtime isn't installed (needs Developer Mode)"),
|
||||
"lepton", (tr("Install"), ft.Icons.DOWNLOAD_ROUNDED, app.install_lepton)))
|
||||
pr = info.get("proton") or {}
|
||||
ready, sug = pr.get("ready"), pr.get("suggested")
|
||||
if pr:
|
||||
rows.append(C.status_row(
|
||||
items.append(C.Check(
|
||||
bool(ready), tr("PC VR games (Proton)"),
|
||||
tr("{display_name} installed").format(display_name=ready['display_name']) if ready else
|
||||
(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.primary(tr("Install"), ft.Icons.DOWNLOAD_ROUNDED, lambda e: app.install_proton(), disabled=not sug),
|
||||
help="proton"))
|
||||
"proton", (tr("Install…"), ft.Icons.DOWNLOAD_ROUNDED, app.install_proton) if sug else None,
|
||||
C.secondary(tr("Test"), G.TEST, lambda e: app.test_proton()) if ready else None))
|
||||
xr = (pr.get("openxr") or {}).get("name")
|
||||
rows.append(C.status_row(bool(xr), tr("OpenXR runtime"), xr or tr("None found"), help="openxr"))
|
||||
items.append(C.Check(bool(xr), tr("OpenXR runtime"), xr or tr("None found"), "openxr"))
|
||||
keys = info.get("kernel_keys") or {}
|
||||
if keys.get("max_keys"):
|
||||
high = keys["keys"] > keys["max_keys"] * 0.75
|
||||
rows.append(C.status_row(not high if keys["keys"] < keys["max_keys"] else False, tr("Game launch capacity"),
|
||||
f"{keys['keys']}/{keys['max_keys']} kernel keys used"
|
||||
+ (tr(" · restart the Frame soon to reset it") if high else ""),
|
||||
help="kernel_keys"))
|
||||
return C.section(tr("Ready to play"), C.card(ft.Column(rows, spacing=0),
|
||||
padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)))
|
||||
items.append(C.Check(not high if keys["keys"] < keys["max_keys"] else False, tr("Game launch capacity"),
|
||||
tr("{used}/{max} kernel keys used").format(used=keys["keys"], max=keys["max_keys"])
|
||||
+ (tr(" · restart the Frame soon to reset it") if high else ""),
|
||||
"kernel_keys",
|
||||
(tr("Restart the Frame…"), ft.Icons.RESTART_ALT_ROUNDED,
|
||||
lambda: app.frame_power("restart"))))
|
||||
return C.section(tr("Ready to play"), C.card(C.checklist(items), padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)))
|
||||
|
||||
def installed(self, info: dict) -> ft.Control:
|
||||
"""The list fills in the background (icon thumbnails may need creating the first time)."""
|
||||
@@ -93,7 +127,7 @@ class FrameView:
|
||||
send = C.ghost(tr("Files"), ft.Icons.FOLDER_OPEN_ROUNDED, lambda e: self.app.go("files"),
|
||||
tooltip=C.tip(HELP["files"]))
|
||||
return C.section(tr("Installed games ({len})").format(len=len(items)), body,
|
||||
action=ft.Row([send, C.ghost(tr("Free up space"), ft.Icons.CLEANING_SERVICES_ROUNDED,
|
||||
action=ft.Row([send, C.ghost(tr("Free up space…"), ft.Icons.CLEANING_SERVICES_OUTLINED,
|
||||
lambda e: self.app.cleanup_frame(),
|
||||
tooltip=C.tip(HELP["free_space"]))], spacing=T.S2, tight=True)
|
||||
if items else send)
|
||||
@@ -124,7 +158,7 @@ class FrameView:
|
||||
else tr(" · {value:.0f} MiB").format(value=size / 2**20))
|
||||
title = display_title(games[pkg], tw) if in_lib else (d.get("title") or pkg)
|
||||
rows.append(ft.Container(ft.Row([
|
||||
C.art_fill(art, radius=T.px(8), width=T.px(44), height=T.px(44)),
|
||||
C.art_fill(art, radius=T.RADIUS_SM, width=T.px(44), height=T.px(44)),
|
||||
ft.Column([C.body(title, T.TEXT, weight=ft.FontWeight.W_500), C.meta(sub)],
|
||||
spacing=T.px(2), expand=True),
|
||||
# same slots on every row: games without settings (PC VR, 2D apps) get an invisible spacer
|
||||
@@ -135,7 +169,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")),
|
||||
@@ -151,7 +185,7 @@ class FrameView:
|
||||
|
||||
app = self.app
|
||||
found = ft.Column(spacing=T.S2)
|
||||
searching = ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT),
|
||||
searching = ft.Row([C.spinner(),
|
||||
C.meta(tr("Looking for Frames on your network…"))], spacing=T.S2)
|
||||
|
||||
def discover():
|
||||
@@ -162,20 +196,20 @@ class FrameView:
|
||||
res = browse(4)
|
||||
items = []
|
||||
for f in res:
|
||||
label = {"devkit": "SteamOS · Developer Mode", "frameport": "Set up for FramePort",
|
||||
"saved": "Remembered", "scan": "SSH found by network scan"}.get(f.source, f.source)
|
||||
label = {"devkit": tr("SteamOS · Developer Mode"), "frameport": tr("Set up for FramePort"),
|
||||
"saved": tr("Remembered"), "scan": tr("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}"),
|
||||
devkit=f.source == "devkit")),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S3, T.px(8), T.S2, T.px(8)), bgcolor=T.SURFACE_2,
|
||||
border_radius=T.RADIUS_SM))
|
||||
found.controls = items or [C.body(tr("No Frames found. New Frame? Use first-time setup (step 1). Otherwise "
|
||||
"make sure Developer Mode is on (Settings → System → Developer) and "
|
||||
"the Frame is on the same network."))]
|
||||
found.controls = items or [ft.Row([C.body(tr("No Frames found. New Frame? Use first-time setup (step "
|
||||
"1). Otherwise turn on Developer Mode and keep the Frame on "
|
||||
"the same network."), expand=True),
|
||||
C.help_icon("developer_mode")], spacing=T.px(4))]
|
||||
found.controls.append(C.ghost(tr("Search again"), ft.Icons.REFRESH_ROUNDED, lambda e: app.run_bg(discover)))
|
||||
C.update(found)
|
||||
|
||||
@@ -205,7 +239,7 @@ class FrameView:
|
||||
if server.requests or not server.running or app.pairing is not server:
|
||||
return
|
||||
server.hint = firewall_hint(server.port) or tr(
|
||||
"Check that the Frame and this computer are on the same network.")
|
||||
"Check that the Frame and this PC are on the same network.")
|
||||
if app.route[0] == "frame" and app.pairing is server:
|
||||
show_command()
|
||||
app.run_bg(check_network)
|
||||
@@ -224,12 +258,12 @@ class FrameView:
|
||||
tr("On the Frame, open Steam's Settings (Steam button → Settings)."),
|
||||
tr("Go to System and turn on \"Enable Developer Mode\". A Developer page appears in Settings; nothing "
|
||||
"else changes, your games and data stay as they are."),
|
||||
tr("Connect the Frame's USB-C port to this computer with a USB cable (a data cable, not a charge-only "
|
||||
tr("Connect the Frame's USB-C port to this PC with a USB cable (a data cable, not a charge-only "
|
||||
"one)."),
|
||||
]
|
||||
dlg = ft.AlertDialog(
|
||||
title=ft.Text(tr("Turn on Developer Mode first"), weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(ft.Column([
|
||||
dlg = C.dialog(
|
||||
tr("Turn on Developer Mode first"),
|
||||
ft.Column([
|
||||
C.body(tr("The Frame only offers its USB connection in Developer Mode:"), T.TEXT),
|
||||
*[ft.Row([ft.Container(ft.Text(str(i), weight=ft.FontWeight.W_700, color=T.ACCENT),
|
||||
width=T.px(24), height=T.px(24), border_radius=T.px(12),
|
||||
@@ -238,8 +272,7 @@ class FrameView:
|
||||
vertical_alignment=ft.CrossAxisAlignment.START)
|
||||
for i, text in enumerate(steps, 1)],
|
||||
C.meta(tr("FramePort then finds the Frame on the cable by itself.")),
|
||||
], spacing=T.S3, tight=True), width=T.px(480)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
], spacing=T.S3, tight=True), size="s",
|
||||
actions=[C.ghost(tr("Cancel"), on_click=lambda ev: close()),
|
||||
C.primary(tr("Done: look for the cable"), ft.Icons.USB_ROUNDED, go)])
|
||||
app.page.show_dialog(dlg)
|
||||
@@ -251,11 +284,11 @@ class FrameView:
|
||||
from ...frame import usb
|
||||
from ...frame.connection import FrameTarget, server_key
|
||||
|
||||
status = ft.Row([ft.ProgressRing(width=T.px(14), height=T.px(14), stroke_width=T.px(2), color=T.ACCENT),
|
||||
status = ft.Row([C.spinner(),
|
||||
C.meta(tr("Waiting for the cable…"))], spacing=T.S2)
|
||||
pair_box.controls = [
|
||||
C.body(tr("Looking for the Frame on a USB cable (Developer Mode on, USB-C port connected to this "
|
||||
"computer)."), T.TEXT),
|
||||
"PC)."), T.TEXT),
|
||||
status,
|
||||
]
|
||||
C.update(pair_box)
|
||||
@@ -294,7 +327,7 @@ class FrameView:
|
||||
over_usb = bool(getattr(app.pairing, "host", ""))
|
||||
pair_box.controls = [
|
||||
C.body(tr("On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → "
|
||||
"Konsole, and run (it reaches this computer over the USB cable):") if over_usb else
|
||||
"Konsole, and run (it reaches this PC over the USB cable):") if over_usb else
|
||||
tr("On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → "
|
||||
"Konsole, and run:"), T.TEXT),
|
||||
ft.Container(ft.Row([ft.Text(line, font_family="monospace", selectable=True, size=T.px(12),
|
||||
@@ -302,12 +335,11 @@ class FrameView:
|
||||
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, tr("Copy"), lambda e: app.copy(line))]),
|
||||
padding=ft.Padding(T.S3, T.S2, T.S2, T.S2), bgcolor=T.BG, border_radius=T.RADIUS_SM,
|
||||
border=ft.Border.all(1, T.BORDER)),
|
||||
ft.Row([ft.ProgressRing(width=T.px(14), height=T.px(14), stroke_width=T.px(2), color=T.ACCENT),
|
||||
ft.Row([C.spinner(),
|
||||
C.meta(tr("Waiting for your Frame… (code {code})").format(code=app.pairing.code))],
|
||||
spacing=T.S2),
|
||||
C.meta(tr("It trusts this app, turns on Developer Mode and installs Lepton if needed. Turning on "
|
||||
"Developer Mode closes the desktop; the setup finishes on its own and FramePort connects "
|
||||
"by itself. You only do this once.")),
|
||||
ft.Row([C.meta(tr("You only do this once: FramePort connects by itself when the setup has finished."),
|
||||
expand=True), C.help_icon("first_time_setup")], spacing=T.px(4)),
|
||||
]
|
||||
hint = getattr(app.pairing, "hint", "")
|
||||
if hint and not app.pairing.requests: # nothing reached us yet: what may block it, and the other way in
|
||||
@@ -315,9 +347,9 @@ class FrameView:
|
||||
C.body(tr("Nothing has reached FramePort from the Frame yet (curl says “timed out”)?"), T.TEXT,
|
||||
weight=ft.FontWeight.W_600),
|
||||
C.body(hint, T.TEXT),
|
||||
C.meta(tr("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.")),
|
||||
ft.Row([C.meta(tr("Or turn on Developer Mode on the Frame (Settings → System → Enable Developer "
|
||||
"Mode) and pair it under step 2: that needs no connection into this PC."),
|
||||
expand=True), C.help_icon("developer_mode")], spacing=T.px(4)),
|
||||
], spacing=T.S2), "warn"))
|
||||
if update:
|
||||
C.update(pair_box) # (a redraw of the page may have replaced it)
|
||||
@@ -325,12 +357,9 @@ class FrameView:
|
||||
if app.pairing and app.pairing.running:
|
||||
show_command(update=False)
|
||||
|
||||
style = dict(dense=True, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
|
||||
focused_border_color=T.ACCENT, content_padding=ft.Padding(T.px(12), T.px(10), T.px(12), T.px(10)),
|
||||
text_size=T.T_BODY)
|
||||
addr = ft.TextField(hint_text=tr("steamos@frame.local or an IP address"), width=T.px(320), **style)
|
||||
pw = ft.TextField(hint_text=tr("Password (first time only)"), password=True, can_reveal_password=True,
|
||||
width=T.px(240), tooltip=C.tip(HELP["password"]), **style)
|
||||
addr = C.field(hint_text=tr("steamos@frame.local or an IP address"), width=T.px(320))
|
||||
pw = C.field(hint_text=tr("Password (first time only)"), password=True, can_reveal_password=True,
|
||||
width=T.px(240), tooltip=C.tip(HELP["password"]))
|
||||
saved = saved_targets()
|
||||
offline = None
|
||||
if getattr(app, "_not_paired", False): # it answered, but doesn't know FramePort yet
|
||||
@@ -345,9 +374,8 @@ class FrameView:
|
||||
C.secondary(tr("Try again"), ft.Icons.REFRESH_ROUNDED,
|
||||
lambda e: app.connect(saved[0]))]), "warn")
|
||||
elif app.frame_state == "connecting":
|
||||
connecting = tr("Connecting to {value}…").format(value=saved[0].label if saved else 'your Frame')
|
||||
offline = C.callout(ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2),
|
||||
color=T.ACCENT),
|
||||
connecting = tr("Connecting to {value}…").format(value=saved[0].label if saved else tr("your Frame"))
|
||||
offline = C.callout(ft.Row([C.spinner(),
|
||||
C.body(connecting, T.TEXT)],
|
||||
spacing=T.S3), "info")
|
||||
import time as _time
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -92,13 +93,15 @@ class GameView:
|
||||
if self.rift and pkg in app.pc_installs():
|
||||
chips.append(C.install_badge("on_pc"))
|
||||
actions = self.actions()
|
||||
return ft.Container(
|
||||
self._wash = ft.Container(left=0, right=0, top=0, bottom=0, border_radius=T.RADIUS) # cover tint (_tint)
|
||||
self._hero = ft.Container(
|
||||
ft.Stack([
|
||||
C.art_fill(art, radius=T.px(16), hero=True, left=0, right=0, top=0, bottom=0,
|
||||
C.art_fill(art, radius=T.RADIUS, hero=True, left=0, right=0, top=0, bottom=0,
|
||||
placeholder_icon=C.platform_icon(g)),
|
||||
ft.Container(left=0, right=0, top=0, bottom=0, border_radius=T.px(16), gradient=ft.LinearGradient(
|
||||
ft.Container(left=0, right=0, top=0, bottom=0, border_radius=T.RADIUS, gradient=ft.LinearGradient(
|
||||
begin=ft.Alignment.CENTER_LEFT, end=ft.Alignment.CENTER_RIGHT,
|
||||
colors=[T.soft(T.BG, 0.97), T.soft(T.BG, 0.80), T.soft(T.BG, 0.25)], stops=[0.0, 0.45, 1.0])),
|
||||
self._wash,
|
||||
ft.Container(ft.Row([
|
||||
ft.Column([
|
||||
C.ghost(tr("Library"), ft.Icons.ARROW_BACK_ROUNDED, lambda e: app.go("library")),
|
||||
@@ -112,24 +115,46 @@ class GameView:
|
||||
], spacing=T.S2, expand=True),
|
||||
]), padding=ft.Padding(T.S4, T.S3, T.S5, T.S5), left=0, right=0, top=0, bottom=0),
|
||||
]),
|
||||
height=T.px(330), border_radius=T.px(16), border=ft.Border.all(1, T.BORDER))
|
||||
height=T.px(330), border_radius=T.RADIUS, border=ft.Border.all(1, T.BORDER))
|
||||
return self._hero
|
||||
|
||||
def _tint(self, tint: str | None) -> None:
|
||||
"""The cover's colour (thumbs.compute_tint, already clamped dark): a soft glow around and inside the hero and
|
||||
a very faint wash at the top of the page. Properties only, so it can arrive after the page is on screen."""
|
||||
if not tint:
|
||||
return
|
||||
self._hero.shadow = ft.BoxShadow(blur_radius=T.px(90), spread_radius=0, color=T.soft(tint, 0.18),
|
||||
offset=ft.Offset(0, T.px(10)))
|
||||
# from the lower left, behind the title and buttons (dark enough there for the text: ≤ 0.18 on the BG fade)
|
||||
self._wash.gradient = ft.RadialGradient(center=ft.Alignment(-0.7, 1.0), radius=1.3,
|
||||
colors=[T.soft(tint, 0.18), T.soft(tint, 0.0)])
|
||||
self._backdrop.gradient = ft.LinearGradient(begin=ft.Alignment.TOP_CENTER, end=ft.Alignment.BOTTOM_CENTER,
|
||||
colors=[T.soft(tint, 0.08), T.soft(tint, 0.0)])
|
||||
|
||||
def _fill_tint(self) -> None:
|
||||
"""Background thread: compute a missing tint, then colour this page if it is still the one shown."""
|
||||
tint = thumbs.compute_tint(self.package)
|
||||
app = self.app
|
||||
if tint and app.route[0] == "game" and app.route[1:2] == (self.package,) and app.game_view is self:
|
||||
self._tint(tint)
|
||||
C.update(self._hero, self._backdrop)
|
||||
|
||||
def actions(self) -> ft.Control:
|
||||
app, g, pkg = self.app, self.g, self.package
|
||||
job = app.jobs.busy_with(pkg)
|
||||
if job:
|
||||
pct = (f" {job.fraction:.0%}" if job.fraction is not None else "") + \
|
||||
(f" · {job.speed.split(' · ')[0]}" if job.speed else "")
|
||||
# the install as a transit: the game crossing the portal; app._on_job updates it on every tick
|
||||
transit = C.Transit()
|
||||
transit.set(job, **app.transit_info(job))
|
||||
app.hero_transit = (pkg, transit)
|
||||
return ft.Row([
|
||||
ft.FilledButton(content=ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2),
|
||||
color=T.ON_ACCENT),
|
||||
ft.Text(f"{job.stage or 'Queued'}{pct}", color=T.ON_ACCENT,
|
||||
weight=ft.FontWeight.W_600)], spacing=T.px(10), tight=True),
|
||||
on_click=lambda e: app.show_activity(True),
|
||||
style=ft.ButtonStyle(bgcolor=T.ACCENT, shape=ft.RoundedRectangleBorder(radius=T.px(8)),
|
||||
padding=ft.Padding(T.px(22), T.px(18), T.px(22), T.px(18)))),
|
||||
ft.Container(transit.control, width=T.px(460), padding=ft.Padding(T.S4, T.S3, T.S4, T.S3),
|
||||
bgcolor=T.soft(T.SURFACE, 0.92), border=ft.Border.all(1, T.BORDER),
|
||||
border_radius=T.RADIUS_SM, ink=True, tooltip=tr("Activity"),
|
||||
on_click=lambda e: app.show_activity(True)),
|
||||
C.ghost(tr("Cancel"), ft.Icons.CLOSE_ROUNDED, lambda e: app.jobs.cancel(job)),
|
||||
], spacing=T.S2)
|
||||
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.END)
|
||||
app.hero_transit = None
|
||||
buttons: list[ft.Control] = []
|
||||
for i, (label, icon, handler, disabled, tip) in enumerate(app.play_options(g) + app.install_options(g)):
|
||||
buttons.append(C.primary(label, icon, handler, disabled, tip, big=True) if i == 0 else
|
||||
@@ -140,8 +165,8 @@ class GameView:
|
||||
lambda e: app.go("files", pkg), False,
|
||||
tr("Opens this player's storage on the Frame (Files tab): upload videos "
|
||||
"into the folder it lists")))
|
||||
more = ft.PopupMenuButton(icon=ft.Icons.MORE_HORIZ_ROUNDED, icon_color=T.TEXT_2, bgcolor=T.SURFACE_2,
|
||||
tooltip=tr("More actions"), items=C.menu_items(app.game_actions(pkg, quick=False)))
|
||||
more = C.menu_button(C.menu_items(app.game_actions(pkg, quick=False)), icon=ft.Icons.MORE_HORIZ_ROUNDED,
|
||||
icon_color=T.TEXT_2, tooltip=tr("More actions"))
|
||||
return ft.Row(buttons + [more], spacing=T.S2, wrap=True)
|
||||
|
||||
# ---------------------------------------------------------------- sections
|
||||
@@ -156,10 +181,10 @@ class GameView:
|
||||
return ", ".join(tr(base.get(i).title) if i in base.REGISTRY else i for i in ids)
|
||||
changed = "; ".join(filter(None, [tr("on: {names}").format(names=names(diff[0])) if diff[0] else "",
|
||||
tr("off: {names}").format(names=names(diff[1])) if diff[1] else ""]))
|
||||
frame_line = {"installed": "Installed",
|
||||
"outdated": (f"Installed · patch settings changed since ({changed}) — update to apply" if diff
|
||||
else "Installed · a newer build is ready"),
|
||||
"missing": "Not installed", None: "Frame not connected"}[st]
|
||||
frame_line = {"installed": tr("Installed"),
|
||||
"outdated": (tr("Installed · patch settings changed since ({changed}) — update to apply")
|
||||
.format(changed=changed) if diff else tr("Installed · a newer build is ready")),
|
||||
"missing": tr("Not installed"), None: tr("Frame not connected")}[st]
|
||||
frame_ok = st in ("installed", "outdated")
|
||||
# Oculus/LibOVR Rift games need Revive, which can't run on the Frame — be honest about it
|
||||
patches = (g.get("recipe") or {}).get("patches", {})
|
||||
@@ -178,15 +203,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,15 +219,15 @@ 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"),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Uninstall from this PC…"),
|
||||
lambda e: app.uninstall(pkg, "pc"))] if dep else []))
|
||||
return C.section(tr("Where it's installed"), ft.Row(cards, spacing=T.S3), help="where")
|
||||
|
||||
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),
|
||||
@@ -222,18 +247,18 @@ class GameView:
|
||||
"them. Look for a build of the app that includes them, then install that one."),
|
||||
T.TEXT),
|
||||
C.meta(", ".join(missing), T.TEXT_2, selectable=True)], spacing=T.px(4)), "error",
|
||||
ft.Icons.EXTENSION_OFF_ROUNDED))
|
||||
ft.Icons.EXTENSION_OFF_OUTLINED))
|
||||
if self.rift and g.get("exe_confirmed") is False:
|
||||
out.append(C.callout(ft.Row([
|
||||
C.body(tr("FramePort picked {value} to start this game, but there are other candidates. "
|
||||
"Check it before installing.").format(value=g.get('exe', '').rsplit('/', 1)[-1]),
|
||||
T.TEXT, expand=True),
|
||||
C.secondary(tr("Check"), ft.Icons.TERMINAL_ROUNDED, lambda e: self.app.choose_exe(pkg))]), "warn",
|
||||
C.secondary(tr("Check…"), ft.Icons.TERMINAL_ROUNDED, lambda e: self.app.choose_exe(pkg))]), "warn",
|
||||
ft.Icons.HELP_OUTLINE_ROUNDED))
|
||||
if self.rift and extra.get("platform_sdk"):
|
||||
out.append(C.callout(tr("Uses the Oculus Platform SDK: it checks your Oculus license. Normally that "
|
||||
"needs the Oculus app on this PC with a license you own, so it may quit right "
|
||||
"after starting on the headset. You can still try it."), "warn"))
|
||||
out.append(C.callout(tr("Uses the Oculus Platform SDK, which checks your Oculus license: it needs the "
|
||||
"Oculus app on this PC and may quit right after starting. You can still try it."),
|
||||
"warn"))
|
||||
installed = C.install_state(g, self.app.frame_info) in ("installed", "outdated")
|
||||
if should_ask_to_share(g, installed):
|
||||
def dismiss(e):
|
||||
@@ -257,12 +282,12 @@ class GameView:
|
||||
from ... import pipeline
|
||||
|
||||
pipeline.apply_catalog_update(pkg)
|
||||
self.app.toast(tr("New config applied: use Update on Frame to install it"))
|
||||
self.app.toast(tr("New recipe applied: use Update on Frame to install it"))
|
||||
self.app.render()
|
||||
out.append(C.callout(ft.Row([
|
||||
C.body(tr("A newer known-good config for this game is available (you changed this game's settings, "
|
||||
C.body(tr("A newer known-good recipe for this game is available (you changed this game's settings, "
|
||||
"so it wasn't applied by itself)."), T.TEXT, expand=True),
|
||||
C.secondary(tr("Use the new config"), ft.Icons.AUTO_FIX_HIGH_OUTLINED, take_update)],
|
||||
C.secondary(tr("Use the new recipe"), ft.Icons.AUTO_FIX_HIGH_ROUNDED, take_update)],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER), "info", ft.Icons.NEW_RELEASES_OUTLINED))
|
||||
if g.get("steam_art_stale") and installed:
|
||||
out.append(C.callout(ft.Row([
|
||||
@@ -282,8 +307,8 @@ class GameView:
|
||||
for r in counterparts(g, self.games):
|
||||
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,
|
||||
.format(value=tr("Rift") if other_rift else tr("Quest")),
|
||||
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))
|
||||
@@ -301,7 +326,7 @@ class GameView:
|
||||
if "details" in self.g:
|
||||
return None
|
||||
return C.section(tr("About this game"), C.card(ft.Row([
|
||||
ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT),
|
||||
C.spinner(),
|
||||
C.meta(tr("Looking up the store description and screenshots…"))], spacing=T.S2)))
|
||||
parts: list[ft.Control] = []
|
||||
if shots:
|
||||
@@ -332,15 +357,14 @@ class GameView:
|
||||
def more(e):
|
||||
state["open"] = not state["open"]
|
||||
body.value = text if state["open"] else short
|
||||
e.control.text = tr("Show less") if state["open"] else tr("Show more")
|
||||
e.control.content = tr("Show less") if state["open"] else tr("Show more")
|
||||
body.update()
|
||||
e.control.update()
|
||||
parts.append(ft.TextButton(tr("Show more"), on_click=more, style=ft.ButtonStyle(color=T.ACCENT)))
|
||||
parts.append(C.ghost(tr("Show more"), on_click=more, color=T.ACCENT))
|
||||
links = det.store_links(d)
|
||||
if links:
|
||||
parts.append(ft.Row([ft.TextButton(label, icon=ft.Icons.OPEN_IN_NEW_ROUNDED, url=url,
|
||||
style=ft.ButtonStyle(color=T.ACCENT)) for label, url in links],
|
||||
spacing=T.S2, wrap=True))
|
||||
parts.append(ft.Row([C.ghost(label, ft.Icons.OPEN_IN_NEW_ROUNDED, color=T.ACCENT, url=url)
|
||||
for label, url in links], spacing=T.S2, wrap=True))
|
||||
src = ", ".join({"oculusdb": "Meta store (OculusDB)", "steam": "Steam store"}.get(s, s)
|
||||
for s in d.get("sources") or [])
|
||||
return C.section(tr("About this game"), C.card(ft.Column(parts, spacing=T.S4)),
|
||||
@@ -359,13 +383,12 @@ class GameView:
|
||||
counter.value = f"{state['i'] + 1} / {len(shots)}"
|
||||
img.update()
|
||||
counter.update()
|
||||
page.show_dialog(ft.AlertDialog(
|
||||
content=ft.Container(ft.Column([img, ft.Row([
|
||||
page.show_dialog(C.viewer(
|
||||
ft.Container(ft.Column([img, ft.Row([
|
||||
C.icon_btn(ft.Icons.CHEVRON_LEFT_ROUNDED, tr("Previous"), lambda e: show(-1)), counter,
|
||||
C.icon_btn(ft.Icons.CHEVRON_RIGHT_ROUNDED, tr("Next"), lambda e: show(1)),
|
||||
ft.Container(expand=True), C.ghost(tr("Close"), on_click=lambda e: page.pop_dialog())])],
|
||||
spacing=T.S2, tight=True), width=T.px(1100)),
|
||||
bgcolor=T.BG, shape=ft.RoundedRectangleBorder(radius=T.RADIUS), content_padding=T.S3))
|
||||
spacing=T.S2, tight=True), width=T.px(1100))))
|
||||
|
||||
def tags(self) -> ft.Control:
|
||||
from .library import all_tags, auto_tags, normalize_tag, user_tags
|
||||
@@ -390,10 +413,8 @@ class GameView:
|
||||
else:
|
||||
e.control.update()
|
||||
|
||||
field = ft.TextField(hint_text=tr("Add a tag"), dense=True, width=T.px(150), text_size=T.T_META,
|
||||
border_radius=T.px(20), bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
|
||||
focused_border_color=T.ACCENT,
|
||||
content_padding=ft.Padding(T.px(12), T.px(6), T.px(12), T.px(6)), on_submit=add)
|
||||
field = C.field(hint_text=tr("Add a tag"), width=T.px(150), text_size=T.T_META, border_radius=T.px(20),
|
||||
content_padding=ft.Padding(T.px(12), T.px(6), T.px(12), T.px(6)), on_submit=add)
|
||||
|
||||
def fill():
|
||||
mine = user_tags(self.g)
|
||||
@@ -402,7 +423,7 @@ class GameView:
|
||||
spacing=T.px(4), tight=True),
|
||||
bgcolor=T.ACCENT_SOFT, border_radius=T.px(20),
|
||||
padding=ft.Padding(T.px(10), T.px(5), T.px(8), T.px(5)),
|
||||
on_click=lambda e, t=t: remove(t), tooltip=tr("Remove tag"))
|
||||
on_click=lambda e, t=t: remove(t), tooltip=tr("Remove tag"), ink=True)
|
||||
for t in mine]
|
||||
chips += [ft.Container(C.meta(t), border=ft.Border.all(1, T.BORDER), border_radius=T.px(20),
|
||||
padding=ft.Padding(T.px(10), T.px(5), T.px(10), T.px(5)),
|
||||
@@ -412,7 +433,8 @@ class GameView:
|
||||
suggestions = [t for t in all_tags(self.games) if t in used and t not in mine][:6]
|
||||
chips.append(field)
|
||||
chips += [ft.Container(C.meta("+ " + t, T.ACCENT), padding=ft.Padding(T.px(6), T.px(5), T.px(6), T.px(5)),
|
||||
on_click=lambda e, t=t: save(user_tags(self.g) + [t]), tooltip=tr("Add this tag"))
|
||||
on_click=lambda e, t=t: save(user_tags(self.g) + [t]), tooltip=tr("Add this tag"),
|
||||
border_radius=T.px(20), ink=True)
|
||||
for t in suggestions]
|
||||
row.controls = chips
|
||||
fill()
|
||||
@@ -451,7 +473,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:
|
||||
@@ -459,7 +481,7 @@ class GameView:
|
||||
ft.Icons.AUTO_AWESOME_ROUNDED, T.ACCENT
|
||||
chips = [ft.Container(C.body(tr(p.title), T.TEXT, size=T.T_META),
|
||||
tooltip=C.tip(tr(recipe.reasons.get(p.id) or p.description)),
|
||||
bgcolor=T.SURFACE_3, border_radius=T.px(6),
|
||||
bgcolor=T.SURFACE_3, border_radius=T.RADIUS_XS,
|
||||
padding=ft.Padding(T.px(10), T.px(5), T.px(10), T.px(5)))
|
||||
for p in visible]
|
||||
base_count = len(on) - len(visible)
|
||||
@@ -495,7 +517,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."))),
|
||||
@@ -606,10 +628,9 @@ class GameView:
|
||||
rows.append(ft.Container(ft.Row([
|
||||
ft.Column([C.body(tr(p.title), T.TEXT, weight=ft.FontWeight.W_500), *sub],
|
||||
spacing=T.px(3), expand=True),
|
||||
ft.Dropdown(value=current, width=T.px(280), dense=True, border_color=T.BORDER,
|
||||
text_size=T.T_BODY,
|
||||
options=[ft.DropdownOption(key=k, text=tr(label)) for k, label in choices],
|
||||
on_select=choose(p.id, p.params[0].key)),
|
||||
C.dropdown(value=current, width=T.px(280),
|
||||
options=[ft.DropdownOption(key=k, text=tr(label)) for k, label in choices],
|
||||
on_select=choose(p.id, p.params[0].key)),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S4, T.px(10), T.S4, T.px(10)), border=ft.Border(
|
||||
top=ft.BorderSide(1, T.BORDER))))
|
||||
continue
|
||||
@@ -617,15 +638,13 @@ class GameView:
|
||||
pass
|
||||
elif cat == "adapter":
|
||||
val = recipe.params(p.id).get("value", p.params[0].default)
|
||||
extra = ft.TextField(value=str(val), width=T.px(90), dense=True, text_size=T.T_BODY,
|
||||
border_color=T.BORDER, on_blur=set_value(p.id, p.params[0].kind))
|
||||
extra = C.field(value=str(val), width=T.px(90), on_blur=set_value(p.id, p.params[0].kind))
|
||||
elif p.params:
|
||||
q = p.params[0]
|
||||
multi = q.kind == "text"
|
||||
extra = ft.TextField(value=str(recipe.params(p.id).get(q.key, q.default) or ""), hint_text=q.help,
|
||||
width=T.px(260), dense=True, multiline=multi, min_lines=1,
|
||||
max_lines=4 if multi else 1, text_size=T.T_BODY, border_color=T.BORDER,
|
||||
on_blur=set_param(p.id, q.key))
|
||||
extra = C.field(value=str(recipe.params(p.id).get(q.key, q.default) or ""), hint_text=q.help,
|
||||
width=T.px(260), multiline=multi, min_lines=1, max_lines=4 if multi else 1,
|
||||
on_blur=set_param(p.id, q.key))
|
||||
rows.append(ft.Container(ft.Row([
|
||||
ft.Column([ft.Row([C.body(tr(p.title), T.TEXT, weight=ft.FontWeight.W_500)]
|
||||
+ ([C.pill(tr("experimental"), T.WARN, tooltip=C.tip(HELP["experimental"]))]
|
||||
@@ -682,7 +701,7 @@ class GameView:
|
||||
ft.Container(ft.Column(rows, spacing=T.S2), padding=ft.Padding(T.S4, 0, T.S4, T.S4))],
|
||||
bgcolor=T.SURFACE, collapsed_bgcolor=T.SURFACE)
|
||||
if self.rift:
|
||||
rows += [C.kv(tr("Folder"), g.get("game_dir") or ""), C.kv(tr("Executable"), g.get("exe") or ""),
|
||||
rows += [C.kv(tr("Folder"), g.get("game_dir") or ""), C.kv(tr("Program"), g.get("exe") or ""),
|
||||
C.kv(tr("Type"), f"{a['abis'][0]} · {a['graphics']}"),
|
||||
C.kv(tr("Size"), tr("{value:.1f} GiB")
|
||||
.format(value=(a.get('extra', {}).get('data_bytes') or 0) / 2**30))]
|
||||
@@ -706,6 +725,7 @@ class GameView:
|
||||
tr("It was removed from the library."),
|
||||
C.primary(tr("Back to library"), on_click=lambda e: self.app.go("library")))
|
||||
about = self.about()
|
||||
self._backdrop = ft.Container(left=0, right=0, top=0, height=T.px(560)) # the cover tint's page wash
|
||||
body = [self.hero(), *self.notes(), self.where(), *([about] if about else []), self.tags(),
|
||||
self.recipe_summary()]
|
||||
if self.advanced and not self.linux: # (a Linux app has no patches)
|
||||
@@ -716,7 +736,13 @@ class GameView:
|
||||
def remember(e):
|
||||
app.game_scroll = e.pixels
|
||||
col = ft.Column([ft.Container(ft.Column(body, spacing=T.S5), padding=ft.Padding(0, 0, T.S3, T.S6))],
|
||||
scroll=ft.ScrollMode.AUTO, expand=True, on_scroll=remember, scroll_interval=100)
|
||||
scroll=ft.ScrollMode.AUTO, on_scroll=remember, scroll_interval=100,
|
||||
left=0, right=0, top=0, bottom=0)
|
||||
app.game_view = self
|
||||
known, tint = thumbs.cached_tint(self.package)
|
||||
self._tint(tint)
|
||||
if not known:
|
||||
app.page.run_thread(self._fill_tint)
|
||||
offset = getattr(app, "game_scroll", 0.0)
|
||||
if offset > 0: # back to where the user was, once the new page is on screen
|
||||
def restore():
|
||||
@@ -725,7 +751,7 @@ class GameView:
|
||||
except Exception: # noqa: BLE001 - the page changed again meanwhile
|
||||
pass
|
||||
threading.Timer(0.08, restore).start()
|
||||
return col
|
||||
return ft.Stack([self._backdrop, col], expand=True)
|
||||
|
||||
|
||||
def _known(pid: str) -> bool:
|
||||
|
||||
@@ -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:
|
||||
@@ -39,18 +40,18 @@ class KeyboardView:
|
||||
on_click=lambda e: self.focus_keys())
|
||||
self.listener = ft.KeyboardListener(self.pad, autofocus=True, on_key_down=self._forward("down"),
|
||||
on_key_up=self._forward("up"), on_key_repeat=self._forward("repeat"))
|
||||
self.paste = ft.TextField(hint_text=tr("Or paste text to type it in one go"), expand=True, dense=True,
|
||||
border_color=T.BORDER, on_submit=self._send_text)
|
||||
self.paste = C.field(hint_text=tr("Or paste text to type it in one go"), expand=True,
|
||||
on_submit=self._send_text)
|
||||
self.reconnect = C.secondary(tr("Reconnect"), ft.Icons.REFRESH_ROUNDED, lambda e: self.start())
|
||||
|
||||
# ---------------------------------------------------------------- building
|
||||
def mount(self) -> ft.Control:
|
||||
app = self.app
|
||||
heading, sub = tr("Type on Frame"), tr("Use this computer's keyboard on the Frame")
|
||||
heading, sub = tr("Type on Frame"), tr("Use this PC's keyboard on the Frame")
|
||||
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:
|
||||
@@ -60,12 +61,12 @@ class KeyboardView:
|
||||
ft.Row([self.status, self.reconnect], spacing=T.S3,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
self.listener,
|
||||
ft.Row([self.paste, C.secondary(tr("Type it"), ft.Icons.SEND_ROUNDED, self._send_text)],
|
||||
ft.Row([self.paste, C.secondary(tr("Type it"), ft.Icons.SEND_OUTLINED, self._send_text)],
|
||||
spacing=T.S2),
|
||||
ft.Row([C.meta(tr("In the headset, select a text field, then type here."), T.TEXT_2),
|
||||
C.help_icon("type_on_frame", 16)], spacing=T.px(2), tight=True,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
], spacing=T.S3)),
|
||||
C.callout(tr("This computer's keyboard works as a keyboard on your Frame while this tab is open. In "
|
||||
"the headset, select a text field (in a game or app, in Steam or on the desktop), then "
|
||||
"type here.")),
|
||||
], spacing=T.S4, horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
|
||||
self.start()
|
||||
return self.root
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -140,6 +141,57 @@ def filter_games(games: list[dict], f: dict, frame_info: dict | None = None,
|
||||
return sorted(out, key=key)
|
||||
|
||||
|
||||
# type to search: Flet sends Flutter's key labels ("A" for a and A, "1", " ", "-", "Arrow Left", "Numpad 1", "F5"),
|
||||
# not the typed character, so shifted symbols follow the US layout (what the label alone can tell)
|
||||
_SHIFTED = dict(zip("1234567890-=[];'`\\,./", "!@#$%^&*()_+{}:\"~|<>?", strict=True))
|
||||
_NAMED_CHARS = {"space": " ", **{f"numpad{d}": d for d in "0123456789"}, "numpadadd": "+",
|
||||
"numpadsubtract": "-", "numpadmultiply": "*", "numpaddivide": "/", "numpaddecimal": "."}
|
||||
|
||||
|
||||
def typed_char(key: str, shift: bool = False, ctrl: bool = False, alt: bool = False,
|
||||
meta: bool = False) -> str | None:
|
||||
"""The character a key press types (for the Library's type-to-search), None for shortcuts (Ctrl/Alt/Meta held),
|
||||
modifiers, navigation, function and other named keys."""
|
||||
if not key or ctrl or alt or meta:
|
||||
return None
|
||||
if len(key) == 1:
|
||||
if key.isalpha():
|
||||
return key.upper() if shift else key.lower()
|
||||
if shift and key in _SHIFTED:
|
||||
return _SHIFTED[key]
|
||||
return key if key.isprintable() else None
|
||||
return _NAMED_CHARS.get(key.lower().replace(" ", "").replace("_", ""))
|
||||
|
||||
|
||||
def should_capture(route: str, dialog_open: bool, field_focused: bool, char: str | None, query: str = "") -> bool:
|
||||
"""Whether a typed character goes to the Library's search: only on the Library itself, with no dialog open and
|
||||
the search not already focused (it types there itself); a leading space is ignored (Space on a focused button)."""
|
||||
return (route == "library" and not dialog_open and not field_focused and char is not None
|
||||
and not (char == " " and not query))
|
||||
|
||||
|
||||
SHELF_MAX = 8
|
||||
|
||||
|
||||
def filters_active(f: dict) -> bool:
|
||||
"""A search or any filter other than the defaults (the sort order isn't a filter)."""
|
||||
return bool((f.get("q") or "").strip() or f.get("tags")) or any(
|
||||
f.get(k, DEFAULT_FILTERS[k]) != DEFAULT_FILTERS[k] for k in ("where", "platform", "status"))
|
||||
|
||||
|
||||
def shelf_games(games: list[dict], f: dict, frame_info: dict | None, connected: bool, select_mode: bool = False,
|
||||
limit: int = SHELF_MAX) -> list[dict]:
|
||||
"""The "On your Frame" shelf: games installed on the connected Frame (outdated ones too), most recently used
|
||||
first, at most `limit`. Empty (the shelf hides) without a connected Frame and while searching or filtering. It
|
||||
stays in select mode (`select_mode` is ignored): hiding it when a drag-select starts moved the grid up under the
|
||||
mouse, and the drag selected the wrong cards."""
|
||||
if not connected or filters_active(f):
|
||||
return []
|
||||
on = [g for g in games if C.install_state(g, frame_info) in ("installed", "outdated")]
|
||||
on.sort(key=lambda g: (-last_used(g), (g.get("title") or g["package"]).lower()))
|
||||
return on[:limit]
|
||||
|
||||
|
||||
def load_filters() -> dict:
|
||||
saved = library.setting("ui.library") or {}
|
||||
return {**DEFAULT_FILTERS, "tags": [], **{k: v for k, v in saved.items() if k in DEFAULT_FILTERS and k != "q"}}
|
||||
@@ -152,6 +204,7 @@ def save_filters(f: dict) -> None:
|
||||
# ------------------------------------------------------------------------------------------ view
|
||||
BATCH = 8
|
||||
CARD_ART = ("portrait", "square", "cover", "icon") # store art first; cover = FramePort's own (no store art)
|
||||
SHELF_ART = ("landscape", "hero", "banner", "portrait", "square", "cover", "icon") # wide first
|
||||
def card_shadow(hover: bool = False) -> ft.BoxShadow:
|
||||
"""Library cards float on the dark background; hovering lifts them further."""
|
||||
if hover:
|
||||
@@ -173,12 +226,22 @@ class LibraryView:
|
||||
self.f = app.lib_filters
|
||||
self.cards: dict[str, tuple[tuple, ft.Control]] = {} # package -> (state key, card)
|
||||
self.checks: dict[str, ft.Control] = {} # package -> selection checkbox overlay
|
||||
self.marks: dict[str, tuple] = {} # package -> (card tile, selection fade overlay, hovered?)
|
||||
self.selected: set[str] = set()
|
||||
self.select_mode = False
|
||||
self.sel_bar = ft.Container(visible=False)
|
||||
self.resume_bar = ft.Container(visible=False)
|
||||
self.update_bar = ft.Container(visible=False) # "FramePort x.y is available" (ui/updater.py)
|
||||
self.games: list[dict] = []
|
||||
self.tw: set[str] = set()
|
||||
# "On your Frame": a row of wide cards above the grid (shelf_games); cards are kept per game and only rebuilt
|
||||
# when what they show changes
|
||||
self.shelf_cards: dict[str, tuple[tuple, ft.Control]] = {}
|
||||
self.shelf_row = ft.Row(spacing=T.S4, scroll=ft.ScrollMode.AUTO)
|
||||
self.shelf = ft.Container(ft.Column([
|
||||
C.h2(tr("On your Frame")),
|
||||
ft.Container(self.shelf_row, padding=ft.Padding(0, 0, 0, T.S2)),
|
||||
], spacing=T.S3, tight=True), visible=False)
|
||||
self._lock = threading.Lock()
|
||||
self._search_timer: threading.Timer | None = None
|
||||
self._gen = 0
|
||||
@@ -186,12 +249,11 @@ class LibraryView:
|
||||
run_spacing=T.S4, padding=ft.Padding(0, T.S2, T.S2, T.S5))
|
||||
self.count = C.meta("")
|
||||
self.subtitle = C.body("", max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
|
||||
self.search = ft.TextField(
|
||||
value=self.f["q"], hint_text=tr("Search games and tags"), prefix_icon=ft.Icons.SEARCH_ROUNDED, dense=True,
|
||||
width=T.px(260), border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
|
||||
focused_border_color=T.ACCENT, content_padding=ft.Padding(T.px(12), T.px(8), T.px(12), T.px(8)),
|
||||
text_size=T.T_BODY,
|
||||
on_change=self._on_search)
|
||||
# typing anywhere on the Library goes here (app._on_key); search_focused tracks where keys already land
|
||||
self.search_focused = False
|
||||
self.search = C.search(value=self.f["q"], hint_text=tr("Type to search games and tags"), width=T.px(260),
|
||||
on_change=self._on_search, on_focus=lambda e: self._focus(True),
|
||||
on_blur=lambda e: self._focus(False))
|
||||
# an X that clears the search at once (shown only while there is text)
|
||||
# a small clickable icon, not an IconButton: its 40 px minimum size made the field taller and pushed the
|
||||
# text off-centre
|
||||
@@ -208,33 +270,22 @@ class LibraryView:
|
||||
lambda pkg, on: self.toggle_selected(pkg) if (pkg in self.selected) != on else None,
|
||||
can_select=lambda pkg: pkg in self.cards,
|
||||
on_start=lambda: None if self.select_mode else self.set_select_mode(True))
|
||||
self.menu = ft.ContextMenu(content=ft.GestureDetector(content=self.grid, expand=True,
|
||||
on_pan_start=self.drag.start, on_pan_end=self.drag.end),
|
||||
secondary_trigger=None, tertiary_trigger=None, expand=True)
|
||||
self.menu = ft.ContextMenu(content=ft.Column([
|
||||
self.shelf,
|
||||
ft.GestureDetector(content=self.grid, expand=True, on_pan_start=self.drag.start, on_pan_end=self.drag.end),
|
||||
], spacing=T.S4, expand=True), secondary_trigger=None, tertiary_trigger=None, expand=True)
|
||||
self.body = ft.Container(self.menu, expand=True)
|
||||
add = ft.PopupMenuButton(
|
||||
content=ft.Container(ft.Row([ft.Icon(ft.Icons.ADD_ROUNDED, color=T.ON_ACCENT, size=T.px(18)),
|
||||
ft.Text(tr("Add games"), color=T.ON_ACCENT, weight=ft.FontWeight.W_600,
|
||||
size=T.px(13))],
|
||||
spacing=T.px(6), tight=True),
|
||||
bgcolor=T.ACCENT, border_radius=T.RADIUS_SM,
|
||||
padding=ft.Padding(T.px(14), T.px(9), T.px(16), T.px(9))),
|
||||
items=[ft.PopupMenuItem(content=ft.Text(tr("Scan a folder…")), icon=ft.Icons.FOLDER_OPEN_ROUNDED,
|
||||
on_click=app.pick_folder),
|
||||
ft.PopupMenuItem(content=ft.Text(tr("Add one game folder…")),
|
||||
icon=ft.Icons.CREATE_NEW_FOLDER_ROUNDED, on_click=app.pick_game_folder),
|
||||
ft.PopupMenuItem(content=ft.Text(tr("Add an APK file…")), icon=ft.Icons.ANDROID_ROUNDED,
|
||||
on_click=app.pick_apk),
|
||||
ft.PopupMenuItem(content=ft.Text(tr("Add a Windows program (.exe)…")),
|
||||
icon=ft.Icons.DESKTOP_WINDOWS_ROUNDED, on_click=app.pick_windows_exe),
|
||||
ft.PopupMenuItem(content=ft.Text(tr("Install from a link…")), icon=ft.Icons.LINK_ROUNDED,
|
||||
on_click=app.pick_link),
|
||||
ft.PopupMenuItem(), # divider: native Linux apps (GitHub #31)
|
||||
ft.PopupMenuItem(content=ft.Text(tr("Add a Linux app…")), icon=ft.Icons.TERMINAL_ROUNDED,
|
||||
on_click=app.pick_linux_app),
|
||||
ft.PopupMenuItem(content=ft.Text(tr("Add a Linux app folder…")), icon=ft.Icons.FOLDER_ROUNDED,
|
||||
on_click=app.pick_linux_folder)],
|
||||
bgcolor=T.SURFACE_2, tooltip="")
|
||||
add = C.primary_menu(tr("Add games"), ft.Icons.ADD_ROUNDED, C.menu_items([
|
||||
(tr("Scan a folder…"), ft.Icons.FOLDER_OPEN_ROUNDED, app.pick_folder),
|
||||
(tr("Add a PC game folder…"), G.PC, app.pick_game_folder),
|
||||
(tr("Add an APK…"), ft.Icons.ANDROID_ROUNDED, app.pick_apk),
|
||||
(tr("Add a Windows program…"), ft.Icons.WEB_ASSET_ROUNDED, app.pick_windows_exe),
|
||||
(tr("Add from a link…"), ft.Icons.LINK_ROUNDED, app.pick_link),
|
||||
None, # native Linux apps (GitHub #31)
|
||||
(tr("Add a Linux app…"), ft.Icons.TERMINAL_ROUNDED, app.pick_linux_app),
|
||||
(tr("Add a Linux app folder…"), ft.Icons.FOLDER_OUTLINED, app.pick_linux_folder),
|
||||
None,
|
||||
(tr("Rescan folders"), ft.Icons.REFRESH_ROUNDED, app.rescan)]))
|
||||
self.rescan_btn = C.secondary(tr("Rescan folders"), ft.Icons.REFRESH_ROUNDED, app.rescan,
|
||||
tooltip=C.tip(HELP["rescan"]))
|
||||
self.update_all_btn = C.secondary(tr("Update all"), ft.Icons.SYSTEM_UPDATE_ALT_ROUNDED,
|
||||
@@ -293,18 +344,17 @@ class LibraryView:
|
||||
games = library.games()
|
||||
self.games = games
|
||||
pc = set(local_installs())
|
||||
tw = twins(games)
|
||||
tw = self.tw = twins(games)
|
||||
self._update_header(games, pc)
|
||||
self.update_update_bar()
|
||||
self.update_resume_bar() # also after Dismiss / Resume and when installs finish or fail
|
||||
if not games:
|
||||
self.body.content = C.empty_state(
|
||||
ft.Icons.LIBRARY_ADD_ROUNDED, tr("Add your games"),
|
||||
tr("Point FramePort at a folder with Android games (APK + OBB, e.g. Quest games) or PC VR games "
|
||||
"(one folder per game, or a folder of them). It finds them, works out what each needs and "
|
||||
"fetches artwork."),
|
||||
C.primary(tr("Scan a folder"), ft.Icons.FOLDER_OPEN_ROUNDED, self.app.pick_folder, big=True),
|
||||
C.secondary(tr("Add an APK file"), ft.Icons.ANDROID_ROUNDED, self.app.pick_apk))
|
||||
ft.Icons.LIBRARY_ADD_OUTLINED, tr("Add your games"),
|
||||
tr("Point FramePort at a folder with Quest or Android games (APK + OBB) or PC VR games. It "
|
||||
"finds them, works out what each needs and fetches artwork."),
|
||||
C.primary(tr("Scan a folder…"), ft.Icons.FOLDER_OPEN_ROUNDED, self.app.pick_folder, big=True),
|
||||
C.secondary(tr("Add an APK file…"), ft.Icons.ANDROID_ROUNDED, self.app.pick_apk))
|
||||
self.cards.clear()
|
||||
C.update(self.root)
|
||||
return
|
||||
@@ -341,8 +391,11 @@ class LibraryView:
|
||||
for pkg, chk in self.checks.items():
|
||||
chk.visible = on
|
||||
chk.content.value = pkg in self.selected
|
||||
for pkg in self.marks:
|
||||
self._paint_card(pkg)
|
||||
self._update_sel_bar()
|
||||
C.update(self.grid, self.sel_bar, self.select_btn)
|
||||
self._update_shelf()
|
||||
C.update(self.grid, self.sel_bar, self.select_btn, self.shelf)
|
||||
|
||||
def toggle_selected(self, pkg: str) -> None:
|
||||
self.selected.symmetric_difference_update({pkg})
|
||||
@@ -350,8 +403,19 @@ class LibraryView:
|
||||
if chk:
|
||||
chk.content.value = pkg in self.selected
|
||||
C.update(chk)
|
||||
if pkg in self.marks:
|
||||
self._paint_card(pkg)
|
||||
C.update(self.marks[pkg][0])
|
||||
self._update_sel_bar()
|
||||
|
||||
def _paint_card(self, pkg: str) -> None:
|
||||
"""A card's border + selection fade (properties only): selected = 2 px accent + the portal fade, hovered =
|
||||
1 px accent, else the plain border."""
|
||||
tile, overlay, hovered = self.marks[pkg]
|
||||
on = self.select_mode and pkg in self.selected
|
||||
C.selected_style(overlay, on, subtle=True)
|
||||
tile.border = ft.Border.all(2 if on else 1, T.ACCENT if on or hovered else T.BORDER)
|
||||
|
||||
def select_visible(self) -> None:
|
||||
shown = filter_games(self.games, self.f, self.app.frame_info, set(self.app.pc_installs()))
|
||||
self.selected |= {g["package"] for g in shown if g["package"] in self.cards}
|
||||
@@ -372,11 +436,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")),
|
||||
@@ -421,8 +485,36 @@ class LibraryView:
|
||||
C.update(self.search)
|
||||
self._apply(update=True)
|
||||
|
||||
def _focus(self, on: bool) -> None:
|
||||
self.search_focused = on
|
||||
|
||||
def type_key(self, char: str | None, backspace: bool = False) -> None:
|
||||
"""A key typed while the search isn't focused (app._on_key): add it (or remove the last character), filter
|
||||
like typing in the field, and focus the field so the next keys go there directly."""
|
||||
value = self.search.value or ""
|
||||
self.search.value = value[:-1] if backspace else value + (char or "")
|
||||
self.set_query(self.search.value)
|
||||
C.update(self.search)
|
||||
try:
|
||||
self.app.page.run_task(self._focus_at_end)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
async def _focus_at_end(self) -> None:
|
||||
await self.search.focus()
|
||||
n = len(self.search.value or "")
|
||||
try: # Flutter selects nothing on a programmatic focus; put the cursor after the typed text
|
||||
self.search.selection = ft.TextSelection(base_offset=n, extent_offset=n)
|
||||
C.update(self.search)
|
||||
except Exception: # noqa: BLE001 (older Flet: no selection property; the cursor goes to the end anyway)
|
||||
pass
|
||||
|
||||
def _on_search(self, e):
|
||||
self.f["q"] = e.control.value
|
||||
self.set_query(e.control.value)
|
||||
|
||||
def set_query(self, value: str) -> None:
|
||||
"""The search text changed: show/hide the clear X, filter after a short pause (debounced)."""
|
||||
self.f["q"] = value
|
||||
if self.search.suffix.visible != bool(self.f["q"]):
|
||||
self.search.suffix.visible = bool(self.f["q"])
|
||||
C.update(self.search)
|
||||
@@ -446,8 +538,75 @@ class LibraryView:
|
||||
self.grid.controls = [self.cards[g["package"]][1] for g in shown if g["package"] in self.cards]
|
||||
n = len(self.games)
|
||||
self.count.value = tr("Showing {len} of {n}").format(len=len(shown), n=n) if len(shown) != n else ""
|
||||
self._update_shelf()
|
||||
if update:
|
||||
C.update(self.grid, self.count)
|
||||
C.update(self.grid, self.count, self.shelf)
|
||||
|
||||
# ---------------------------------------------------------------- "On your Frame" shelf
|
||||
def _update_shelf(self) -> None:
|
||||
"""Show the shelf's games (properties + reused cards; a card is rebuilt only when what it shows changed)."""
|
||||
app = self.app
|
||||
games = shelf_games(self.games, self.f, app.frame_info, app.frame_state == "connected", self.select_mode)
|
||||
controls = []
|
||||
for g in games:
|
||||
pkg = g["package"]
|
||||
key = (display_title(g, self.tw), C.install_state(g, app.frame_info), bool(app.jobs.busy_with(pkg)),
|
||||
thumbs.url(pkg, SHELF_ART, T.px(640), wait=False))
|
||||
old = self.shelf_cards.get(pkg)
|
||||
if not old or old[0] != key:
|
||||
old = self.shelf_cards[pkg] = (key, self.shelf_card(g, *key))
|
||||
controls.append(old[1])
|
||||
for pkg in set(self.shelf_cards) - {g["package"] for g in games}:
|
||||
self.shelf_cards.pop(pkg, None)
|
||||
self.shelf_row.controls = controls
|
||||
self.shelf.visible = bool(controls)
|
||||
|
||||
def shelf_card(self, g: dict, title: str, state: str | None, busy: bool, art: str | None) -> ft.Control:
|
||||
"""A wide cover: title, "Update ready" when the Frame runs an older build, Play on hover; click opens the
|
||||
game, right-click shows its menu (the same as the grid's)."""
|
||||
app = self.app
|
||||
pkg = g["package"]
|
||||
w, h = T.px(264), T.px(148)
|
||||
play = ft.Container(
|
||||
ft.Icon(ft.Icons.PLAY_ARROW_ROUNDED, size=T.px(34), color=T.ON_ACCENT),
|
||||
width=T.px(60), height=T.px(60), border_radius=T.px(30), bgcolor=T.ACCENT, alignment=ft.Alignment.CENTER,
|
||||
shadow=ft.BoxShadow(blur_radius=20, spread_radius=1, color=T.soft("#000000", 0.6), offset=ft.Offset(0, 4)),
|
||||
tooltip=ft.Tooltip(message=tr("Play on Frame"), wait_duration=800), ink=True,
|
||||
on_click=lambda e: app.play(pkg, "frame"),
|
||||
scale=0.85, animate_scale=ft.Animation(160, ft.AnimationCurve.EASE_OUT))
|
||||
quick = ft.Container(play, left=0, right=0, top=0, bottom=T.px(36), alignment=ft.Alignment.CENTER, opacity=0,
|
||||
animate_opacity=ft.Animation(160, ft.AnimationCurve.EASE_OUT), visible=not busy)
|
||||
badges = [C.install_badge("outdated")] if state == "outdated" else []
|
||||
if busy:
|
||||
badges = [C.pill(tr("Working…"), T.ACCENT, ft.Icons.SYNC_ROUNDED, solid=True)]
|
||||
tile = ft.Container(
|
||||
ft.Stack([
|
||||
C.art_fill(art, left=0, right=0, top=0, bottom=0, placeholder_icon=C.platform_icon(g)),
|
||||
ft.Container(C.bottom_fade(None, 0.92), left=0, right=0, bottom=0, top=T.px(48)),
|
||||
*([ft.Container(ft.Row(badges, spacing=T.px(4)), left=T.px(10), top=T.px(10))] if badges else []),
|
||||
quick,
|
||||
ft.Container(ft.Text(title, size=T.px(14), weight=ft.FontWeight.W_700, color=T.TEXT, max_lines=1,
|
||||
overflow=ft.TextOverflow.ELLIPSIS),
|
||||
left=T.px(12), right=T.px(12), bottom=T.px(10)),
|
||||
]),
|
||||
width=w, height=h, border_radius=T.RADIUS, bgcolor=T.SURFACE, border=ft.Border.all(1, T.BORDER),
|
||||
shadow=card_shadow(), scale=1.0, animate_scale=ft.Animation(140, ft.AnimationCurve.EASE_OUT),
|
||||
tooltip=ft.Tooltip(message=tr("Click to open · right-click for quick actions"), wait_duration=1500),
|
||||
on_click=lambda e: app.open_game(pkg))
|
||||
|
||||
def hover(e):
|
||||
on = e.data in (True, "true")
|
||||
tile.scale = 1.03 if on else 1.0
|
||||
tile.shadow = card_shadow(on)
|
||||
tile.border = ft.Border.all(1, T.ACCENT if on else T.BORDER)
|
||||
quick.opacity = 1 if on else 0
|
||||
play.scale = 1.0 if on else 0.85
|
||||
tile.update()
|
||||
tile.on_hover = hover
|
||||
# (padding: room for the hover lift and shadow inside the scrolling row, which clips)
|
||||
return ft.Container(ft.GestureDetector(content=tile,
|
||||
on_secondary_tap_down=lambda e: self.open_menu(pkg, e.global_position)),
|
||||
padding=ft.Padding(T.px(2), T.px(6), T.px(2), T.px(8)))
|
||||
|
||||
# ---------------------------------------------------------------- header / filter bar
|
||||
def _update_hint(self):
|
||||
@@ -456,7 +615,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
|
||||
@@ -503,28 +662,18 @@ class LibraryView:
|
||||
C.update(self.subtitle, self.hint, self.filters)
|
||||
|
||||
def _seg(self, key: str, options: list[tuple[str, str]]) -> ft.Control:
|
||||
items = []
|
||||
for value, label in options:
|
||||
on = self.f.get(key) == value
|
||||
items.append(ft.Container(
|
||||
ft.Text(label, size=T.T_META, weight=ft.FontWeight.W_600, color=T.TEXT if on else T.TEXT_2),
|
||||
padding=ft.Padding(T.px(12), T.px(6), T.px(12), T.px(6)), border_radius=T.px(20),
|
||||
bgcolor=T.SURFACE_3 if on else None,
|
||||
on_click=lambda e, v=value: self._set(key, v), ink=True))
|
||||
return ft.Container(ft.Row(items, spacing=T.px(2), tight=True), padding=T.px(3), border_radius=T.px(22),
|
||||
border=ft.Border.all(1, T.BORDER))
|
||||
return C.segmented(options, self.f.get(key), lambda v: self._set(key, v))
|
||||
|
||||
def _menu_chip(self, key: str, label: str, options: list[tuple[str, str]]) -> ft.Control:
|
||||
current = dict(options).get(self.f.get(key), options[0][1])
|
||||
return ft.PopupMenuButton(
|
||||
return C.menu_button(
|
||||
content=ft.Container(ft.Row([C.meta(label + ":"), C.body(current, T.TEXT, size=T.T_META),
|
||||
ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED, size=T.px(16), color=T.TEXT_2)],
|
||||
spacing=T.px(4), tight=True),
|
||||
padding=ft.Padding(T.px(12), T.px(7), T.px(8), T.px(7)), border_radius=T.px(20),
|
||||
border=ft.Border.all(1, T.BORDER)),
|
||||
items=[ft.PopupMenuItem(content=ft.Text(text), checked=self.f.get(key) == value,
|
||||
on_click=lambda e, v=value: self._set(key, v)) for value, text in options],
|
||||
bgcolor=T.SURFACE_2, tooltip="")
|
||||
items=[C.check_item(text, self.f.get(key) == value, lambda e, v=value: self._set(key, v))
|
||||
for value, text in options])
|
||||
|
||||
def _toggle_tag(self, tag: str):
|
||||
tags = list(self.f.get("tags") or [])
|
||||
@@ -534,13 +683,13 @@ class LibraryView:
|
||||
def _tag_menu(self, games: list[dict]) -> ft.Control:
|
||||
chosen = self.f.get("tags") or []
|
||||
label = ", ".join(chosen) if chosen else tr("Any")
|
||||
items = [ft.PopupMenuItem(content=ft.Text(t), checked=t in chosen, on_click=lambda e, t=t: self._toggle_tag(t))
|
||||
for t in all_tags(games)]
|
||||
items = [C.check_item(t, t in chosen, lambda e, t=t: self._toggle_tag(t)) for t in all_tags(games)]
|
||||
if chosen:
|
||||
items.append(ft.PopupMenuItem(content=ft.Text(tr("Clear tags")), icon=ft.Icons.CLEAR_ROUNDED,
|
||||
on_click=lambda e: self._set("tags", [])))
|
||||
return ft.PopupMenuButton(
|
||||
content=ft.Container(ft.Row([ft.Icon(ft.Icons.SELL_OUTLINED, size=T.px(14), color=T.TEXT_2),
|
||||
items += C.menu_items([None, (tr("Clear tags"), ft.Icons.CLOSE_ROUNDED, lambda e: self._set("tags", []))])
|
||||
return C.menu_button(
|
||||
content=ft.Container(ft.Row([ft.Container(ft.Icon(ft.Icons.SELL_OUTLINED, size=T.px(14), color=T.TEXT_2),
|
||||
width=T.px(16), height=T.px(16),
|
||||
alignment=ft.Alignment.CENTER), # sized: overlapped the label
|
||||
C.meta(tr("Tags:")),
|
||||
C.body(label if len(label) < 28
|
||||
else tr("{len} selected").format(len=len(chosen)), T.TEXT,
|
||||
@@ -549,7 +698,7 @@ class LibraryView:
|
||||
spacing=T.px(4), tight=True),
|
||||
padding=ft.Padding(T.px(12), T.px(7), T.px(8), T.px(7)), border_radius=T.px(20),
|
||||
border=ft.Border.all(1, T.ACCENT if chosen else T.BORDER)),
|
||||
items=items, bgcolor=T.SURFACE_2, tooltip=C.tip(HELP["tags"]))
|
||||
items=items, tooltip=C.tip(HELP["tags"]))
|
||||
|
||||
# ---------------------------------------------------------------- cards
|
||||
def _key(self, g: dict, pc: set[str], tw: set[str]) -> tuple:
|
||||
@@ -577,16 +726,17 @@ class LibraryView:
|
||||
if on_pc:
|
||||
badges.append(C.install_badge("on_pc"))
|
||||
if rift and g.get("exe_confirmed") is False:
|
||||
badges.append(C.pill(tr("Check exe"), T.WARN, ft.Icons.HELP_OUTLINE_ROUNDED, overlay=True,
|
||||
badges.append(C.pill(tr("Check program"), T.WARN, ft.Icons.HELP_OUTLINE_ROUNDED, overlay=True,
|
||||
tooltip=C.tip(HELP["check_exe"])))
|
||||
label, _, help_key = C.platform(g)
|
||||
platform = C.pill(label, T.PC if rift else T.TEXT, C.platform_icon(g), overlay=True,
|
||||
tooltip=C.tip(HELP[help_key]))
|
||||
check = ft.Container(ft.Checkbox(value=pkg in self.selected, active_color=T.ACCENT, check_color=T.ON_ACCENT,
|
||||
on_change=lambda e: self.toggle_selected(pkg)),
|
||||
bgcolor=T.soft("#000000", 0.6), border_radius=T.px(8), left=T.px(6), top=T.px(40),
|
||||
bgcolor=T.soft("#000000", 0.6), border_radius=T.RADIUS_SM, left=T.px(6), top=T.px(40),
|
||||
visible=self.select_mode)
|
||||
self.checks[pkg] = check
|
||||
overlay = ft.Container(left=0, right=0, top=0, bottom=0, border_radius=T.RADIUS) # the selected fade
|
||||
quick_label, quick_kind = app.quick_action(g)
|
||||
circle = ft.Container(
|
||||
ft.Icon(quick_icon(quick_kind), size=T.px(56), color=T.ON_ACCENT),
|
||||
@@ -608,6 +758,7 @@ class LibraryView:
|
||||
# platform + state badges in one row that wraps: on a narrow card "On Frame" covered "Android"
|
||||
ft.Container(ft.Row([platform, *badges], spacing=T.px(4), run_spacing=T.px(4), wrap=True),
|
||||
left=T.px(10), right=T.px(10), top=T.px(10)),
|
||||
overlay,
|
||||
*([quick] if quick else []),
|
||||
check,
|
||||
ft.Container(ft.Column([
|
||||
@@ -628,13 +779,16 @@ class LibraryView:
|
||||
on = e.data in (True, "true")
|
||||
self.drag.hover(pkg, on)
|
||||
tile.scale = 1.03 if on else 1.0
|
||||
tile.border = ft.Border.all(1, T.ACCENT if on else T.BORDER)
|
||||
self.marks[pkg] = (tile, overlay, on)
|
||||
self._paint_card(pkg)
|
||||
tile.shadow = card_shadow(on)
|
||||
if quick:
|
||||
quick.opacity = 1 if on and not self.select_mode else 0
|
||||
circle.scale = 1.0 if on else 0.85
|
||||
tile.update()
|
||||
tile.on_hover = hover
|
||||
self.marks[pkg] = (tile, overlay, False)
|
||||
self._paint_card(pkg)
|
||||
# no key=: Flet freezes keyed controls, and cards change in place (hover, state); the grid only ever holds
|
||||
# the matching cards in order (see _apply), which is what fixed search and sorting
|
||||
return ft.GestureDetector(content=tile, expand=True,
|
||||
|
||||
@@ -25,8 +25,8 @@ def kind_label(kind: str | None) -> str:
|
||||
|
||||
def show_paste_dialog(app: FramePortApp) -> None:
|
||||
"""Add games → "Install from link…": paste a FrameDrop/FramePort button link, a manifest or a file URL."""
|
||||
field = ft.TextField(label=tr("Link"), hint_text="https://framedropvr.com/install?manifest=…", autofocus=True,
|
||||
width=T.px(560), border_color=T.BORDER)
|
||||
field = C.field(label=tr("Link"), hint_text="https://framedropvr.com/install?manifest=…", autofocus=True,
|
||||
expand=True)
|
||||
|
||||
def go(e=None):
|
||||
text = (field.value or "").strip()
|
||||
@@ -35,14 +35,14 @@ def show_paste_dialog(app: FramePortApp) -> None:
|
||||
app.page.pop_dialog()
|
||||
open_link(app, text, pasted=True)
|
||||
field.on_submit = go
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(tr("Install from a link")), bgcolor=T.SURFACE_2,
|
||||
content=ft.Column([
|
||||
app.page.show_dialog(C.dialog(
|
||||
tr("Install from a link"),
|
||||
ft.Column([
|
||||
C.body(tr("Paste the address of an \"Install with FrameDrop\" button (right-click it → Copy link), a "
|
||||
"FramePort or FrameDrop manifest (.json) or a direct link to an APK, a Linux build (.zip) or a "
|
||||
"Windows program (.exe).")),
|
||||
ft.Row([field, C.help_icon("install_links")]),
|
||||
], tight=True, spacing=T.S3, width=T.px(600)),
|
||||
], tight=True, spacing=T.S3),
|
||||
actions=[C.ghost(tr("Cancel"), on_click=lambda e: app.page.pop_dialog()),
|
||||
C.primary(tr("Continue"), ft.Icons.ARROW_FORWARD_ROUNDED, on_click=go)]))
|
||||
|
||||
@@ -57,7 +57,7 @@ def open_link(app: FramePortApp, text: str, pasted: bool = False) -> None:
|
||||
return
|
||||
|
||||
def run(job: Job):
|
||||
job.reporter.stage("Reading the install link")
|
||||
job.reporter.stage(tr("Reading the install link"))
|
||||
m = deeplink.fetch_manifest(req)
|
||||
sizes = {f.url: deeplink.head_size(f.url) for f in m.files}
|
||||
icon = deeplink.fetch_icon(m) # FramePort's manifest extension (None without one)
|
||||
@@ -122,7 +122,7 @@ def download_and_install(app: FramePortApp, m: deeplink.Manifest, icon=None) ->
|
||||
def run(job: Job):
|
||||
rep = job.reporter
|
||||
path = deeplink.download(m, rep)
|
||||
rep.stage("Adding to the library")
|
||||
rep.stage(tr("Adding to the library"))
|
||||
g = pipeline.add_from_link(m, path, rep, icon=icon)
|
||||
pkg = g["package"]
|
||||
try:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,13 +70,14 @@ class LiveView:
|
||||
self._busy = False
|
||||
self._ticker: threading.Thread | None = None
|
||||
self.root = None
|
||||
self.idle = None # the "start the live view" hint below the bar while not streaming
|
||||
self.dot = C.dot(T.TEXT_3, 10)
|
||||
self.state = C.body(tr("Not streaming"), T.TEXT, weight=ft.FontWeight.W_500)
|
||||
self.detail = C.meta("")
|
||||
self.quality_dd = ft.Dropdown(label=tr("Quality"), value=self.quality, width=T.px(260), dense=True,
|
||||
self.quality_dd = C.dropdown(label=tr("Quality"), value=self.quality, width=T.px(230),
|
||||
options=[ft.DropdownOption(key=k, text=t) for k, t in QUALITIES],
|
||||
on_select=self._set_quality)
|
||||
self.start_btn = C.primary(tr("Start live view"), ft.Icons.PLAY_ARROW_ROUNDED, self._start, big=True)
|
||||
self.start_btn = C.primary(tr("Start live view"), ft.Icons.PLAY_ARROW_ROUNDED, self._start)
|
||||
self.open_btn = C.secondary(tr("Open viewer"), ft.Icons.OPEN_IN_NEW_ROUNDED, self._open)
|
||||
self.stop_btn = C.ghost(tr("Stop"), ft.Icons.STOP_ROUNDED, self._stop_click)
|
||||
self.url = C.meta("", selectable=True)
|
||||
@@ -88,28 +90,34 @@ 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:
|
||||
parts = C.transit_parts()
|
||||
h = T.px(56)
|
||||
self.idle = ft.Container(ft.Column([
|
||||
ft.Row([ft.Image(src=parts["portal"], height=h, width=h * 24 / 50, fit=ft.BoxFit.CONTAIN),
|
||||
ft.Image(src=parts["headset"], height=h * 0.86, fit=ft.BoxFit.CONTAIN)],
|
||||
spacing=T.S3, tight=True, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
C.body(tr("Start the live view to watch the headset in your browser"), T.TEXT_2,
|
||||
text_align=ft.TextAlign.CENTER),
|
||||
], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S4, tight=True),
|
||||
alignment=ft.Alignment.CENTER, expand=True, padding=T.S6)
|
||||
self.root = ft.Column([
|
||||
app.top_bar(heading, sub),
|
||||
ft.Row([C.meta(tr("The headset's view and sound open in your web browser. Stop the stream when "
|
||||
"you're done."), T.TEXT_2), C.help_icon("live_view", 16)],
|
||||
spacing=T.px(2), tight=True, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
# status, Start/Open/Stop and Quality in one bar
|
||||
C.card(ft.Column([
|
||||
ft.Row([self.dot, self.state], spacing=T.S2,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
self.detail,
|
||||
ft.Container(height=T.S2),
|
||||
ft.Row([self.start_btn, self.open_btn, self.stop_btn, self.quality_dd], # (no expand child: wraps)
|
||||
spacing=T.S3, wrap=True, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
ft.Row([self.dot, ft.Column([self.state, self.detail], spacing=T.px(2), expand=True),
|
||||
self.quality_dd, self.start_btn, self.open_btn, self.stop_btn],
|
||||
spacing=T.S3, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
self.url,
|
||||
], spacing=T.S2)),
|
||||
C.callout(tr("The picture opens in your default web browser, where it plays smoothly and can go "
|
||||
"full screen. It shows the headset's view with its sound "
|
||||
"(click Sound on in the player) whatever is running: Steam's menus, SteamVR or a game; "
|
||||
"while the headset sleeps the picture is black and updates about once a second. "
|
||||
"The Frame's hardware video encoder does the work (if it isn't available, the "
|
||||
"processor does, which costs a game more); stop the stream when you're done.")),
|
||||
], spacing=T.S4, horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
|
||||
self.idle,
|
||||
], spacing=T.S4, horizontal_alignment=ft.CrossAxisAlignment.STRETCH, expand=True)
|
||||
self._refresh(update=False)
|
||||
self._ensure_ticker()
|
||||
return self.root
|
||||
@@ -134,11 +142,12 @@ class LiveView:
|
||||
self.quality_dd.disabled = running or self._busy
|
||||
self.url.value = (tr("Viewer address on this PC: {url}").format(url=live.url) if running else "")
|
||||
self.url.visible = running
|
||||
self.idle.visible = live is None
|
||||
if update: # called from the ticker / start / stream-end threads: send through Flet's event loop
|
||||
if self._push is None:
|
||||
self._push = C.LoopUpdater(self.app.page)
|
||||
self._push(self.dot, self.state, self.detail, self.start_btn, self.open_btn, self.stop_btn,
|
||||
self.quality_dd, self.url)
|
||||
self.quality_dd, self.url, self.idle)
|
||||
|
||||
def _ensure_ticker(self) -> None:
|
||||
if self._ticker and self._ticker.is_alive():
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Monitor: a live view of the Frame — the running game (fps, CPU/GPU/memory), CPU, GPU, memory, temperatures, power
|
||||
and battery with 2-minute sparklines, and its processes (end or kill one, end a game). Streamed by the agent's
|
||||
`_monitor` session (frame/monitor.py) only while the tab is shown: app.go / app.disconnect / closing the window call
|
||||
stop(), and the agent ends the stream at EOF, so nothing keeps running on the Frame.
|
||||
and battery with 2-minute sparklines, and its processes (end or kill one, end a game). Fed by the app's MonitorHub
|
||||
(frame/monitor_hub.py; one agent `_monitor` stream shared with the sidebar's live Frame card): the view subscribes to
|
||||
everything only while the tab is shown (app.go / app.disconnect / closing the window call stop()); the hub connects,
|
||||
reconnects and ends the stream when nobody needs it.
|
||||
|
||||
Built once and updated in place (CLAUDE.md performance rules): a tick changes values, colours and sparkline paths;
|
||||
the process table is a fixed pool of rows re-bound to the newest data, so clicks and menus keep working."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
@@ -23,11 +23,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:
|
||||
@@ -121,9 +131,9 @@ class ProcRow:
|
||||
self.group_box.bgcolor = T.soft(color, 0.12)
|
||||
self.lock.visible = bool(p.get("critical") or p.get("locked"))
|
||||
self.lock.color = T.TEXT_3 if p.get("locked") else T.WARN
|
||||
self.cpu.value = f"{p.get('cpu', 0):.1f} %"
|
||||
self.cpu.value = f"{p.get('cpu', 0):.1f}%"
|
||||
self.cpu.color = LEVEL_COLOR[M.level("cpu", p.get("cpu"))] if p.get("cpu", 0) >= 1 else T.TEXT_2
|
||||
self.gpu.value = f"{p['gpu']:.0f} %" if p.get("gpu") else "–"
|
||||
self.gpu.value = f"{p['gpu']:.0f}%" if p.get("gpu") else "–"
|
||||
self.mem.value = M.fmt_bytes(p.get("rss"))
|
||||
self.age.value = M.fmt_duration(p.get("age"))
|
||||
|
||||
@@ -131,10 +141,7 @@ class ProcRow:
|
||||
class MonitorView:
|
||||
def __init__(self, app: FramePortApp):
|
||||
self.app = app
|
||||
self.session = None # frame.monitor.MonitorSession while the tab is open
|
||||
self._connecting = False
|
||||
self._gen = 0 # bumped by stop(): a connect that finishes after the tab was left closes itself
|
||||
self.stopped = True
|
||||
self.stopped = True # not subscribed to the app's monitor hub (the tab isn't shown)
|
||||
self.root = None
|
||||
self.history = M.History()
|
||||
self.static: dict = {}
|
||||
@@ -151,14 +158,13 @@ class MonitorView:
|
||||
|
||||
# ---------------------------------------------------------------- building
|
||||
def _build(self) -> None:
|
||||
self.live_dot = C.dot(T.TEXT_3, T.px(8))
|
||||
self.live_dot = C.dot(T.TEXT_3, 8)
|
||||
self.live_text = C.meta(tr("Connecting…"))
|
||||
self.live = ft.Container(ft.Row([self.live_dot, self.live_text], spacing=T.S2, tight=True),
|
||||
bgcolor=T.SURFACE_3, border_radius=T.px(20),
|
||||
padding=ft.Padding(T.px(10), T.px(5), T.px(12), T.px(5)))
|
||||
self.overhead = C.meta("")
|
||||
self.interval = ft.Dropdown(value=f"{M.DEFAULT_INTERVAL:g}", dense=True, width=T.px(150),
|
||||
border_color=T.BORDER, text_size=T.T_BODY,
|
||||
self.interval = C.dropdown(value=f"{M.DEFAULT_INTERVAL:g}", width=T.px(150),
|
||||
options=[ft.DropdownOption(f"{s:g}", tr("Every {n}").format(n=M.fmt_interval(s)))
|
||||
for s in M.INTERVALS],
|
||||
on_select=self._set_interval, tooltip=tr("How often the Frame sends new numbers"))
|
||||
@@ -195,7 +201,7 @@ class MonitorView:
|
||||
# metric tiles
|
||||
self.t_cpu = Tile(tr("CPU"), ft.Icons.MEMORY_ROUNDED, T.INFO, hi=100)
|
||||
self.t_gpu = Tile(tr("GPU"), ft.Icons.VIEW_IN_AR_ROUNDED, T.ACCENT, hi=100, help_key="monitor_gpu")
|
||||
self.t_mem = Tile(tr("Memory"), ft.Icons.STORAGE_ROUNDED, T.PC, hi=100, help_key="monitor_pressure")
|
||||
self.t_mem = Tile(tr("Memory"), ft.Icons.STORAGE_OUTLINED, T.PC, hi=100, help_key="monitor_pressure")
|
||||
self.t_temp = Tile(tr("Temperature"), ft.Icons.THERMOSTAT_ROUNDED, T.WARN)
|
||||
self.t_power = Tile(tr("Power"), ft.Icons.BOLT_ROUNDED, T.OK, help_key="monitor_power")
|
||||
self.t_bat = Tile(tr("Battery"), ft.Icons.BATTERY_STD_ROUNDED, T.OK, hi=100)
|
||||
@@ -226,13 +232,10 @@ class MonitorView:
|
||||
self.details_btn = C.ghost(tr("Show details"), ft.Icons.EXPAND_MORE_ROUNDED, lambda e: self.toggle_details())
|
||||
|
||||
# processes
|
||||
self.filter = ft.SegmentedButton(
|
||||
segments=[ft.Segment("game", label=tr("Game")), ft.Segment("steam", label=tr("Steam & SteamVR")),
|
||||
ft.Segment("all", label=tr("All"))],
|
||||
selected=["game"], show_selected_icon=False, on_change=self._set_filter)
|
||||
self.search = ft.TextField(hint_text=tr("Search name, PID or game"), dense=True, width=T.px(240),
|
||||
border_color=T.BORDER, prefix_icon=ft.Icons.SEARCH_ROUNDED,
|
||||
on_change=lambda e: self._bind_rows(update=True))
|
||||
self.filter = C.segmented([("game", tr("Game")), ("steam", tr("Steam & SteamVR")), ("all", tr("All"))],
|
||||
"game", self._set_filter) # .data = the selected filter
|
||||
self.search = C.search(hint_text=tr("Search name, PID or game"), width=T.px(240),
|
||||
on_change=lambda e: self._bind_rows(update=True))
|
||||
self.sort_btns: dict[str, ft.TextButton] = {}
|
||||
left, right = ft.Alignment.CENTER_LEFT, ft.Alignment.CENTER_RIGHT
|
||||
header_cells = [ft.Container(self._sort_btn("name", tr("Name")), expand=True, alignment=left),
|
||||
@@ -248,7 +251,8 @@ class MonitorView:
|
||||
self.rows = [ProcRow(self) for _ in range(ROWS)]
|
||||
self.proc_note = C.meta("")
|
||||
self.no_game = C.callout(tr("No FramePort game is running. Start one in the headset or with Play, or switch "
|
||||
"the filter to see everything on the Frame."), icon=ft.Icons.SPORTS_ESPORTS_ROUNDED)
|
||||
"the filter to see everything on the Frame."),
|
||||
icon=ft.Icons.SPORTS_ESPORTS_OUTLINED)
|
||||
self.no_game.visible = False
|
||||
self.menu = ft.ContextMenu(content=C.card(ft.Column(
|
||||
[self.header, ft.Column([r.control for r in self.rows], spacing=0), self.proc_note],
|
||||
@@ -288,74 +292,63 @@ class MonitorView:
|
||||
return self.root
|
||||
|
||||
# ---------------------------------------------------------------- session
|
||||
@property
|
||||
def hub(self):
|
||||
return self.app.monitor_hub
|
||||
|
||||
def start(self) -> None:
|
||||
"""Subscribe to the app's monitor hub (everything, at the chosen interval); the hub connects, reconnects
|
||||
after a lost stream and shares it with the sidebar's live Frame card."""
|
||||
self.stopped = False
|
||||
if self.session is not None or self._connecting:
|
||||
hub = self.hub
|
||||
if hub.subscribed("monitor"):
|
||||
hub.reconnect() # the Reconnect button (after an error)
|
||||
return
|
||||
self._connecting = True
|
||||
self._set_live(tr("Connecting…"), T.TEXT_3, update=self.root is not None)
|
||||
gen, target = self._gen, self.app.target
|
||||
self.app.run_bg(self._connect, gen, target)
|
||||
|
||||
def _connect(self, gen: int, target) -> None:
|
||||
try:
|
||||
session = M.MonitorSession(target.frame, lambda s, g=gen: self._on_sample(g, s),
|
||||
lambda why, g=gen: self._on_end(g, why))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._connecting = False
|
||||
if gen == self._gen:
|
||||
self._set_live(tr("Not connected: {error}").format(error=explain(exc)), T.ERROR)
|
||||
return
|
||||
self._connecting = False
|
||||
if gen != self._gen: # the tab was left while connecting
|
||||
session.close()
|
||||
return
|
||||
self.session = session
|
||||
if session.static != self.static:
|
||||
self.static = session.static
|
||||
self._build_static()
|
||||
if self.interval.value != f"{M.DEFAULT_INTERVAL:g}":
|
||||
session.set_interval(float(self.interval.value))
|
||||
if self.filter.selected != ["game"]:
|
||||
session.set_filter(self.filter.selected[0])
|
||||
hub.set_filter(self.filter.data)
|
||||
hub.subscribe("monitor", "all", float(self.interval.value), self._on_sample, self._on_state)
|
||||
if self.paused:
|
||||
session.pause(True)
|
||||
self._set_live(tr("Live"), T.OK)
|
||||
hub.pause("monitor", True)
|
||||
|
||||
def _on_end(self, gen: int, why: str) -> None:
|
||||
if gen != self._gen:
|
||||
def _on_state(self, name: str, state: str, detail) -> None:
|
||||
"""The hub's stream state (its connect/reader/timer threads)."""
|
||||
if self.stopped:
|
||||
return
|
||||
self.session = None
|
||||
self._set_live(tr("Connection lost, reconnecting…"), T.WARN)
|
||||
timer = threading.Timer(RETRY_SECONDS, lambda: gen == self._gen and not self.stopped and self.start())
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
if state == "connecting":
|
||||
self._set_live(tr("Connecting…"), T.TEXT_3)
|
||||
elif state == "live":
|
||||
static = self.hub.static
|
||||
if static and static != self.static:
|
||||
self.static = static
|
||||
self._build_static()
|
||||
self._set_live(tr("Live"), T.OK)
|
||||
elif state == "lost":
|
||||
self._set_live(tr("Connection lost, reconnecting…"), T.WARN)
|
||||
elif state == "error":
|
||||
self._set_live(tr("Not connected: {error}").format(error=explain(detail)), T.ERROR)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""End the stream (leaving the tab, disconnecting, closing the app)."""
|
||||
self._gen += 1
|
||||
"""Stop receiving (leaving the tab, disconnecting, closing the app); the hub ends the stream when nobody else
|
||||
(the live Frame card) needs it."""
|
||||
self.stopped = True
|
||||
session, self.session = self.session, None
|
||||
if session is not None:
|
||||
session.close()
|
||||
self.hub.unsubscribe("monitor")
|
||||
|
||||
def _set_live(self, text: str, color: str, update: bool = True) -> None:
|
||||
self.live_text.value, self.live_dot.bgcolor = text, color
|
||||
self.reconnect.visible = color == T.ERROR
|
||||
if update:
|
||||
C.update(self.live, self.reconnect)
|
||||
self._updater()(self.live, self.reconnect)
|
||||
|
||||
def _set_interval(self, e) -> None:
|
||||
if self.session:
|
||||
self.session.set_interval(float(self.interval.value))
|
||||
self.hub.update("monitor", interval=float(self.interval.value))
|
||||
|
||||
def toggle_pause(self) -> None:
|
||||
self.paused = not self.paused
|
||||
if self.session:
|
||||
self.session.pause(self.paused)
|
||||
self.hub.pause("monitor", self.paused) # paused: no samples here (the card keeps its own)
|
||||
self.pause_btn.icon = ft.Icons.PLAY_ARROW_ROUNDED if self.paused else ft.Icons.PAUSE_ROUNDED
|
||||
self.pause_btn.tooltip = tr("Resume") if self.paused else tr("Pause")
|
||||
self._set_live(tr("Paused") if self.paused else tr("Live"), T.TEXT_3 if self.paused else T.OK, update=False)
|
||||
if self.paused:
|
||||
self._set_live(tr("Paused"), T.TEXT_3, update=False)
|
||||
C.update(self.pause_btn, self.live)
|
||||
|
||||
def toggle_details(self) -> None:
|
||||
@@ -366,10 +359,8 @@ class MonitorView:
|
||||
self.details_btn.icon = ft.Icons.EXPAND_LESS_ROUNDED if self.details.visible else ft.Icons.EXPAND_MORE_ROUNDED
|
||||
C.update(self.details, self.details_btn)
|
||||
|
||||
def _set_filter(self, e) -> None:
|
||||
which = (self.filter.selected or ["game"])[0]
|
||||
if self.session:
|
||||
self.session.set_filter(which)
|
||||
def _set_filter(self, which: str) -> None:
|
||||
self.hub.set_filter(which)
|
||||
|
||||
def set_sort(self, key: str) -> None:
|
||||
if self.sort_key == key:
|
||||
@@ -422,7 +413,7 @@ class MonitorView:
|
||||
for color, name in zip(POWER_COLORS, ("CPU", "GPU", "NPU", tr("Other")), strict=True):
|
||||
t = C.meta(f"{name} –")
|
||||
self.legend_texts.append(t)
|
||||
legend.append(ft.Row([C.dot(color, T.px(8)), t], spacing=T.px(6), tight=True))
|
||||
legend.append(ft.Row([C.dot(color, 8), t], spacing=T.px(6), tight=True))
|
||||
self.power_legend.controls = legend
|
||||
self.net.controls, self.net_texts = [], {}
|
||||
# through the event loop like the ticks: sent directly from this (connect) thread, the patch adding these
|
||||
@@ -437,8 +428,8 @@ class MonitorView:
|
||||
return self.push
|
||||
|
||||
# ---------------------------------------------------------------- samples
|
||||
def _on_sample(self, gen: int, s: dict) -> None:
|
||||
if gen != self._gen or self.root is None:
|
||||
def _on_sample(self, s: dict) -> None:
|
||||
if self.stopped or self.root is None:
|
||||
return
|
||||
self.last_sample = s
|
||||
for name, value in M.series_of(s).items():
|
||||
@@ -448,7 +439,7 @@ class MonitorView:
|
||||
self.procs = s.get("procs") or []
|
||||
changed.append(self._bind_rows(update=False))
|
||||
cpu_share = (s.get("self_ms") or 0) / 10 / max(1.0, s.get("dt") or 1.0) / max(1, self.static.get("cores", 1))
|
||||
self.overhead.value = tr("Monitor is using {pct} % of the Frame's CPU").format(pct=f"{cpu_share:.2f}")
|
||||
self.overhead.value = tr("Monitor is using {pct}% of the Frame's CPU").format(pct=f"{cpu_share:.2f}")
|
||||
self.overhead.tooltip = tr("The time the Frame spends collecting these numbers")
|
||||
self._updater()(self.overhead, *[c for group in changed for c in group])
|
||||
|
||||
@@ -470,7 +461,7 @@ class MonitorView:
|
||||
if swap > 64 * 1024 ** 2:
|
||||
sub += " · " + tr("swap {n}").format(n=M.fmt_bytes(swap))
|
||||
if psi:
|
||||
sub += " · " + tr("waiting {pct} %").format(pct=f"{psi:.0f}")
|
||||
sub += " · " + tr("waiting {pct}%").format(pct=f"{psi:.0f}")
|
||||
lvl = max((M.level("mem", 100.0 * used / total if total else None), M.level("psi_memory", psi)),
|
||||
key=("ok", "warn", "error").index)
|
||||
self.t_mem.set(M.fmt_pct(100.0 * used / total if total else None), sub, h.get("mem"), lvl)
|
||||
@@ -509,7 +500,7 @@ class MonitorView:
|
||||
game = games[0] if games else None
|
||||
was = self.game_card.visible
|
||||
self.game_card.visible = game is not None
|
||||
self.no_game.visible = game is None and (self.filter.selected or ["game"])[0] == "game"
|
||||
self.no_game.visible = game is None and self.filter.data == "game"
|
||||
if game is None:
|
||||
if self.game_pkg is not None:
|
||||
self.game_pkg = None
|
||||
@@ -555,7 +546,7 @@ class MonitorView:
|
||||
cores = (s.get("cpu") or {}).get("cores") or []
|
||||
for (bar, val), pct in zip(self.core_bars, cores, strict=False):
|
||||
bar.set([pct / 100.0])
|
||||
val.value = f"{pct:.0f} %"
|
||||
val.value = f"{pct:.0f}%"
|
||||
out += [bar.control, val]
|
||||
temps = s.get("temps") or {}
|
||||
zones = s.get("zones")
|
||||
@@ -620,35 +611,13 @@ class MonitorView:
|
||||
def open_menu(self, p: dict | None, position=None) -> None:
|
||||
if not p:
|
||||
return
|
||||
from ..menus import process_menu
|
||||
|
||||
name, pid = p.get("name", "?"), p.get("pid")
|
||||
actions: list = []
|
||||
if p.get("kind") == "group":
|
||||
n = p["count"]
|
||||
actions.append((tr("Hide its processes") if p.get("expanded") else tr("Show its processes"),
|
||||
ft.Icons.UNFOLD_MORE_ROUNDED, lambda e: self.toggle_group(p)))
|
||||
if not p.get("locked"):
|
||||
actions += [None, (tr("End all {n}").format(n=n), ft.Icons.CLOSE_ROUNDED,
|
||||
lambda e: self.ask_kill(p, "TERM")),
|
||||
(tr("Force kill all {n}").format(n=n), ft.Icons.DANGEROUS_OUTLINED,
|
||||
lambda e: self.ask_kill(p, "KILL"))]
|
||||
if p.get("game"):
|
||||
actions += [None, (tr("End the whole game"), ft.Icons.STOP_CIRCLE_OUTLINED,
|
||||
lambda e: self.ask_end_game(p["game"]))]
|
||||
actions += [None, (tr("Copy name"), ft.Icons.CONTENT_COPY_ROUNDED, lambda e: self.app.copy(name))]
|
||||
self.menu.items = C.menu_items(actions)
|
||||
C.update(self.menu)
|
||||
self.app.page.run_task(self.menu.open, global_position=position)
|
||||
return
|
||||
if not p.get("locked"):
|
||||
actions += [(tr("End process"), ft.Icons.CLOSE_ROUNDED, lambda e: self.ask_kill(p, "TERM")),
|
||||
(tr("Force kill"), ft.Icons.DANGEROUS_OUTLINED, lambda e: self.ask_kill(p, "KILL"))]
|
||||
if p.get("game"):
|
||||
actions += [None, (tr("End the whole game"), ft.Icons.STOP_CIRCLE_OUTLINED,
|
||||
lambda e: self.ask_end_game(p["game"]))]
|
||||
actions += [None, (tr("Copy PID {pid}").format(pid=pid), ft.Icons.CONTENT_COPY_ROUNDED,
|
||||
lambda e: self.app.copy(str(pid))),
|
||||
(tr("Copy name"), ft.Icons.CONTENT_COPY_ROUNDED, lambda e: self.app.copy(name))]
|
||||
self.menu.items = C.menu_items(actions)
|
||||
handlers = {"toggle": lambda e: self.toggle_group(p), "copy_name": lambda e: self.app.copy(name),
|
||||
"copy_pid": lambda e: self.app.copy(str(pid)), "term": lambda e: self.ask_kill(p, "TERM"),
|
||||
"kill": lambda e: self.ask_kill(p, "KILL"), "end_game": lambda e: self.ask_end_game(p["game"])}
|
||||
self.menu.items = C.menu_items(process_menu(p, handlers.__getitem__))
|
||||
C.update(self.menu)
|
||||
self.app.page.run_task(self.menu.open, global_position=position)
|
||||
|
||||
@@ -656,13 +625,13 @@ class MonitorView:
|
||||
name, pid = p.get("name", "?"), p.get("pid")
|
||||
if p.get("kind") == "group":
|
||||
n = p["count"]
|
||||
heading = (tr("Force kill all {n} {name}?") if sig == "KILL" else tr("End all {n} {name}?")).format(
|
||||
heading = (tr("Force quit all {n} {name}?") if sig == "KILL" else tr("End all {n} {name}?")).format(
|
||||
n=n, name=name)
|
||||
text = tr("{n} processes named {name} with the same parent are ended.").format(n=n, name=name)
|
||||
if p.get("critical"):
|
||||
text += " " + tr("They are part of Steam, SteamVR or the desktop: the headset view or Steam may "
|
||||
"restart, and anything running in the headset may stop.")
|
||||
C.confirm(self.app.page, heading, text, tr("Force kill all") if sig == "KILL" else tr("End all"),
|
||||
C.confirm(self.app.page, heading, text, tr("Force quit all") if sig == "KILL" else tr("End all"),
|
||||
lambda: self.app.run_bg(self._kill, p, sig), danger=True)
|
||||
return
|
||||
if p.get("critical"):
|
||||
@@ -670,20 +639,20 @@ class MonitorView:
|
||||
text = tr("{name} is part of Steam, SteamVR or the desktop. Ending it can close the headset view or "
|
||||
"restart Steam, and anything running in the headset may stop.").format(name=name)
|
||||
elif sig == "KILL":
|
||||
heading = tr("Force kill {name}?").format(name=name)
|
||||
heading = tr("Force quit {name}?").format(name=name)
|
||||
text = tr("{name} (PID {pid}) stops at once without saving anything.").format(name=name, pid=pid)
|
||||
else:
|
||||
heading = tr("End {name}?").format(name=name)
|
||||
text = tr("{name} (PID {pid}) is asked to close.").format(name=name, pid=pid)
|
||||
if p.get("game"):
|
||||
text += " " + tr("It belongs to a running game, which may stop with it.")
|
||||
label = tr("Force kill") if sig == "KILL" else tr("End process")
|
||||
label = tr("Force quit") if sig == "KILL" else tr("End process")
|
||||
C.confirm(self.app.page, heading, text, label, lambda: self.app.run_bg(self._kill, p, sig),
|
||||
danger=sig == "KILL" or bool(p.get("critical")))
|
||||
|
||||
def _kill(self, p: dict, sig: str) -> None:
|
||||
session = self.session
|
||||
if session is None:
|
||||
session = self.hub
|
||||
if session.session is None:
|
||||
self.app.toast(tr("The monitor isn't connected."), error=True)
|
||||
return
|
||||
name = p.get("name", "?")
|
||||
@@ -697,7 +666,7 @@ class MonitorView:
|
||||
left.append(m["pid"])
|
||||
if left:
|
||||
self.app.toast(tr("{n} of the {name} processes are still running.").format(n=len(left), name=name),
|
||||
action=None if sig == "KILL" else tr("Force kill"),
|
||||
action=None if sig == "KILL" else tr("Force quit"),
|
||||
on_action=None if sig == "KILL" else lambda e: self.app.run_bg(self._kill, p, "KILL"))
|
||||
else:
|
||||
self.app.toast(tr("All {n} {name} processes ended.").format(n=p["count"], name=name))
|
||||
@@ -712,7 +681,7 @@ class MonitorView:
|
||||
if res.get("ended"):
|
||||
self.app.toast(tr("{name} ended.").format(name=name))
|
||||
else:
|
||||
self.app.toast(tr("{name} is still running.").format(name=name), action=tr("Force kill"),
|
||||
self.app.toast(tr("{name} is still running.").format(name=name), action=tr("Force quit"),
|
||||
on_action=lambda e: self.app.run_bg(self._kill, p, "KILL"))
|
||||
|
||||
def ask_end_game(self, pkg: str | None = None) -> None:
|
||||
@@ -726,8 +695,8 @@ class MonitorView:
|
||||
tr("End game"), lambda: self.app.run_bg(self._end_game, pkg, title), danger=True)
|
||||
|
||||
def _end_game(self, pkg: str, title: str) -> None:
|
||||
session = self.session
|
||||
if session is None:
|
||||
session = self.hub
|
||||
if session.session is None:
|
||||
self.app.toast(tr("The monitor isn't connected."), error=True)
|
||||
return
|
||||
self.end_btn.disabled = True
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,24 +70,27 @@ class ScreenshotsView:
|
||||
self.games: list[dict] = []
|
||||
self.selected: set[str] = set() # paths
|
||||
self.checks: dict[str, ft.Checkbox] = {}
|
||||
self.tiles: dict[str, tuple] = {} # path → (tile, quiet checkbox, selection overlay)
|
||||
self.hot: set[str] = set() # tiles under the mouse or with keyboard focus
|
||||
self._gen = 0
|
||||
self._loaded = False
|
||||
self._lock = threading.Lock()
|
||||
self.dropdown = ft.Dropdown(label=tr("Game"), value=ALL, width=T.px(300), dense=True,
|
||||
self.dropdown = C.dropdown(label=tr("Game"), value=ALL, width=T.px(300),
|
||||
options=[ft.DropdownOption(key=ALL, text=tr("All games"))],
|
||||
on_select=lambda e: self.set_filter(e.control.value))
|
||||
self.select_all = ft.Checkbox(value=False, active_color=T.ACCENT, check_color=T.ON_ACCENT,
|
||||
tooltip=tr("Select all"), on_change=self._toggle_all)
|
||||
label=tr("Select all"), label_style=ft.TextStyle(color=T.TEXT_2, size=T.T_BODY),
|
||||
on_change=self._toggle_all)
|
||||
self.toolbar = ft.Row([
|
||||
self.dropdown, self.select_all, ft.Container(expand=True),
|
||||
C.secondary(tr("Download all"), ft.Icons.DOWNLOAD_ROUNDED, self._download_all),
|
||||
C.secondary(tr("Download all…"), ft.Icons.DOWNLOAD_ROUNDED, self._download_all),
|
||||
C.icon_btn(ft.Icons.REFRESH_ROUNDED, tr("Refresh"), lambda e: self.load()),
|
||||
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
self.sel_label = C.body("", T.TEXT, weight=ft.FontWeight.W_500)
|
||||
self.sel_bar = ft.Container(ft.Row([
|
||||
self.sel_label, ft.Container(expand=True),
|
||||
C.secondary(tr("Download selected"), ft.Icons.DOWNLOAD_ROUNDED, self._download_selected),
|
||||
C.ghost(tr("Delete"), ft.Icons.DELETE_OUTLINE_ROUNDED, lambda e: self.delete(self._chosen())),
|
||||
C.secondary(tr("Download selected…"), ft.Icons.DOWNLOAD_ROUNDED, self._download_selected),
|
||||
C.ghost(tr("Delete…"), ft.Icons.DELETE_OUTLINE_ROUNDED, lambda e: self.delete(self._chosen())),
|
||||
C.ghost(tr("Clear"), ft.Icons.CLOSE_ROUNDED, lambda e: self._clear_selection()),
|
||||
], spacing=T.S2), padding=ft.Padding(T.S3, T.px(6), T.S2, T.px(6)), border_radius=T.RADIUS_SM,
|
||||
bgcolor=T.ACCENT_SOFT, visible=False)
|
||||
@@ -110,7 +114,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:
|
||||
@@ -157,7 +161,7 @@ class ScreenshotsView:
|
||||
self.shots, self.games = r.get("shots") or [], r.get("games") or []
|
||||
self.selected &= {s["path"] for s in self.shots}
|
||||
self._fill_dropdown()
|
||||
self.checks = {}
|
||||
self.checks, self.tiles, self.hot = {}, {}, set()
|
||||
self.grid.controls = []
|
||||
if not self.shots:
|
||||
empty = C.empty_state(
|
||||
@@ -224,14 +228,22 @@ class ScreenshotsView:
|
||||
end=ft.Alignment.BOTTOM_CENTER,
|
||||
colors=[ft.Colors.TRANSPARENT, T.soft("#000000", 0.8)]),
|
||||
border_radius=ft.BorderRadius(0, 0, T.RADIUS_SM, T.RADIUS_SM))
|
||||
check.on_focus = lambda e, p=s["path"]: self._set_hot(p, True)
|
||||
check.on_blur = lambda e, p=s["path"]: self._set_hot(p, False)
|
||||
quiet_check = C.quiet(check)
|
||||
quiet_check.left, quiet_check.top = 0, 0
|
||||
overlay = ft.Container(left=0, right=0, top=0, bottom=0, border_radius=T.RADIUS_SM)
|
||||
tile = ft.Container(
|
||||
ft.Stack([C.art_fill(self._thumb_url(s), radius=T.RADIUS_SM, placeholder_icon=ft.Icons.IMAGE_OUTLINED,
|
||||
left=0, right=0, top=0, bottom=0),
|
||||
caption, ft.Container(check, left=0, top=0)]),
|
||||
overlay, caption, quiet_check]),
|
||||
width=T.px(256), height=T.px(144), border_radius=T.RADIUS_SM, ink=True,
|
||||
border=ft.Border.all(2, ft.Colors.TRANSPARENT),
|
||||
tooltip=f"{s.get('title') or ''} · {day_label(s.get('time'))} {when}",
|
||||
on_click=lambda e, i=i: self.viewer(i),
|
||||
on_hover=lambda e, p=s["path"]: self.drag.hover(p, e.data in (True, "true")))
|
||||
on_hover=lambda e, p=s["path"]: self._hover(p, e.data in (True, "true")))
|
||||
self.tiles[s["path"]] = (tile, quiet_check, overlay)
|
||||
self._paint(s["path"])
|
||||
return ft.GestureDetector(content=tile, on_secondary_tap_down=lambda e, s=s: self.open_menu(s,
|
||||
e.global_position))
|
||||
|
||||
@@ -292,46 +304,54 @@ class ScreenshotsView:
|
||||
# ---------------------------------------------------------------- selection
|
||||
def _toggle(self, path: str, on: bool) -> None:
|
||||
(self.selected.add if on else self.selected.discard)(path)
|
||||
check = self.checks.get(path)
|
||||
if check is not None and check.value != on:
|
||||
check.value = on
|
||||
C.update(check)
|
||||
self._update_selection()
|
||||
|
||||
def _toggle_all(self, e) -> None:
|
||||
on = bool(e.control.value)
|
||||
self.selected = {s["path"] for s in self.shots} if on else set()
|
||||
for check in self.checks.values():
|
||||
if check.value != on:
|
||||
check.value = on
|
||||
C.update(check)
|
||||
self.selected = {s["path"] for s in self.shots} if e.control.value else set()
|
||||
self._update_selection()
|
||||
|
||||
def _select_all(self) -> None:
|
||||
self.selected = {s["path"] for s in self.shots}
|
||||
for check in self.checks.values():
|
||||
if not check.value:
|
||||
check.value = True
|
||||
C.update(check)
|
||||
self._update_selection()
|
||||
|
||||
def _clear_selection(self) -> None:
|
||||
self.selected.clear()
|
||||
for check in self.checks.values():
|
||||
if check.value:
|
||||
check.value = False
|
||||
C.update(check)
|
||||
self._update_selection()
|
||||
|
||||
# quiet tiles: the checkbox shows on hover, keyboard focus or while anything is selected
|
||||
def _hover(self, path: str, inside: bool) -> None:
|
||||
self.drag.hover(path, inside)
|
||||
self._set_hot(path, inside)
|
||||
|
||||
def _set_hot(self, path: str, on: bool) -> None:
|
||||
(self.hot.add if on else self.hot.discard)(path)
|
||||
if path in self.tiles and self._paint(path):
|
||||
C.update(self.tiles[path][0])
|
||||
|
||||
def _paint(self, path: str) -> bool:
|
||||
"""Properties only: checkbox value + visibility, the accent border and the selection fade. True = changed."""
|
||||
tile, quiet_check, overlay = self.tiles[path]
|
||||
on = path in self.selected
|
||||
check = self.checks[path]
|
||||
before = (check.value, quiet_check.opacity, overlay.bgcolor, overlay.gradient is not None)
|
||||
check.value = on
|
||||
C.reveal(quiet_check, on or path in self.hot or bool(self.selected))
|
||||
C.selected_style(overlay, on, subtle=True)
|
||||
tile.border = ft.Border.all(2, T.ACCENT if on else ft.Colors.TRANSPARENT)
|
||||
return before != (check.value, quiet_check.opacity, overlay.bgcolor, overlay.gradient is not None)
|
||||
|
||||
def _update_selection(self, render: bool = True) -> None:
|
||||
n = len(self.selected)
|
||||
size = sum(s.get("size") or 0 for s in self._chosen())
|
||||
self.sel_label.value = tr("{n} selected").format(n=n) + (f" · {human(size)}" if size else "")
|
||||
self.sel_bar.visible = bool(n)
|
||||
self.select_all.value = bool(self.shots) and n == len(self.shots)
|
||||
changed = [path for path in self.tiles if self._paint(path)]
|
||||
if render:
|
||||
for c in (self.sel_bar, self.select_all):
|
||||
C.update(c)
|
||||
for path in changed:
|
||||
C.update(self.tiles[path][0])
|
||||
|
||||
def _chosen(self) -> list[dict]:
|
||||
return [s for s in self.shots if s["path"] in self.selected]
|
||||
@@ -377,16 +397,15 @@ class ScreenshotsView:
|
||||
def delete(e):
|
||||
page.pop_dialog()
|
||||
self.delete([shots[state["i"]]])
|
||||
page.show_dialog(ft.AlertDialog(
|
||||
content=ft.Container(ft.Column([img, ft.Row([
|
||||
page.show_dialog(C.viewer(
|
||||
ft.Container(ft.Column([img, ft.Row([
|
||||
C.icon_btn(ft.Icons.CHEVRON_LEFT_ROUNDED, tr("Previous"), lambda e: show(-1)),
|
||||
ft.Column([caption, details], spacing=0, expand=True),
|
||||
C.icon_btn(ft.Icons.CHEVRON_RIGHT_ROUNDED, tr("Next"), lambda e: show(1)),
|
||||
C.secondary(tr("Download"), ft.Icons.DOWNLOAD_ROUNDED, download),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Delete"), delete),
|
||||
C.secondary(tr("Download…"), ft.Icons.DOWNLOAD_ROUNDED, download),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Delete…"), delete),
|
||||
C.ghost(tr("Close"), on_click=lambda e: page.pop_dialog())], spacing=T.S2,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)], spacing=T.S2, tight=True), width=T.px(1100)),
|
||||
bgcolor=T.BG, shape=ft.RoundedRectangleBorder(radius=T.RADIUS), content_padding=T.S3))
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)], spacing=T.S2, tight=True), width=T.px(1100))))
|
||||
show()
|
||||
|
||||
# ---------------------------------------------------------------- actions
|
||||
|
||||
@@ -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:
|
||||
@@ -19,6 +21,20 @@ if TYPE_CHECKING:
|
||||
TOOL_TITLES = {"java": tr("Java runtime"), "overport": "OVRPort", "apksigner": "apksigner", "revive": tr("Revive")}
|
||||
TOOL_WHY = {"java": tr("Runs OVRPort and apksigner"), "overport": tr("Converts Quest games to OpenXR"),
|
||||
"apksigner": tr("Signs rebuilt games"), "revive": tr("Runs Oculus PC games on OpenXR")}
|
||||
# the page's sections in order (the index on the left lists them): Appearance near the top (it's the most used),
|
||||
# "Remove FramePort" last
|
||||
SECTIONS = ("appearance", "updates", "links", "installing", "tools", "pc", "data", "feedback", "about", "remove")
|
||||
|
||||
|
||||
def section_titles() -> dict[str, str]:
|
||||
"""Index labels of SECTIONS (the sections' own headings may be longer)."""
|
||||
return {"appearance": tr("Appearance"), "updates": tr("Updates"), "links": tr("Install links"),
|
||||
"installing": tr("Installing"), "tools": tr("Tools"), "pc": tr("This PC"), "data": tr("Data"),
|
||||
"feedback": tr("Problems and feedback"), "about": tr("About"), "remove": tr("Remove FramePort")}
|
||||
|
||||
|
||||
def scroll_key(section: str) -> str:
|
||||
return f"settings-{section}"
|
||||
|
||||
|
||||
class SettingsView:
|
||||
@@ -26,6 +42,8 @@ class SettingsView:
|
||||
self.app = app
|
||||
self.tools = ft.Column(spacing=0)
|
||||
self.pc = ft.Column(spacing=0)
|
||||
self.page_col: ft.Column | None = None # the scrolling column of sections (build())
|
||||
self.index: dict[str, ft.Container] = {} # section -> its entry in the index
|
||||
|
||||
def fill_tools(self, check_latest=False):
|
||||
from ...tools import toolchain
|
||||
@@ -41,37 +59,37 @@ class SettingsView:
|
||||
detail += " · " + s.detail.split("·")[-1].strip()
|
||||
else:
|
||||
detail += tr(" · downloaded when first needed") if s.optional else tr(" · not installed yet")
|
||||
rows.append(C.status_row(True if s.installed else (None if s.optional else False),
|
||||
TOOL_TITLES.get(s.name, s.name), detail,
|
||||
help="revive" if s.name == "revive" else None))
|
||||
self.tools.controls = rows
|
||||
rows.append(C.Check(True if s.installed else ("warn" if s.optional else False),
|
||||
TOOL_TITLES.get(s.name, s.name), detail, "revive" if s.name == "revive" else None,
|
||||
None if s.optional else (tr("Install"), ft.Icons.DOWNLOAD_ROUNDED,
|
||||
self.app.update_tools)))
|
||||
self.tools.controls = [C.checklist(rows)]
|
||||
C.update(self.tools)
|
||||
|
||||
def fill_pc(self):
|
||||
from ...core import winhost
|
||||
|
||||
if not winhost.available():
|
||||
self.pc.controls = [C.status_row(
|
||||
None, tr("Windows not detected"),
|
||||
tr("PC VR games can run on this PC only with Windows (or WSL on Windows)"))]
|
||||
items = [C.Check("warn", tr("Windows not detected"),
|
||||
tr("PC VR games can run on this PC only with Windows (or WSL on Windows)"))]
|
||||
else:
|
||||
try:
|
||||
from ...targets.pc_revive import PcReviveTarget
|
||||
|
||||
d = PcReviveTarget().describe()
|
||||
self.pc.controls = [
|
||||
C.status_row(bool(d["steam"]), tr("Steam"),
|
||||
tr("Found") if d["steam"] else tr("Steam for Windows not found")),
|
||||
C.status_row(d["steamvr"] or None, tr("SteamVR"),
|
||||
tr("Installed") if d["steamvr"]
|
||||
else tr("Install SteamVR from Steam to play PC VR games"),
|
||||
help="steamvr_pc"),
|
||||
C.status_row(bool(d["revive"]), tr("Revive"), (f"{d['revive_version']} · {d['revive']}"
|
||||
if d["revive"] else tr("Downloaded when first needed")),
|
||||
help="revive"),
|
||||
items = [
|
||||
C.Check(bool(d["steam"]), tr("Steam"),
|
||||
tr("Found") if d["steam"] else tr("Steam for Windows not found")),
|
||||
C.Check(True if d["steamvr"] else "warn", tr("SteamVR"),
|
||||
tr("Installed") if d["steamvr"] else tr("Install SteamVR from Steam to play PC VR games"),
|
||||
"steamvr_pc"),
|
||||
C.Check(bool(d["revive"]), tr("Revive"), (f"{d['revive_version']} · {d['revive']}"
|
||||
if d["revive"] else tr("Downloaded when first needed")),
|
||||
"revive"),
|
||||
]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.pc.controls = [C.status_row(False, tr("Couldn't check this PC"), explain(exc))]
|
||||
items = [C.Check(False, tr("Couldn't check this PC"), explain(exc))]
|
||||
self.pc.controls = [C.checklist(items)]
|
||||
C.update(self.pc)
|
||||
|
||||
def installing(self) -> ft.Control:
|
||||
@@ -93,7 +111,7 @@ class SettingsView:
|
||||
launch = C.switch(tr("Launch test after installing on the Frame (starts the game once without the headset "
|
||||
"and checks its log)"), value=bool(library.setting("install.launch_test", True)),
|
||||
on_change=changed)
|
||||
return ft.Column([launch, keep, C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_ROUNDED,
|
||||
return ft.Column([launch, keep, C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_OUTLINED,
|
||||
clean, tooltip=tr("Your own game files aren't touched."))],
|
||||
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
@@ -106,7 +124,7 @@ class SettingsView:
|
||||
|
||||
app = self.app
|
||||
last = library.setting("update.last_check")
|
||||
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(last)) if last else "never"
|
||||
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(last)) if last else tr("never")
|
||||
found = app.updater.found
|
||||
status = (C.callout(ft.Row([C.body(tr("FramePort {version} is available.").format(version=found.version),
|
||||
T.TEXT, expand=True),
|
||||
@@ -120,8 +138,8 @@ class SettingsView:
|
||||
|
||||
def auto_install(e):
|
||||
library.set_setting("update.auto_install", bool(e.control.value))
|
||||
kind = {"bundle": "the downloaded app", "source": "a source checkout (git pull + uv sync)",
|
||||
"wheel": "an installed Python package (reinstalled from the release)"}[updates.install_kind()]
|
||||
kind = {"bundle": tr("the downloaded app"), "source": tr("a source checkout (git pull + uv sync)"),
|
||||
"wheel": tr("an installed Python package (reinstalled from the release)")}[updates.install_kind()]
|
||||
return ft.Column([
|
||||
ft.Row([C.kv(tr("Installed"), tr("FramePort {version} · {kind}").format(version=__version__, kind=kind)),
|
||||
ft.Container(expand=True),
|
||||
@@ -134,7 +152,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)
|
||||
@@ -149,7 +167,7 @@ class SettingsView:
|
||||
app = self.app
|
||||
st = catalog.remote_status()
|
||||
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(st["checked"])) if st["checked"] else tr("never")
|
||||
line = tr("Game configs from GitHub: last checked {when}").format(when=when)
|
||||
line = tr("Game recipes from GitHub: last checked {when}").format(when=when)
|
||||
if st["skipped"]:
|
||||
line += " · " + tr_n("{n} needs a newer FramePort", "{n} need a newer FramePort", len(st["skipped"]))
|
||||
|
||||
@@ -160,7 +178,7 @@ class SettingsView:
|
||||
C.ghost(tr("Check now"), ft.Icons.SYNC_ROUNDED,
|
||||
lambda e: app.run_bg(lambda: app._refresh_catalog(force=True)))],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
C.switch(tr("Update game configs from GitHub automatically (confirmed configs arrive without a "
|
||||
C.switch(tr("Update game recipes from GitHub automatically (confirmed recipes arrive without a "
|
||||
"FramePort update)"), value=bool(library.setting("catalog.auto_update", True)),
|
||||
on_change=toggle),
|
||||
], spacing=T.S2)
|
||||
@@ -225,6 +243,95 @@ 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.TEXT if T.DUAL else 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.RADIUS_XS, ink=True,
|
||||
on_click=lambda e, t=tid: remove(t)))
|
||||
corner = ft.Row(marks, spacing=T.S2, tight=True)
|
||||
box = 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,
|
||||
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
|
||||
C.selected_style(box, on)
|
||||
return box
|
||||
|
||||
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 colors 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
|
||||
|
||||
@@ -242,15 +349,22 @@ class SettingsView:
|
||||
else tr("Now {scale:.0%}.").format(scale=T.SCALE))
|
||||
C.update(note)
|
||||
|
||||
dd = ft.Dropdown(label=tr("Text and layout size"), value=str(current) if current != "auto" else "auto",
|
||||
dd = C.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]
|
||||
live_card = C.switch(tr("Show the running game on the Frame card"), value=self.app.live_card,
|
||||
on_change=lambda e: self.app.set_live_card(e.control.value))
|
||||
live_note = C.meta(tr("Streams a small sample from the Frame every 5 seconds (game, frame rate, battery). "
|
||||
"Turn off to save the Frame's processor."))
|
||||
motion = C.switch(tr("Reduce motion (no fades between pages, no portal pulse when the Frame connects)"),
|
||||
value=self.app.reduce_motion, on_change=lambda e: self.app.set_reduce_motion(e.control.value))
|
||||
controls = [self.theme_picker(), ft.Container(height=T.S2), dd, note, ft.Container(height=T.S2), live_card,
|
||||
live_note, motion]
|
||||
languages = i18n.available()
|
||||
if len(languages) > 1: # only once a translation exists
|
||||
def language_changed(e):
|
||||
library.set_setting("ui.language", e.control.value)
|
||||
C.update(lang_note)
|
||||
lang = ft.Dropdown(label=tr("Language"), value=i18n.language(), width=T.px(260), on_select=language_changed,
|
||||
lang = C.dropdown(label=tr("Language"), value=i18n.language(), width=T.px(260), on_select=language_changed,
|
||||
options=[ft.dropdown.Option(code, i18n.language_name(code)) for code in languages])
|
||||
lang_note = C.meta(tr("Changes apply the next time FramePort starts."))
|
||||
controls += [lang, lang_note]
|
||||
@@ -262,7 +376,7 @@ class SettingsView:
|
||||
from ...frame.connection import bundled_agent_version
|
||||
|
||||
mine = bundled_agent_version()
|
||||
text = f"v{mine}" if mine else "unknown"
|
||||
text = f"v{mine}" if mine else tr("unknown")
|
||||
info = self.app.frame_info or {}
|
||||
remote = info.get("agent_version")
|
||||
if self.app.frame_state == "connected" and remote:
|
||||
@@ -272,29 +386,55 @@ class SettingsView:
|
||||
text += tr(" · Frame not connected")
|
||||
return text
|
||||
|
||||
def index_column(self) -> ft.Control:
|
||||
"""The page's index (left): one entry per section; a click scrolls there and highlights the entry."""
|
||||
def entry(key: str, label: str) -> ft.Container:
|
||||
text = C.body(label, T.TEXT_2, weight=ft.FontWeight.W_500)
|
||||
|
||||
async def click(e, k=key):
|
||||
await self.show_section(k)
|
||||
box = ft.Container(text, padding=ft.Padding(T.S3, T.px(7), T.S3, T.px(7)), border_radius=T.RADIUS_SM,
|
||||
ink=True, on_click=click, data=text)
|
||||
self.index[key] = box
|
||||
return box
|
||||
titles = section_titles()
|
||||
col = ft.Column([entry(k, titles[k]) for k in SECTIONS], spacing=T.px(2))
|
||||
self._mark(SECTIONS[0])
|
||||
return ft.Container(col, width=T.px(180), padding=ft.Padding(0, T.px(2), T.S4, 0))
|
||||
|
||||
def _mark(self, current: str) -> None:
|
||||
for key, box in self.index.items():
|
||||
C.selected_style(box, key == current, text=box.data)
|
||||
|
||||
async def show_section(self, key: str) -> None:
|
||||
"""Scroll the page to a section (index click; ui_smoke calls it too)."""
|
||||
self._mark(key)
|
||||
C.update(*self.index.values())
|
||||
if self.page_col is not None:
|
||||
await self.page_col.scroll_to(scroll_key=scroll_key(key), duration=0 if self.app.reduce_motion else 300)
|
||||
|
||||
def build(self) -> ft.Control:
|
||||
app = self.app
|
||||
self.tools.controls = [ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2),
|
||||
color=T.ACCENT),
|
||||
C.meta(tr("Checking tools…"))], spacing=T.S2)]
|
||||
self.tools.controls = [C.checklist([C.Check(None, tr("Checking tools…"))])]
|
||||
app.run_bg(self.fill_tools)
|
||||
self.pc.controls = [ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2),
|
||||
color=T.ACCENT),
|
||||
C.meta(tr("Checking this PC…"))], spacing=T.S2)]
|
||||
self.pc.controls = [C.checklist([C.Check(None, tr("Checking this PC…"))])]
|
||||
app.run_bg(self.fill_pc)
|
||||
from ... import __version__ as ver
|
||||
data = str(user_data_dir())
|
||||
return ft.Column([
|
||||
app.top_bar(tr("Settings"), tr("Updates, tools, this PC and where FramePort keeps its data")),
|
||||
C.section(tr("Updates"), C.card(self.updates_card(), padding=T.S4), help="app_updates"),
|
||||
C.section(tr("Tools"), C.card(self.tools, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)),
|
||||
subtitle=tr("FramePort manages its own copies; nothing is installed system-wide"),
|
||||
action=ft.Row([C.ghost(tr("Update tools"), ft.Icons.UPDATE_ROUNDED,
|
||||
lambda e: app.update_tools(update=True)),
|
||||
C.secondary(tr("Install missing"), ft.Icons.DOWNLOAD_ROUNDED,
|
||||
lambda e: app.update_tools())], spacing=T.S2)),
|
||||
C.section(tr("This PC (for PC VR games)"), C.card(self.pc, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2))),
|
||||
C.section(tr("Data"), C.card(ft.Column([
|
||||
sections = {
|
||||
"appearance": C.section(tr("Appearance"), C.card(self.appearance(), padding=T.S4), help="ui_scale"),
|
||||
"updates": C.section(tr("Updates"), C.card(self.updates_card(), padding=T.S4), help="app_updates"),
|
||||
"links": C.section(tr("Install links"), C.card(self.links_card(), padding=T.S4), help="install_links"),
|
||||
"installing": C.section(tr("Installing"), C.card(self.installing(), padding=T.S4), help="launch_test"),
|
||||
"tools": C.section(tr("Tools"), C.card(self.tools, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)),
|
||||
subtitle=tr("FramePort manages its own copies; nothing is installed system-wide"),
|
||||
action=ft.Row([C.ghost(tr("Update tools"), ft.Icons.UPDATE_ROUNDED,
|
||||
lambda e: app.update_tools(update=True)),
|
||||
C.secondary(tr("Install missing"), ft.Icons.DOWNLOAD_ROUNDED,
|
||||
lambda e: app.update_tools())], spacing=T.S2)),
|
||||
"pc": C.section(tr("This PC (for PC VR games)"),
|
||||
C.card(self.pc, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2))),
|
||||
"data": C.section(tr("Data"), C.card(ft.Column([
|
||||
C.kv(tr("Data folder"), ft.Row([C.body(data, T.TEXT, selectable=True, expand=True),
|
||||
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, tr("Copy path"),
|
||||
lambda e: app.copy(data))]), "data_folder"),
|
||||
@@ -303,7 +443,7 @@ class SettingsView:
|
||||
self.catalog_updates(),
|
||||
], spacing=T.S2))),
|
||||
# text above, buttons below (side by side, the buttons squeezed the text in a narrow window)
|
||||
C.section(tr("Problems and feedback"), C.card(ft.Column([
|
||||
"feedback": C.section(tr("Problems and feedback"), C.card(ft.Column([
|
||||
C.body(tr("Something not working? Collect a diagnostics zip (logs, settings, device info; personal "
|
||||
"data removed) and attach it to a GitHub issue. For one game, use its menu instead.")),
|
||||
ft.Row([C.secondary(tr("Report a problem…"), ft.Icons.BUG_REPORT_OUTLINED,
|
||||
@@ -311,23 +451,27 @@ class SettingsView:
|
||||
C.ghost(tr("Collect app logs"), ft.Icons.FOLDER_ZIP_OUTLINED, lambda e: app.collect_logs())],
|
||||
spacing=T.S3, run_spacing=T.S2, wrap=True),
|
||||
], spacing=T.S3)), help="diag_bundle"),
|
||||
C.section(tr("Remove FramePort"), C.card(ft.Column([
|
||||
C.body(tr("Removes everything FramePort created: its data and tools on this PC, the Steam entries it "
|
||||
"added, and (optionally) its games and files on the Frame. Your game dumps aren't touched.")),
|
||||
ft.OutlinedButton(tr("Uninstall FramePort…"), icon=ft.Icons.DELETE_FOREVER_ROUNDED,
|
||||
on_click=lambda e: app.uninstall_app(),
|
||||
style=ft.ButtonStyle(color=T.ERROR, side=ft.BorderSide(1, T.soft(T.ERROR, 0.6)),
|
||||
shape=ft.RoundedRectangleBorder(radius=T.RADIUS_SM))),
|
||||
], spacing=T.S3, horizontal_alignment=ft.CrossAxisAlignment.START))),
|
||||
C.section(tr("Installing"), C.card(self.installing(), padding=T.S4), help="launch_test"),
|
||||
C.section(tr("Install links"), C.card(self.links_card(), padding=T.S4), help="install_links"),
|
||||
C.section(tr("Appearance"), C.card(self.appearance(), padding=T.S4), help="ui_scale"),
|
||||
C.section(tr("About"), C.card(ft.Column([
|
||||
"about": C.section(tr("About"), C.card(ft.Column([
|
||||
C.kv(tr("Version"), ver),
|
||||
C.kv(tr("Frame agent"), self.agent_text(), "frame_agent"),
|
||||
C.kv(tr("Source"), ft.TextButton(REPO_URL.removeprefix("https://"), icon=ft.Icons.OPEN_IN_NEW_ROUNDED,
|
||||
url=REPO_URL)),
|
||||
C.kv(tr("Source"), C.ghost(REPO_URL.removeprefix("https://"), ft.Icons.OPEN_IN_NEW_ROUNDED,
|
||||
color=T.ACCENT, url=REPO_URL)),
|
||||
C.meta(tr("Uses OVRPort, Revive (LibreVR), Valve's Lepton and Proton. "
|
||||
"Not affiliated with Valve or Meta.")),
|
||||
], spacing=T.S2))),
|
||||
], spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
"remove": C.section(tr("Remove FramePort"), C.card(ft.Column([
|
||||
C.body(tr("Removes everything FramePort created: its data and tools on this PC, the Steam entries it "
|
||||
"added, and (optionally) its games and files on the Frame. Your game dumps aren't touched.")),
|
||||
C.danger(tr("Uninstall FramePort…"), ft.Icons.DELETE_FOREVER_OUTLINED, lambda e: app.uninstall_app(),
|
||||
outline=True),
|
||||
], spacing=T.S3, horizontal_alignment=ft.CrossAxisAlignment.START))),
|
||||
}
|
||||
# the bottom padding lets the last sections scroll up to the top too
|
||||
self.page_col = ft.Column([ft.Container(sections[k], key=ft.ScrollKey(scroll_key(k))) for k in SECTIONS]
|
||||
+ [ft.Container(height=T.px(240))],
|
||||
spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
return ft.Column([
|
||||
app.top_bar(tr("Settings"), tr("Updates, tools, this PC and where FramePort keeps its data")),
|
||||
ft.Row([self.index_column(), self.page_col], expand=True, spacing=0,
|
||||
vertical_alignment=ft.CrossAxisAlignment.START),
|
||||
], spacing=T.S5, expand=True)
|
||||
@@ -32,8 +32,7 @@ class WelcomeView:
|
||||
def step(self, n: int, head: str, text: str, state: str, *content: ft.Control) -> ft.Control:
|
||||
icon = {"done": (ft.Icons.CHECK_ROUNDED, T.OK), "busy": (None, T.ACCENT),
|
||||
"todo": (None, T.TEXT_3), "error": (ft.Icons.PRIORITY_HIGH_ROUNDED, T.ERROR)}[state]
|
||||
marker = ft.ProgressRing(width=T.px(20), height=T.px(20), stroke_width=T.px(2),
|
||||
color=T.ACCENT) if state == "busy" else \
|
||||
marker = C.spinner("m") if state == "busy" else \
|
||||
ft.Icon(icon[0], size=T.px(18), color=icon[1]) if icon[0] else ft.Text(str(n), weight=ft.FontWeight.W_700,
|
||||
color=T.TEXT_2)
|
||||
return C.card(ft.Row([
|
||||
@@ -63,9 +62,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),
|
||||
@@ -79,9 +76,9 @@ class WelcomeView:
|
||||
else [C.secondary(tr("Set up the Frame"), ft.Icons.ARROW_FORWARD_ROUNDED,
|
||||
lambda e: app.go("frame"))])),
|
||||
self.step(3, tr("Add your games"),
|
||||
tr("A folder with Android games (APK + OBB, e.g. Quest games) or PC VR games."),
|
||||
g_state, ft.Row([C.primary(tr("Scan a folder"), ft.Icons.FOLDER_OPEN_ROUNDED, app.pick_folder),
|
||||
C.ghost(tr("Add an APK file"), ft.Icons.ANDROID_ROUNDED, app.pick_apk)],
|
||||
tr("A folder with Android games (APK + OBB, for example Quest games) or PC VR games."),
|
||||
g_state, ft.Row([C.primary(tr("Scan a folder…"), ft.Icons.FOLDER_OPEN_ROUNDED, app.pick_folder),
|
||||
C.ghost(tr("Add an APK file…"), ft.Icons.ANDROID_ROUNDED, app.pick_apk)],
|
||||
spacing=T.S2)),
|
||||
ft.Row([ft.Container(expand=True),
|
||||
C.primary(tr("Go to my library"), ft.Icons.ARROW_FORWARD_ROUNDED, lambda e: app.finish_welcome())
|
||||
|
||||
@@ -124,7 +124,7 @@ def check(force: bool = False) -> Update | None:
|
||||
up = update_from_release(data) if data else None
|
||||
if not up or not is_newer(up.version):
|
||||
return None
|
||||
if not force and library.setting("update.skipped") == up.version:
|
||||
if not force and hidden(up.version):
|
||||
return None
|
||||
return up
|
||||
|
||||
@@ -154,7 +154,7 @@ def cached_update() -> Update | None:
|
||||
up = update_from_release(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not up or not is_newer(up.version) or library.setting("update.skipped") == up.version:
|
||||
if not up or not is_newer(up.version) or hidden(up.version):
|
||||
return None
|
||||
return up
|
||||
|
||||
@@ -165,9 +165,28 @@ def cache_age() -> float | None:
|
||||
|
||||
|
||||
def skip(version: str) -> None:
|
||||
""""Skip this version": never offered again automatically ("Check now" still finds it)."""
|
||||
library.set_setting("update.skipped", version)
|
||||
|
||||
|
||||
def snooze(version: str, hours: float = 24) -> None:
|
||||
"""The banner's "Later": hide this version for a while (a day by default), then offer it again."""
|
||||
library.set_setting("update.snoozed", {"version": version, "until": time.time() + hours * 3600})
|
||||
|
||||
|
||||
def hidden(version: str) -> bool:
|
||||
"""True when the user skipped this version, or snoozed it and the snooze hasn't run out yet."""
|
||||
if library.setting("update.skipped") == version:
|
||||
return True
|
||||
snoozed = library.setting("update.snoozed")
|
||||
if isinstance(snoozed, dict) and snoozed.get("version") == version:
|
||||
try:
|
||||
return time.time() < float(snoozed.get("until") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------- how installed
|
||||
def running_executable() -> Path | None:
|
||||
try:
|
||||
|
||||
@@ -26,11 +26,11 @@ def test_no_action_without_frame_work_or_while_charging_or_paused_for_another_re
|
||||
|
||||
def test_plugged_in_but_draining_counts_as_not_charging():
|
||||
weak = {"percent": 12, "status": "Discharging", "plugged": True, "draining": True}
|
||||
assert B.advice(weak, True, None, True) == "pause" and B.low(weak) and B.label(weak) == "12 %"
|
||||
assert B.advice(weak, True, None, True) == "pause" and B.low(weak) and B.label(weak) == "12%"
|
||||
|
||||
|
||||
def test_label():
|
||||
assert B.label(bat(76)) == "76 %" and B.label(bat(76, True)) == "76 % ⚡" and B.label(None) == ""
|
||||
assert B.label(bat(76)) == "76%" and B.label(bat(76, True)) == "76% ⚡" and B.label(None) == ""
|
||||
assert B.low(bat(20)) and not B.low(bat(20, True)) and not B.low(bat(60))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""The sidebar's live Frame card: display values from a monitor sample (ui/frame_card.py, no Flet)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from frameport.ui import frame_card as FC
|
||||
|
||||
|
||||
def sample(**game):
|
||||
g = {"package": "com.example.game", "title": "Example Game", "kind": "quest", "elapsed": 27 * 60 + 10,
|
||||
"fps": 71.6}
|
||||
g.update(game)
|
||||
return {"t": 1000.0, "games": [g], "battery": {"percent": 76, "plugged": False, "draining": True}}
|
||||
|
||||
|
||||
def test_no_game_no_row():
|
||||
assert FC.now_playing(None) is None
|
||||
assert FC.now_playing({"games": []}) is None
|
||||
assert FC.now_playing({"battery": {"percent": 50}}) is None
|
||||
|
||||
|
||||
def test_now_playing_values():
|
||||
np = FC.now_playing(sample(), [72.0] * 10)
|
||||
assert np["package"] == "com.example.game" and np["title"] == "Example Game"
|
||||
assert np["minutes"] == 27 and "27 min" in np["line"]
|
||||
assert np["fps_text"] == "72" and np["level"] == "ok" and np["target"] == 72
|
||||
|
||||
|
||||
def test_fps_levels_against_the_refresh_rate():
|
||||
history = [90.0] * 20 # aiming for 90 Hz
|
||||
assert FC.now_playing(sample(fps=78.0), history)["level"] == "warn" # < 90 %
|
||||
assert FC.now_playing(sample(fps=60.0), history)["level"] == "error" # < 75 %
|
||||
assert FC.now_playing(sample(fps=89.5), history)["level"] == "ok"
|
||||
|
||||
|
||||
def test_missing_fps_and_title():
|
||||
np = FC.now_playing(sample(fps=None, title=None, elapsed=None))
|
||||
assert np["fps_text"] == "–" and np["level"] == "none" and np["target"] is None
|
||||
assert np["title"] == "com.example.game" and np["minutes"] is None and np["line"]
|
||||
|
||||
|
||||
def test_battery_ring():
|
||||
assert FC.battery_ring(None) is None and FC.battery_ring({}) is None
|
||||
r = FC.battery_ring({"percent": 76, "plugged": False, "draining": True})
|
||||
assert r == {"value": 0.76, "text": "76", "level": "ok"}
|
||||
assert FC.battery_ring({"percent": 12, "plugged": False, "draining": True})["level"] == "warn"
|
||||
assert FC.battery_ring({"percent": 40, "plugged": True, "draining": False})["level"] == "charging"
|
||||
assert FC.battery_ring({"percent": 140})["value"] == 1.0
|
||||
|
||||
|
||||
def test_fps_history_covers_two_minutes():
|
||||
h = FC.fps_history()
|
||||
for i in range(300): # one sample a second for 5 minutes
|
||||
h.add("fps", 72.0, 1000.0 + i)
|
||||
assert len(h.get("fps")) == FC.FPS_POINTS and FC.FPS_POINTS * FC.FPS_BUCKET == 120
|
||||
|
||||
|
||||
def test_uploading():
|
||||
assert FC.uploading(SimpleNamespace(stage="Upload game (3 files, 1.2 GiB)"))
|
||||
assert not FC.uploading(SimpleNamespace(stage="Finalizing"))
|
||||
assert not FC.uploading(None)
|
||||
@@ -13,7 +13,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
UI = ROOT / "src" / "frameport" / "ui"
|
||||
# calls whose first argument is text shown to the user
|
||||
TEXT_CALLS = {"body", "meta", "title", "h2", "primary", "secondary", "ghost", "pill", "callout", "toast", "switch",
|
||||
"section", "Text", "TextButton", "empty_state", "confirm", "kv", "status_row"}
|
||||
"section", "Text", "TextButton", "empty_state", "confirm", "kv"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Library "On your Frame" shelf (which games, order, limit, when it hides) and the cover tint helpers."""
|
||||
import colorsys
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.artwork.thumbs import dominant_rgb, tint_from_rgb
|
||||
from frameport.ui.views.library import DEFAULT_FILTERS, filters_active, shelf_games
|
||||
|
||||
|
||||
def g(pkg, played=0, sha=None, title=None):
|
||||
return {"package": pkg, "title": title or pkg, "last_played": played,
|
||||
"build": {"sha256": sha} if sha else {}}
|
||||
|
||||
|
||||
FRAME = {"installed": [{"package": "a", "sha256": "1"}, {"package": "b", "sha256": "old"},
|
||||
{"package": "c", "sha256": "1"}]}
|
||||
GAMES = [g("a", 10, "1"), g("b", 30, "1"), g("c", 0, "1"), g("d", 99, "1")] # d isn't on the Frame
|
||||
|
||||
|
||||
def names(games):
|
||||
return [x["package"] for x in games]
|
||||
|
||||
|
||||
def test_shelf_installed_most_recent_first():
|
||||
# outdated (b) counts as on the Frame; d isn't installed; never played (c) last
|
||||
assert names(shelf_games(GAMES, dict(DEFAULT_FILTERS), FRAME, True)) == ["b", "a", "c"]
|
||||
|
||||
|
||||
def test_shelf_limit():
|
||||
games = [g(f"p{i}", i) for i in range(12)]
|
||||
frame = {"installed": [{"package": x["package"]} for x in games]}
|
||||
out = shelf_games(games, dict(DEFAULT_FILTERS), frame, True)
|
||||
assert len(out) == 8 and out[0]["package"] == "p11"
|
||||
assert len(shelf_games(games, dict(DEFAULT_FILTERS), frame, True, limit=3)) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [{"q": "x"}, {"where": "frame"}, {"platform": "pcvr"}, {"status": "works"},
|
||||
{"tags": ["Favorite"]}])
|
||||
def test_shelf_hides_while_filtering(change):
|
||||
f = {**DEFAULT_FILTERS, **change}
|
||||
assert filters_active(f)
|
||||
assert shelf_games(GAMES, f, FRAME, True) == []
|
||||
|
||||
|
||||
def test_shelf_ignores_sort_and_blank_search():
|
||||
f = {**DEFAULT_FILTERS, "sort": "size", "q": " "}
|
||||
assert not filters_active(f)
|
||||
assert shelf_games(GAMES, f, FRAME, True)
|
||||
|
||||
|
||||
def test_shelf_hides_without_frame_but_stays_in_select_mode():
|
||||
f = dict(DEFAULT_FILTERS)
|
||||
assert shelf_games(GAMES, f, FRAME, False) == []
|
||||
assert shelf_games(GAMES, f, None, False) == []
|
||||
# select mode keeps it: hiding it shifted the grid under a drag-select
|
||||
assert shelf_games(GAMES, f, FRAME, True, select_mode=True) == shelf_games(GAMES, f, FRAME, True)
|
||||
assert shelf_games(GAMES, f, {"installed": []}, True) == []
|
||||
|
||||
|
||||
def hls(hex_):
|
||||
return colorsys.rgb_to_hls(*(int(hex_[i:i + 2], 16) / 255 for i in (1, 3, 5)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rgb", [(255, 0, 0), (0, 0, 0), (255, 255, 255), (20, 200, 240), (128, 128, 128),
|
||||
(250, 240, 30), (10, 5, 40)])
|
||||
def test_tint_is_clamped(rgb):
|
||||
out = tint_from_rgb(rgb)
|
||||
assert out.startswith("#") and len(out) == 7
|
||||
_, lum, s = hls(out)
|
||||
assert 0.35 - 0.01 <= lum <= 0.55 + 0.01
|
||||
assert s <= 0.55 + 0.01
|
||||
|
||||
|
||||
def test_tint_keeps_hue_and_mild_colours():
|
||||
h0 = colorsys.rgb_to_hls(20 / 255, 200 / 255, 240 / 255)[0]
|
||||
assert abs(hls(tint_from_rgb((20, 200, 240)))[0] - h0) < 0.02
|
||||
assert tint_from_rgb((115, 89, 64)) == "#735940" # already inside the range: unchanged
|
||||
|
||||
|
||||
def test_dominant_rgb_prefers_colour():
|
||||
black, white, red, blue = (5, 5, 5), (250, 250, 250), (200, 30, 30), (30, 30, 200)
|
||||
assert dominant_rgb([(100, black), (80, white), (10, red), (20, blue)]) == blue
|
||||
assert dominant_rgb([(100, black), (5, (120, 120, 120))]) == black # nothing colourful: the most common
|
||||
assert dominant_rgb([]) is None
|
||||
|
||||
|
||||
def test_compute_tint_cached_next_to_the_art(tmp_path, monkeypatch):
|
||||
from PIL import Image
|
||||
|
||||
from frameport.artwork import thumbs
|
||||
|
||||
src = tmp_path / "portrait.png"
|
||||
im = Image.new("RGB", (60, 90), (8, 8, 8))
|
||||
im.paste((40, 90, 220), (0, 0, 60, 30)) # a third blue on black
|
||||
im.save(src)
|
||||
monkeypatch.setattr(thumbs, "pick", lambda pkg, kinds: src)
|
||||
monkeypatch.setattr(thumbs, "_tints", {})
|
||||
assert thumbs.cached_tint("x.tint") == (False, None)
|
||||
tint = thumbs.compute_tint("x.tint")
|
||||
h, lum, s = hls(tint)
|
||||
assert 0.55 < h < 0.7 and 0.35 <= lum <= 0.56 and s <= 0.56 # blue, clamped
|
||||
assert thumbs.cached_tint("x.tint") == (True, tint)
|
||||
cached = list(tmp_path.glob("t_tint_*.json"))
|
||||
assert len(cached) == 1
|
||||
cached[0].write_text('{"tint": "#123456"}', encoding="utf-8") # the cached answer is used: no image work
|
||||
assert thumbs.compute_tint("x.tint") == "#123456"
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Library type-to-search: which key presses type a character and when they go to the search field."""
|
||||
import pytest
|
||||
|
||||
from frameport.ui.views.library import should_capture, typed_char
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key,shift,want", [
|
||||
("A", False, "a"), ("A", True, "A"), ("z", False, "z"),
|
||||
("1", False, "1"), ("1", True, "!"), ("0", False, "0"),
|
||||
(" ", False, " "), ("Space", False, " "),
|
||||
("-", False, "-"), ("-", True, "_"), (".", False, "."), ("'", False, "'"), (",", False, ","),
|
||||
("&", False, "&"), ("Numpad 7", False, "7"), ("Numpad Decimal", False, "."),
|
||||
])
|
||||
def test_typed_char_printable(key, shift, want):
|
||||
assert typed_char(key, shift) == want
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["Shift Left", "Control Left", "Alt", "Meta Left", "Caps Lock", "Arrow Left",
|
||||
"Arrow Down", "Home", "Page Up", "F1", "F12", "Escape", "Enter", "Tab",
|
||||
"Backspace", "Delete", "", "\t"])
|
||||
def test_typed_char_named_keys(key):
|
||||
assert typed_char(key) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mods", [dict(ctrl=True), dict(alt=True), dict(meta=True), dict(ctrl=True, shift=True)])
|
||||
def test_typed_char_shortcuts(mods):
|
||||
assert typed_char("F", **mods) is None
|
||||
assert typed_char("1", **mods) is None
|
||||
|
||||
|
||||
def test_should_capture():
|
||||
assert should_capture("library", False, False, "a")
|
||||
assert not should_capture("game", False, False, "a") # the game page has no search
|
||||
assert not should_capture("files", False, False, "a")
|
||||
assert not should_capture("library", True, False, "a") # a dialog has the keyboard
|
||||
assert not should_capture("library", False, True, "a") # the field types it itself
|
||||
assert not should_capture("library", False, False, None)
|
||||
assert not should_capture("library", False, False, " ") # Space on a focused button, not a search
|
||||
assert should_capture("library", False, False, " ", "arc")
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Menu structure: game menus (Library right-click, game page "…"), the Monitor process menu and menu_items."""
|
||||
import flet as ft
|
||||
|
||||
from frameport.ui import components as C
|
||||
from frameport.ui import theme as T
|
||||
from frameport.ui.menus import Header, MenuState, menu_sections, process_menu
|
||||
|
||||
DESTRUCTIVE = {"Uninstall from Frame…", "Uninstall from this PC…", "Remove from library…"}
|
||||
QUEST = {"package": "com.q", "title": "Q"}
|
||||
RIFT = {"package": "rift.r", "kind": "rift", "title": "R"}
|
||||
LINUX = {"package": "linux.l", "kind": "linux", "title": "L"}
|
||||
|
||||
|
||||
def _key(k):
|
||||
return k
|
||||
|
||||
|
||||
def _play(where="Frame"):
|
||||
return (f"Play on {where}", ft.Icons.PLAY_ARROW_ROUNDED, f"play_{where}")
|
||||
|
||||
|
||||
def _install(label="Reinstall on Frame"):
|
||||
return (label, "fp:frame", "install")
|
||||
|
||||
|
||||
def _states():
|
||||
"""(name, game, state) for the cases the menus have to handle."""
|
||||
return [
|
||||
("quest on Frame", QUEST, MenuState(connected=True, on_frame=True, plays=[_play()], installs=[_install()],
|
||||
settings=True, selectable=True)),
|
||||
("quest not installed", QUEST, MenuState(connected=True, installs=[_install("Install on Frame")],
|
||||
settings=True, selectable=True)),
|
||||
("rift on PC", RIFT, MenuState(on_pc=True, plays=[_play("this PC")], programs=True, selectable=True,
|
||||
installs=[_install("Reinstall on this PC")])),
|
||||
("linux on Frame", LINUX, MenuState(connected=True, on_frame=True, plays=[_play()], installs=[_install()],
|
||||
selectable=True)),
|
||||
("job running", QUEST, MenuState(connected=True, on_frame=True, job=True, settings=True, selectable=True)),
|
||||
]
|
||||
|
||||
|
||||
def _items(entries):
|
||||
return [e for e in entries if e is not None and not isinstance(e, Header)]
|
||||
|
||||
|
||||
def _section_of(entries, label):
|
||||
"""The header the item sits under (None = top section)."""
|
||||
head = None
|
||||
for e in entries:
|
||||
if isinstance(e, Header):
|
||||
head = e.label
|
||||
elif e is not None and e[0] == label:
|
||||
return head
|
||||
raise AssertionError(f"{label} not in menu")
|
||||
|
||||
|
||||
def test_library_menu_structure():
|
||||
for name, g, st in _states():
|
||||
entries = C.menu_entries(menu_sections(g, st, _key))
|
||||
labels = [e[0] for e in _items(entries)]
|
||||
assert "Select" in labels[:4], (name, labels)
|
||||
assert "Type on Frame" not in labels
|
||||
# destructive items close the menu, after a divider
|
||||
n = sum(1 for lbl in labels if lbl in DESTRUCTIVE)
|
||||
assert labels[-n:] == [lbl for lbl in labels if lbl in DESTRUCTIVE] and labels[-1] == "Remove from library…"
|
||||
first = next(i for i, e in enumerate(entries) if e is not None and not isinstance(e, Header)
|
||||
and e[0] in DESTRUCTIVE)
|
||||
assert entries[first - 1] is None, name
|
||||
# every header has items under it; every item has an icon
|
||||
for i, e in enumerate(entries):
|
||||
if isinstance(e, Header):
|
||||
assert i + 1 < len(entries) and entries[i + 1] is not None and not isinstance(entries[i + 1], Header)
|
||||
assert all(e[1] for e in _items(entries)), name
|
||||
if st.on_frame and not st.job:
|
||||
assert _section_of(entries, "Run launch test on Frame") == "Troubleshoot"
|
||||
if st.plays and not st.job:
|
||||
assert labels[0].startswith("Play on")
|
||||
|
||||
|
||||
def test_job_items_first_and_page_menu_leaves_out_its_buttons():
|
||||
_, g, st = _states()[-1]
|
||||
labels = [e[0] for e in _items(menu_sections(g, st, _key))]
|
||||
assert labels[:2] == ["Show progress", "Cancel"]
|
||||
for name, g, st in _states():
|
||||
st.quick = False
|
||||
labels = [e[0] for e in _items(menu_sections(g, st, _key))]
|
||||
for gone in ("Select", "Open game page", "Show progress", "Cancel", "Play on Frame", "Reinstall on Frame",
|
||||
"Run launch test on Frame", "Uninstall from Frame…"):
|
||||
assert gone not in labels, (name, gone)
|
||||
assert labels[-1] == "Remove from library…"
|
||||
|
||||
|
||||
def test_sections_and_conditions():
|
||||
_, g, st = _states()[0]
|
||||
entries = menu_sections(g, st, _key)
|
||||
heads = [e.label for e in entries if isinstance(e, Header)]
|
||||
assert heads == ["Artwork", "Recipe", "Troubleshoot"]
|
||||
assert _section_of(entries, "Analyze again") == "Troubleshoot"
|
||||
assert _section_of(entries, "Update Steam art on Frame") == "Artwork"
|
||||
assert _section_of(entries, "Share working recipe…") == "Recipe"
|
||||
linux = menu_sections(LINUX, _states()[3][2], _key)
|
||||
assert "Recipe" not in [e.label for e in linux if isinstance(e, Header)]
|
||||
rift = [e[0] for e in _items(menu_sections(RIFT, _states()[2][2], _key))]
|
||||
assert {"Run launch test on this PC", "Check game files", "Change program…", "Uninstall from this PC…"} <= set(rift)
|
||||
assert "Analyze again" not in rift
|
||||
# the reset icon isn't the Frame's Restart icon
|
||||
reset = next(e for e in _items(entries) if e[0] == "Reset to suggested recipe…")
|
||||
assert reset[1] != ft.Icons.RESTART_ALT_ROUNDED
|
||||
|
||||
|
||||
def test_menu_entries_trim_headers_and_dividers():
|
||||
act = ("A", ft.Icons.ADD_ROUNDED, None)
|
||||
raw = [None, Header("Empty"), None, Header("Kept"), act, None, None, Header("Last")]
|
||||
assert C.menu_entries(raw) == [Header("Kept"), act]
|
||||
items = C.menu_items([act, None, Header("H"), act])
|
||||
assert items[0].height == items[3].height == T.px(32) and items[2].height == T.px(24)
|
||||
assert items[1].content is None # divider
|
||||
assert items[2].disabled and items[0].disabled # no handler: shown disabled
|
||||
|
||||
|
||||
def test_process_menu():
|
||||
proc = {"name": "x", "pid": 5, "game": "com.q"}
|
||||
labels = [e[0] if e else "—" for e in process_menu(proc, _key)]
|
||||
assert labels == ["Copy name", "Copy PID 5", "—", "End…", "Force quit…", "—", "End game…"]
|
||||
locked = process_menu({**proc, "locked": True}, _key)
|
||||
prot = next(e for e in locked if e and e[0] == "Protected by SteamOS")
|
||||
assert prot[2] is None and "End…" not in [e[0] for e in locked if e]
|
||||
group = [e[0] if e else "—" for e in process_menu({"kind": "group", "count": 3, "name": "g"}, _key)]
|
||||
assert group == ["Show its processes", "Copy name", "—", "End all 3…", "Force quit all 3…"]
|
||||
end = next(e for e in process_menu(proc, _key) if e and e[0] == "End…")
|
||||
assert end[1] != ft.Icons.CLOSE_ROUNDED
|
||||
@@ -199,7 +199,7 @@ def test_formatting():
|
||||
assert M.fmt_bytes(None) == "–" and M.fmt_rate(2048) == "2 KB/s"
|
||||
assert M.fmt_watts(3.558) == "3.56 W" and M.fmt_watts(12.34) == "12.3 W"
|
||||
assert M.fmt_duration(45) == "45 s" and M.fmt_duration(720) == "12 min" and M.fmt_duration(3900) == "1 h 05 min"
|
||||
assert M.fmt_pct(33.4) == "33 %" and M.fmt_pct(None) == "–"
|
||||
assert M.fmt_pct(33.4) == "33%" and M.fmt_pct(None) == "–"
|
||||
|
||||
|
||||
def test_levels():
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""MonitorHub: one shared `_monitor` stream for every subscriber (union of modules, fastest interval, fan-out,
|
||||
reconnect), and the agent's per-module collection (monitor_plan, {"modules": …})."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from test_monitor_agent import fa, monitor # noqa: F401 (fixture + helper)
|
||||
|
||||
from frameport.frame import monitor as M
|
||||
from frameport.frame import monitor_hub as H
|
||||
|
||||
|
||||
class FakeSession:
|
||||
instances: list = []
|
||||
|
||||
def __init__(self, frame, on_sample, on_end, agent=63):
|
||||
self.frame, self.on_sample, self.on_end = frame, on_sample, on_end
|
||||
self.static = {"agent": agent}
|
||||
self.calls: list = []
|
||||
self.closed = False
|
||||
FakeSession.instances.append(self)
|
||||
|
||||
def set_interval(self, s):
|
||||
self.calls.append(("interval", s))
|
||||
|
||||
def set_modules(self, m):
|
||||
self.calls.append(("modules", m))
|
||||
return True
|
||||
|
||||
def set_filter(self, w):
|
||||
self.calls.append(("filter", w))
|
||||
|
||||
def pause(self, p):
|
||||
self.calls.append(("pause", p))
|
||||
|
||||
def kill(self, pid, sig="TERM", force=False):
|
||||
return {"ended": True, "pid": pid, "sig": sig, "force": force}
|
||||
|
||||
def end_game(self, pkg):
|
||||
return {"ended": True, "package": pkg}
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
# test helpers
|
||||
def push(self, sample):
|
||||
self.on_sample(sample)
|
||||
|
||||
def end(self, why="gone"):
|
||||
self.closed = True
|
||||
self.on_end(why)
|
||||
|
||||
|
||||
class Timers:
|
||||
def __init__(self):
|
||||
self.pending: list = []
|
||||
|
||||
def __call__(self, delay, fn):
|
||||
t = SimpleNamespace(delay=delay, fn=fn, cancelled=False)
|
||||
t.cancel = lambda: setattr(t, "cancelled", True)
|
||||
self.pending.append(t)
|
||||
return t
|
||||
|
||||
def fire(self):
|
||||
due, self.pending = self.pending, []
|
||||
for t in due:
|
||||
if not t.cancelled:
|
||||
t.fn()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub():
|
||||
FakeSession.instances = []
|
||||
timers = Timers()
|
||||
target = SimpleNamespace(frame="frame")
|
||||
box = {"target": target}
|
||||
h = H.MonitorHub(lambda: box["target"], session_factory=FakeSession, spawn=lambda fn: fn(), timer=timers)
|
||||
h.timers, h.box = timers, box
|
||||
return h
|
||||
|
||||
|
||||
def sub(modules, interval, paused=False):
|
||||
s = H.Subscriber("x", H.norm_modules(modules), interval, lambda _s: None)
|
||||
s.paused = paused
|
||||
return s
|
||||
|
||||
|
||||
def test_merge():
|
||||
assert H.merge([]) == (None, None)
|
||||
assert H.merge([sub({"cpu"}, 2), sub({"games", "battery"}, 1)]) == (frozenset({"cpu", "games", "battery"}), 1.0)
|
||||
assert H.merge([sub({"cpu"}, 2), sub("all", 5)]) == ("all", 2.0)
|
||||
assert H.merge([sub({"cpu"}, 2), sub("all", 0.1, paused=True)]) == (frozenset({"cpu"}), 2.0)
|
||||
assert H.merge([sub("all", 0.1, paused=True)]) == (None, None)
|
||||
assert H.merge([sub({"cpu"}, 0.3)]) == (frozenset({"cpu"}), 0.25) # snapped to what the agent accepts
|
||||
assert H.snap_interval(0.01) == 0.1 and H.snap_interval(10) == 5.0
|
||||
|
||||
|
||||
def test_subscribe_starts_once_and_reconfigures(hub):
|
||||
states = []
|
||||
hub.subscribe("card", {"battery", "games"}, 2, lambda s: None, lambda *a: states.append(a))
|
||||
assert len(FakeSession.instances) == 1
|
||||
s = FakeSession.instances[0]
|
||||
assert s.calls == [("interval", 2.0), ("modules", frozenset({"battery", "games"}))]
|
||||
assert states == [("card", "connecting", None), ("card", "live", None)]
|
||||
assert hub.static == {"agent": 63}
|
||||
later = []
|
||||
hub.subscribe("tab", "all", 0.5, lambda s: None, lambda *a: later.append(a))
|
||||
assert len(FakeSession.instances) == 1 # same stream, reconfigured
|
||||
assert s.calls[-2:] == [("interval", 0.5), ("modules", "all")]
|
||||
assert later == [("tab", "live", None)]
|
||||
hub.update("tab", interval=0.1)
|
||||
assert s.calls[-1] == ("interval", 0.1)
|
||||
hub.unsubscribe("tab")
|
||||
assert s.calls[-2:] == [("interval", 2.0), ("modules", frozenset({"battery", "games"}))] and not s.closed
|
||||
hub.unsubscribe("card")
|
||||
assert s.closed and hub.session is None and hub.state == "idle"
|
||||
hub.subscribe("card", {"cpu"}, 1, lambda s: None)
|
||||
assert len(FakeSession.instances) == 2 # lazily started again
|
||||
|
||||
|
||||
def test_pause_excludes_and_stops(hub):
|
||||
got = {"a": [], "b": []}
|
||||
hub.subscribe("a", {"cpu"}, 1, got["a"].append)
|
||||
hub.subscribe("b", "all", 0.25, got["b"].append)
|
||||
s = FakeSession.instances[0]
|
||||
hub.pause("b", True)
|
||||
assert s.calls[-2:] == [("interval", 1.0), ("modules", frozenset({"cpu"}))]
|
||||
s.push({"n": 1})
|
||||
assert got == {"a": [{"n": 1}], "b": []}
|
||||
hub.pause("a", True) # nobody active: the stream ends
|
||||
assert s.closed and hub.session is None
|
||||
hub.pause("b", False)
|
||||
assert len(FakeSession.instances) == 2 and FakeSession.instances[1].calls[-1] == ("interval", 0.25)
|
||||
|
||||
|
||||
def test_fan_out_survives_a_failing_subscriber(hub):
|
||||
got = []
|
||||
|
||||
def bad(_s):
|
||||
raise RuntimeError("view bug")
|
||||
hub.subscribe("bad", "all", 1, bad)
|
||||
hub.subscribe("good", "all", 1, got.append)
|
||||
FakeSession.instances[0].push({"n": 1})
|
||||
FakeSession.instances[0].push({"n": 2})
|
||||
assert got == [{"n": 1}, {"n": 2}]
|
||||
|
||||
|
||||
def test_reconnect_after_loss(hub):
|
||||
states = []
|
||||
hub.subscribe("tab", {"procs"}, 1, lambda s: None, lambda *a: states.append(a[1:]))
|
||||
hub.set_filter("all")
|
||||
hub.pause_stream(True)
|
||||
first = FakeSession.instances[0]
|
||||
first.end("connection reset")
|
||||
assert hub.session is None and states[-1] == ("lost", "connection reset")
|
||||
assert len(hub.timers.pending) == 1 and hub.timers.pending[0].delay == H.RETRY_SECONDS
|
||||
hub.timers.fire()
|
||||
second = FakeSession.instances[1]
|
||||
assert hub.session is second and states[-1] == ("live", None)
|
||||
# the new stream gets the subscriber's config plus the kept filter and pause
|
||||
assert second.calls == [("interval", 1.0), ("modules", frozenset({"procs"})), ("filter", "all"), ("pause", True)]
|
||||
assert hub.kill(5)["pid"] == 5 and hub.end_game("com.x.y")["package"] == "com.x.y"
|
||||
|
||||
|
||||
def test_retry_cancelled_when_nobody_is_left(hub):
|
||||
hub.subscribe("tab", "all", 1, lambda s: None)
|
||||
FakeSession.instances[0].end()
|
||||
hub.unsubscribe("tab")
|
||||
hub.timers.fire()
|
||||
assert len(FakeSession.instances) == 1 and hub.session is None
|
||||
|
||||
|
||||
def test_error_without_target_then_reconnect(hub):
|
||||
states = []
|
||||
hub.box["target"] = None
|
||||
hub.subscribe("tab", "all", 1, lambda s: None, lambda *a: states.append(a[1]))
|
||||
assert states == ["connecting", "error"] and hub.session is None and not hub.timers.pending
|
||||
with pytest.raises(M.MonitorUnavailable):
|
||||
hub.kill(1)
|
||||
hub.box["target"] = SimpleNamespace(frame="frame")
|
||||
hub.reconnect()
|
||||
assert hub.session is FakeSession.instances[0] and states[-1] == "live"
|
||||
|
||||
|
||||
def test_close_ends_everything(hub):
|
||||
hub.subscribe("tab", "all", 1, lambda s: None)
|
||||
s = FakeSession.instances[0]
|
||||
hub.close()
|
||||
assert s.closed and hub.session is None
|
||||
hub.subscribe("tab", "all", 1, lambda s: None)
|
||||
assert len(FakeSession.instances) == 1
|
||||
|
||||
|
||||
def test_session_set_modules_needs_a_new_agent():
|
||||
sent = []
|
||||
s = M.MonitorSession.__new__(M.MonitorSession)
|
||||
s.send = sent.append
|
||||
s.static = {"agent": 62}
|
||||
assert s.set_modules({"cpu"}) is False and sent == []
|
||||
s.static = {"agent": M.MIN_AGENT_MODULES}
|
||||
assert s.set_modules({"cpu", "battery"}) is True and sent == [{"modules": ["battery", "cpu"]}]
|
||||
assert s.set_modules("all") and sent[-1] == {"modules": "all"}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ agent side
|
||||
def test_monitor_plan(fa): # noqa: F811
|
||||
every = set(fa.MON_MODULES) | {"scan", "gpu_ns"}
|
||||
assert fa.monitor_plan() == every and fa.monitor_plan("all") == every and fa.monitor_plan("cpu") == every
|
||||
assert fa.monitor_plan([]) == frozenset()
|
||||
assert fa.monitor_plan(["battery", "cpu", "bogus"]) == {"battery", "cpu"} # no process scan
|
||||
assert fa.monitor_plan(["games"]) == {"games", "scan", "gpu_ns"}
|
||||
assert fa.monitor_plan(["gpu"]) == {"gpu", "scan", "gpu_ns"} # GPU busy comes from the scanned render fds
|
||||
assert set(fa.MON_MODULES) == set(M.MODULES)
|
||||
|
||||
|
||||
def test_sample_collects_only_requested_modules(fa): # noqa: F811
|
||||
m, clock = monitor(fa)
|
||||
m.set_modules(["battery", "cpu"])
|
||||
scanned = []
|
||||
m.scan = lambda *a: scanned.append(a) # instance attribute, removed below (monkeypatch.undo would undo `fa`)
|
||||
s = m.sample(wall=2000.0)
|
||||
assert set(s) == {"t", "dt", "battery", "cpu", "self_ms"} and not scanned
|
||||
m.set_modules(["games"])
|
||||
del m.scan
|
||||
clock["t"] += 0.25
|
||||
s = m.sample(wall=2000.25)
|
||||
assert "games" in s and s["games"][0]["package"] == "com.example.game" and "procs" not in s and "cpu" not in s
|
||||
m.set_modules(["procs"]) # newly wanted: scanned at once, not 2 s later
|
||||
clock["t"] += 0.25
|
||||
assert "procs" in m.sample(wall=2000.5)
|
||||
m.set_modules("all")
|
||||
clock["t"] += 0.25
|
||||
full = m.sample(wall=2000.75)
|
||||
assert {"cpu", "mem", "psi", "temps", "power", "battery", "gpu", "games"} <= set(full)
|
||||
assert fa.Monitor(clock=lambda: 0.0).static()["modules"] == list(fa.MON_MODULES)
|
||||
|
||||
|
||||
def test_session_modules_message(fa): # noqa: F811
|
||||
class Mon:
|
||||
def __init__(self):
|
||||
self.modules = None
|
||||
|
||||
def static(self):
|
||||
return {"agent": fa.AGENT_VERSION}
|
||||
|
||||
def sample(self):
|
||||
return {"modules": self.modules}
|
||||
|
||||
def set_modules(self, modules):
|
||||
self.modules = modules
|
||||
|
||||
r, wfd = os.pipe()
|
||||
stdin, writer = os.fdopen(r), os.fdopen(wfd, "w")
|
||||
out = io.StringIO()
|
||||
mon = Mon()
|
||||
t = threading.Thread(target=fa.monitor_session, args=(stdin, out, mon), daemon=True)
|
||||
t.start()
|
||||
writer.write(json.dumps({"modules": ["battery"]}) + "\n")
|
||||
writer.flush()
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline and mon.modules != ["battery"]:
|
||||
time.sleep(0.02)
|
||||
writer.close()
|
||||
t.join(5)
|
||||
assert mon.modules == ["battery"] and not t.is_alive()
|
||||
@@ -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 color"),
|
||||
({"colors": {"GLOW": "#FFFFFF"}}, "unknown color"),
|
||||
({"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
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Install transit: job stage names → step + overall progress (ui/transit.py, no Flet)."""
|
||||
import pytest
|
||||
|
||||
from frameport.ui.transit import STEPS, step_of, transit_state
|
||||
|
||||
QUEST = ["Patching the game", "OVRPort (primary)", "Frame fixes (primary)", "sign (primary)", "validate (primary)"]
|
||||
ALT = ["OVRPort (alt)", "Frame fixes (alt)", "sign (alt)", "validate (alt)"]
|
||||
INSTALL = ["Prepare Frame", "Upload APK", "Upload data (12 files)", "Artwork for the Steam library",
|
||||
"Finalize install", "Add to Steam library"]
|
||||
|
||||
|
||||
def run(stages, **kw):
|
||||
return transit_state(stage=stages[-1] if stages else "", stages=stages, **kw)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stage,step", [
|
||||
("Patching the game", 0), ("Checking the game (installing it as it is)", 0), ("Check game files", 0),
|
||||
("OVRPort (primary)", 1), ("Frame fixes (alt)", 1), ("Revive", 1), ("sign (primary)", 2), ("validate (alt)", 2),
|
||||
("Prepare Frame", 3), ("Upload APK", 3), ("Upload data (3 files)", 3), ("Upload game (40 files, 3.1 GiB)", 3),
|
||||
("Artwork for the Steam library", 4), ("Finalize install", 4), ("Add to Steam library", 4),
|
||||
("Launch test (headless)", 5), ("Launch test: passed", 5), ("Installed", len(STEPS)), ("Something new", None)])
|
||||
def test_step_of(stage, step):
|
||||
assert step_of(stage) == step
|
||||
|
||||
|
||||
def test_quest_primary_and_alt_never_go_back():
|
||||
s = run(QUEST)
|
||||
assert (s.step, s.label) == (2, "sign")
|
||||
s = run(QUEST + ALT[:1]) # the alternate build's OVRPort after the primary's validate
|
||||
assert s.step == 2
|
||||
assert s.fraction == pytest.approx(2 / 6)
|
||||
|
||||
|
||||
def test_upload_fraction_and_detail():
|
||||
s = run(QUEST + ALT + INSTALL[:2], fraction=0.5, speed="36.4 MB/s · ~1 min left")
|
||||
assert s.step == 3 and s.label == "upload"
|
||||
assert s.fraction == pytest.approx(3.5 / 6)
|
||||
assert s.detail == "50% · 36.4 MB/s · ~1 min left"
|
||||
assert s.target == "frame" and not s.waiting and not s.failed
|
||||
|
||||
|
||||
def test_install_and_test_steps():
|
||||
assert run(QUEST + INSTALL).step == 4
|
||||
s = run(QUEST + INSTALL + ["Launch test (headless)"])
|
||||
assert (s.step, s.label) == (5, "test")
|
||||
tested = QUEST + INSTALL + ["Launch test (headless)", "Launch test: passed"]
|
||||
s = transit_state("done", "Launch test: passed", tested)
|
||||
assert s.fraction == 1.0 and s.label == "done"
|
||||
|
||||
|
||||
def test_launch_test_off_ends_at_installed():
|
||||
s = run(QUEST + INSTALL + ["Installed"])
|
||||
assert s.fraction == 1.0 and s.label == "done"
|
||||
|
||||
|
||||
def test_rift_and_as_is():
|
||||
rift = ["Checking the game", "Check game files", "Revive", "Prepare Frame", "Upload game (40 files, 3.1 GiB)"]
|
||||
s = run(rift, fraction=0.25)
|
||||
assert s.step == 3 and s.fraction == pytest.approx(3.25 / 6)
|
||||
s = run(["Checking the game", "Checking the game (installing it as it is)"])
|
||||
assert s.step == 0 and s.fraction == 0.0
|
||||
|
||||
|
||||
def test_test_only_job():
|
||||
s = transit_state(stage="Launch test (headless)", stages=["Launch test (headless)"], kind="test")
|
||||
assert s.step == 5
|
||||
s = transit_state(stage="", stages=[], kind="test")
|
||||
assert s.step == 5 # starting: nothing before the test to do
|
||||
|
||||
|
||||
def test_pc_install():
|
||||
s = run(["Checking the game", "Check game files", "Revive", "Prepare this PC", "Add to Steam library (this PC)"],
|
||||
to="pc")
|
||||
assert s.target == "pc" and s.step == 4
|
||||
s = transit_state("done", "Installed", ["Checking the game", "Prepare this PC", "Installed"], to="pc")
|
||||
assert s.fraction == 1.0
|
||||
|
||||
|
||||
def test_waiting_keeps_the_step():
|
||||
stages = QUEST + ["Prepare Frame", "Upload APK"]
|
||||
s = transit_state("queued", "Waiting for the Frame", stages, kind="install")
|
||||
assert s.waiting and s.step == 3 and s.fraction == pytest.approx(3 / 6)
|
||||
# resumed: the job runs again from the start
|
||||
s = run(stages + ["Patching the game"])
|
||||
assert s.step == 0 and not s.waiting
|
||||
|
||||
|
||||
def test_failed_keeps_the_step():
|
||||
s = transit_state("failed", "Upload APK", QUEST + ["Prepare Frame", "Upload APK"], fraction=0.3)
|
||||
assert s.failed and s.step == 3 and s.fraction < 1.0
|
||||
|
||||
|
||||
def test_unknown_stage_keeps_the_previous_step():
|
||||
s = run(QUEST + INSTALL[:1] + ["Ensuring Proton"])
|
||||
assert s.step == 3
|
||||
|
||||
|
||||
def test_upload_never_goes_back_between_apk_and_data():
|
||||
from frameport.ui.transit import Monotonic
|
||||
|
||||
mono = Monotonic()
|
||||
base = QUEST + INSTALL[:2]
|
||||
seen = [mono.follow("j", run(base, fraction=f)).fraction for f in (0.0, 0.1, 0.2)]
|
||||
# "Upload data" starts: the job's fraction is cleared until the first tick, then continues over all files
|
||||
seen += [mono.follow("j", run(QUEST + INSTALL[:3], fraction=f)).fraction for f in (None, 0.2, 0.6, 1.0)]
|
||||
seen.append(mono.follow("j", run(QUEST + INSTALL[:4])).fraction)
|
||||
assert seen == sorted(seen)
|
||||
assert seen[3] == pytest.approx(3.2 / 6) and seen[-2] == pytest.approx(4 / 6)
|
||||
|
||||
|
||||
def test_monotonic_resets_for_another_job_or_a_new_run():
|
||||
from frameport.ui.transit import Monotonic
|
||||
|
||||
mono = Monotonic()
|
||||
assert mono.follow("a", run(QUEST + INSTALL[:3], fraction=0.9)).fraction == pytest.approx(3.9 / 6)
|
||||
assert mono.follow("a", run(["Patching the game"])).fraction == 0.0 # started over
|
||||
mono.follow("a", run(QUEST + INSTALL[:3], fraction=0.9))
|
||||
assert mono.follow("b", run(QUEST[:1])).fraction == 0.0
|
||||
@@ -0,0 +1,140 @@
|
||||
"""GUI consistency guard: dialogs, spinners and inputs come from the shared helpers in ui/components.py (C.dialog,
|
||||
C.viewer, C.spinner, C.field/C.search/C.dropdown), icons are line icons (ROUNDED, or OUTLINED where ROUNDED is
|
||||
filled), and radii are tokens."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import flet as ft
|
||||
|
||||
UI = Path(__file__).resolve().parents[1] / "src" / "frameport" / "ui"
|
||||
COMPONENTS = UI / "components.py"
|
||||
# controls only components.py may create (everything else uses its helper)
|
||||
SHARED = {"AlertDialog": "C.dialog / C.viewer", "ProgressRing": "C.spinner", "TextField": "C.field / C.search",
|
||||
"Dropdown": "C.dropdown", "SegmentedButton": "C.segmented"}
|
||||
# justified exceptions: (file name, control) -> why
|
||||
ALLOWED: dict[tuple[str, str], str] = {}
|
||||
|
||||
|
||||
def _files():
|
||||
return sorted(UI.rglob("*.py"))
|
||||
|
||||
|
||||
def _ft_calls(tree: ast.AST):
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and \
|
||||
isinstance(node.func.value, ast.Name) and node.func.value.id == "ft":
|
||||
yield node.func.attr, node
|
||||
|
||||
|
||||
def test_shared_controls_only_from_components():
|
||||
bad = []
|
||||
for path in _files():
|
||||
if path == COMPONENTS:
|
||||
continue
|
||||
for name, node in _ft_calls(ast.parse(path.read_text(encoding="utf-8"))):
|
||||
if name in SHARED and (path.name, name) not in ALLOWED:
|
||||
bad.append(f"{path.relative_to(UI)}:{node.lineno}: ft.{name}( -> use {SHARED[name]}")
|
||||
assert not bad, "\n".join(bad)
|
||||
|
||||
|
||||
# The icon rule is visual weight, not the suffix: every icon is a line icon. Material's ROUNDED family is the line
|
||||
# style for most glyphs (tune, delete_outline, folder_open, …) but FILLED for these, so they use their OUTLINED twin
|
||||
# (checked side by side in a render: e.g. ROUNDED monitor_heart / bedtime are solid shapes next to line icons).
|
||||
# Status marks (check_circle, error, cancel next to them) stay filled on purpose: they're badges, not actions.
|
||||
LINE_OUTLINED = {
|
||||
"BEDTIME", "BUG_REPORT", "BUILD_CIRCLE", "CHECK_BOX", "CLEANING_SERVICES", "CREATE_NEW_FOLDER", "DANGEROUS",
|
||||
"DELETE_FOREVER", "DESCRIPTION", "DO_NOT_DISTURB_ON", "DRIVE_FILE_RENAME_OUTLINE", "DRIVE_FOLDER_UPLOAD",
|
||||
"EXTENSION_OFF", "FILE_OPEN", "FILTER_ALT", "FOLDER", "FOLDER_OFF", "FOLDER_SPECIAL", "FOLDER_ZIP", "GRID_VIEW",
|
||||
"HEALING", "HOME", "IMAGE", "INSERT_DRIVE_FILE", "LABEL", "LIBRARY_ADD", "MONITOR_HEART", "MOVIE",
|
||||
"NEW_RELEASES", "PHOTO_CAMERA", "PIN", "SELL", "SEND", "SNIPPET_FOLDER", "SPORTS_ESPORTS", "STOP_CIRCLE",
|
||||
"STOREFRONT", "STORAGE", "THUMB_UP", "UPLOAD_FILE", "VIDEO_LIBRARY", "VIDEOGAME_ASSET", "VOLUNTEER_ACTIVISM",
|
||||
}
|
||||
|
||||
|
||||
def test_line_style_icons():
|
||||
"""ROUNDED where it is the line style; OUTLINED only for the glyphs whose ROUNDED variant is filled."""
|
||||
names = {m.name for m in ft.Icons}
|
||||
bad = []
|
||||
for path in _files():
|
||||
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
||||
if not (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Attribute) and
|
||||
node.value.attr == "Icons"):
|
||||
continue
|
||||
if node.attr.endswith("_OUTLINED"):
|
||||
base_ = node.attr.removesuffix("_OUTLINED")
|
||||
if base_ + "_ROUNDED" in names and base_ not in LINE_OUTLINED:
|
||||
bad.append(f"{path.relative_to(UI)}:{node.lineno}: Icons.{node.attr} -> Icons.{base_}_ROUNDED")
|
||||
elif node.attr.endswith("_ROUNDED") and node.attr.removesuffix("_ROUNDED") in LINE_OUTLINED:
|
||||
bad.append(f"{path.relative_to(UI)}:{node.lineno}: Icons.{node.attr} is filled -> _OUTLINED")
|
||||
assert not bad, "\n".join(bad)
|
||||
|
||||
|
||||
def test_radii_are_tokens():
|
||||
"""radius=/border_radius= never a bare number in ui/ (T.RADIUS, T.RADIUS_SM, T.RADIUS_XS or T.px(...))."""
|
||||
bad = []
|
||||
for path in _files():
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
# a RadialGradient's radius is a fraction of the box, not pixels
|
||||
fractions = {id(k) for n in ast.walk(tree) if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
|
||||
and n.func.attr == "RadialGradient" for k in n.keywords}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.keyword) and node.arg in ("radius", "border_radius") and \
|
||||
id(node) not in fractions and \
|
||||
isinstance(node.value, ast.Constant) and isinstance(node.value.value, (int, float)):
|
||||
bad.append(f"{path.relative_to(UI)}:{node.value.lineno}: {node.arg}={node.value.value}")
|
||||
assert not bad, "\n".join(bad)
|
||||
|
||||
|
||||
def test_title_sizes_are_tokens():
|
||||
"""C.title(text, size): the size is a token (T.T_DISPLAY …), never a raw number (it wouldn't scale)."""
|
||||
bad = []
|
||||
for path in _files():
|
||||
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
||||
if isinstance(node, ast.Call) and (isinstance(node.func, ast.Attribute) and node.func.attr == "title"
|
||||
or isinstance(node.func, ast.Name) and node.func.id == "title"):
|
||||
sizes = node.args[1:2] + [k.value for k in node.keywords if k.arg == "size"]
|
||||
if any(isinstance(s, ast.Constant) and isinstance(s.value, (int, float)) for s in sizes):
|
||||
bad.append(f"{path.relative_to(UI)}:{node.lineno}")
|
||||
assert not bad, "\n".join(bad)
|
||||
|
||||
|
||||
def test_dialog_helper_style():
|
||||
"""C.dialog: the shared look (12 px corners, semibold title, SURFACE_2, actions at the end, size tokens)."""
|
||||
from frameport.ui import components as C
|
||||
from frameport.ui import theme as T
|
||||
|
||||
d = C.dialog("Title", ft.Text("body"), [C.ghost("Cancel"), C.primary("OK")], size="s")
|
||||
assert d.shape.radius == T.RADIUS and d.bgcolor == T.SURFACE_2
|
||||
assert d.title.weight == ft.FontWeight.W_600
|
||||
assert d.content.width == T.DIALOG_S
|
||||
assert d.actions_alignment == ft.MainAxisAlignment.END
|
||||
assert C.dialog("T", ft.Text("b"), size="l").content.width == T.DIALOG_L
|
||||
assert C.dialog("T", ft.Text("b"), title_actions=[ft.Text("1 more")]).title.controls[-1].value == "1 more"
|
||||
|
||||
|
||||
def test_danger_matches_primary_metrics():
|
||||
from frameport.ui import components as C
|
||||
from frameport.ui import theme as T
|
||||
|
||||
p, d = C.primary("Go"), C.danger("Delete")
|
||||
assert d.style.padding == p.style.padding and d.style.text_style.size == p.style.text_style.size
|
||||
assert d.style.bgcolor[ft.ControlState.DEFAULT] == T.ERROR
|
||||
menu = C.primary_menu("Add", ft.Icons.ADD_ROUNDED, [])
|
||||
assert isinstance(menu.content, ft.FilledButton) and menu.content.style.padding == p.style.padding
|
||||
|
||||
|
||||
def test_segmented_and_selected_style():
|
||||
from frameport.ui import components as C
|
||||
from frameport.ui import theme as T
|
||||
|
||||
seen = []
|
||||
seg = C.segmented([("a", "A"), ("b", "B")], "a", seen.append)
|
||||
first, second = seg.content.controls
|
||||
assert seg.data == "a" and first.content.color == T.TEXT and second.content.color == T.TEXT_2
|
||||
second.on_click(None)
|
||||
assert seen == ["b"] and seg.data == "b" and second.content.color == T.TEXT
|
||||
box, icon, text = ft.Container(), ft.Icon(ft.Icons.FOLDER_ROUNDED), ft.Text("x")
|
||||
C.selected_style(box, True, icon, text)
|
||||
assert text.color == T.TEXT and (box.gradient is not None if T.DUAL else box.bgcolor == T.ACCENT_SOFT)
|
||||
C.selected_style(box, False, icon, text)
|
||||
assert box.gradient is None and box.bgcolor is None and icon.color == T.TEXT_2
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Setup checklist items, the Settings index and the Reduce motion setting."""
|
||||
import flet as ft
|
||||
|
||||
from frameport.core import library
|
||||
from frameport.ui import components as C
|
||||
from frameport.ui import theme as T
|
||||
from frameport.ui.views import settings
|
||||
|
||||
|
||||
def fix():
|
||||
return ("Install", ft.Icons.DOWNLOAD_ROUNDED, lambda: None)
|
||||
|
||||
|
||||
def test_check_defaults_and_tuples():
|
||||
c = C.Check(True, "Steam")
|
||||
assert (c.detail, c.help, c.fix, c.extra) == ("", None, None, None)
|
||||
assert C.Check(*(False, "Java", "missing", "java")).help == "java"
|
||||
|
||||
|
||||
def test_check_marks():
|
||||
assert C.check_look(True) == (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK)
|
||||
assert C.check_look(False) == (ft.Icons.ERROR_ROUNDED, T.ERROR)
|
||||
assert C.check_look("warn")[1] == T.WARN
|
||||
assert C.check_look(None)[0] is None # pending: a spinner
|
||||
|
||||
|
||||
def test_fix_only_while_not_ready():
|
||||
f = fix()
|
||||
assert C.check_fix(C.Check(False, "x", fix=f)) is f
|
||||
assert C.check_fix(C.Check("warn", "x", fix=f)) is f
|
||||
assert C.check_fix(C.Check(True, "x", fix=f)) is None
|
||||
assert C.check_fix(C.Check(None, "x", fix=f)) is None
|
||||
|
||||
|
||||
def test_checklist_builds_rows_with_fix_and_extra():
|
||||
calls = []
|
||||
col = C.checklist([C.Check(False, "Lepton", "missing", None, ("Install", None, lambda: calls.append(1))),
|
||||
(True, "Proton", "ready", "proton", None, C.secondary("Test")),
|
||||
C.Check(None, "Checking…")])
|
||||
assert len(col.controls) == 3
|
||||
first = col.controls[0].content.controls
|
||||
button = first[-1]
|
||||
button.on_click(None)
|
||||
assert calls == [1]
|
||||
assert len(col.controls[1].content.controls) == 3 # mark, text, the extra button (no fix: ready)
|
||||
assert isinstance(col.controls[2].content.controls[0].content, ft.ProgressRing)
|
||||
|
||||
|
||||
def test_settings_sections_order():
|
||||
s = settings.SECTIONS
|
||||
assert s[0] == "appearance" and s[-1] == "remove"
|
||||
assert len(set(s)) == len(s)
|
||||
assert set(settings.section_titles()) == set(s)
|
||||
assert s.index("tools") < s.index("pc") < s.index("data") < s.index("about")
|
||||
|
||||
|
||||
def test_reduce_motion_default_off():
|
||||
from frameport.ui.app import REDUCE_MOTION, reduce_motion_setting
|
||||
|
||||
assert reduce_motion_setting() is False
|
||||
library.set_setting(REDUCE_MOTION, True)
|
||||
assert reduce_motion_setting() is True
|
||||
@@ -52,26 +52,29 @@ def _app(monkeypatch, game, frame_info=None, busy=None, pc=()):
|
||||
|
||||
|
||||
def _labels(actions):
|
||||
return [a[0] if a else "—" for a in actions]
|
||||
"""Item labels in order ("—" = divider); group headers are left out."""
|
||||
from frameport.ui.menus import Header
|
||||
|
||||
return [a[0] if a else "—" for a in actions if not isinstance(a, Header)]
|
||||
|
||||
|
||||
def test_right_click_menu_follows_install_state(monkeypatch):
|
||||
g = {"package": "com.q", "title": "Q", "recipe": {"status": "works"}, "build": {"sha256": "new"}}
|
||||
app = _app(monkeypatch, g, {"installed": [{"package": "com.q", "sha256": "old"}]})
|
||||
installed = _labels(app.game_actions("com.q"))
|
||||
assert installed[:3] == ["Open", "Play on Frame", "Update on Frame"]
|
||||
expected = {"Launch test on Frame", "Game settings…", "Uninstall from Frame", "Remove from library"}
|
||||
assert installed[:3] == ["Play on Frame", "Open game page", "Update on Frame"]
|
||||
expected = {"Run launch test on Frame", "Game settings…", "Uninstall from Frame…", "Remove from library…"}
|
||||
assert expected <= set(installed)
|
||||
|
||||
missing = _labels(_app(monkeypatch, g, {"installed": []}).game_actions("com.q"))
|
||||
assert "Install on Frame" in missing and "Uninstall from Frame" not in missing and "Play on Frame" not in missing
|
||||
assert "Install on Frame" in missing and "Uninstall from Frame…" not in missing and "Play on Frame" not in missing
|
||||
assert "Game settings…" in missing # saved to the recipe, used when it's installed
|
||||
|
||||
flat = {**g, "analysis": {"extra": {"vr_kind": "none"}}} # a 2D Android app has no adapter
|
||||
assert "Game settings…" not in _labels(_app(monkeypatch, flat, {"installed": []}).game_actions("com.q"))
|
||||
|
||||
offline = _labels(_app(monkeypatch, g).game_actions("com.q"))
|
||||
assert "Connect your Frame" in offline and "Launch test on Frame" not in offline
|
||||
assert "Connect your Frame" in offline and "Run launch test on Frame" not in offline
|
||||
|
||||
|
||||
def test_right_click_menu_while_busy_and_on_game_page(monkeypatch):
|
||||
@@ -79,7 +82,8 @@ def test_right_click_menu_while_busy_and_on_game_page(monkeypatch):
|
||||
busy = _labels(_app(monkeypatch, g, {"installed": []}, busy=object()).game_actions("rift.r"))
|
||||
assert "Cancel" in busy and "Install on Frame" not in busy and "Check game files" not in busy
|
||||
page = _labels(_app(monkeypatch, g, {"installed": []}).game_actions("rift.r", quick=False))
|
||||
assert page[0] == "Change executable…" and "Open" not in page and "Install on Frame" not in page
|
||||
assert "Change program…" in page and "Open game page" not in page and "Install on Frame" not in page
|
||||
assert "Cancel" not in page # the hero shows progress + Cancel
|
||||
|
||||
|
||||
def test_play_is_the_quick_action_when_installed(monkeypatch):
|
||||
@@ -100,8 +104,8 @@ def test_menus_offer_sharing_and_diagnostics(monkeypatch):
|
||||
g = {"package": "com.q", "title": "Q", "recipe": {"status": "works"}, "build": {"sha256": "x"}}
|
||||
for quick in (True, False):
|
||||
labels = _labels(_app(monkeypatch, g, {"installed": []}).game_actions("com.q", quick=quick))
|
||||
assert {"Share working config…", "Collect logs", "Report a problem…"} <= set(labels)
|
||||
assert labels[-1] == "Remove from library"
|
||||
assert {"Share working recipe…", "Collect logs", "Report a problem…"} <= set(labels)
|
||||
assert labels[-1] == "Remove from library…"
|
||||
|
||||
|
||||
def test_install_browser_explains_known_folders():
|
||||
@@ -138,14 +142,14 @@ def test_linux_app_menus_have_no_conversion_actions(monkeypatch):
|
||||
on_frame = {"installed": [{"package": "linux.tool", "kind": "linux"}]}
|
||||
for quick in (True, False):
|
||||
labels = _labels(_app(monkeypatch, g, on_frame).game_actions("linux.tool", quick=quick))
|
||||
for gone in ("Analyze again", "Rebuild only (no install)", "Check game files", "Reset to suggested recipe",
|
||||
"Save as known-good recipe", "Share working config…", "Game settings…",
|
||||
"Add videos and files…", "Change program…"):
|
||||
for gone in ("Analyze again", "Rebuild only (no install)", "Check game files", "Reset to suggested recipe…",
|
||||
"Save as known-good recipe", "Share working recipe…", "Game settings…",
|
||||
"Add videos and files", "Change program…"):
|
||||
assert gone not in labels, gone
|
||||
assert {"Find artwork…", "Report a problem…", "Collect logs", "Screenshots"} <= set(labels)
|
||||
assert labels[-1] == "Remove from library"
|
||||
assert labels[-1] == "Remove from library…"
|
||||
quick = _labels(_app(monkeypatch, g, on_frame).game_actions("linux.tool"))
|
||||
assert {"Play on Frame", "Reinstall on Frame", "Launch test on Frame", "Uninstall from Frame"} <= set(quick)
|
||||
assert {"Play on Frame", "Reinstall on Frame", "Run launch test on Frame", "Uninstall from Frame…"} <= set(quick)
|
||||
several = _linux_app(candidates=["tool", "tool-helper"])
|
||||
assert "Change program…" in _labels(_app(monkeypatch, several, {"installed": []}).game_actions("linux.tool"))
|
||||
lone = _linux_app(candidates=["a", "b"], files=["Tool.AppImage"]) # a lone AppImage: nothing to choose
|
||||
|
||||
@@ -361,7 +361,7 @@ def test_activity_pins_the_running_job_above_a_long_queue():
|
||||
assert order[0] is running and order[1:41] == queued and len(order) == 61
|
||||
assert [j.title for j in order[41:43]] == ["done 24", "done 23"] # finished: newest first, at most 20
|
||||
panel = ActivityPanel(SimpleNamespace(jobs=jm, job_followups=lambda j: [], copy=None, show_log_file=None,
|
||||
show_activity=None))
|
||||
show_activity=None, transit_info=lambda j: {"title": j.title}))
|
||||
panel.root.width = 400 # open
|
||||
panel.refresh(update=False)
|
||||
assert len(panel.pinned.controls) == 1 # the running job, outside the scrolling list
|
||||
@@ -443,9 +443,12 @@ def test_activity_progress_ticks_only_touch_the_progress_controls(monkeypatch):
|
||||
from frameport.ui.views.activity import ActivityPanel
|
||||
|
||||
jm = JobManager(save_logs=False)
|
||||
run = Job("running", run=lambda j: None, state="running", created=1, started=1, fraction=0.1)
|
||||
run = Job("running", run=lambda j: None, state="running", created=1, started=1, fraction=0.1,
|
||||
stage="Upload APK", stages=["Patching the game", "Prepare Frame", "Upload APK"])
|
||||
jm.jobs = [run, *[Job(f"q{i}", run=lambda j: None, created=2 + i) for i in range(30)]]
|
||||
panel = ActivityPanel(SimpleNamespace(jobs=jm, job_followups=lambda j: [], copy=None, show_log_file=None))
|
||||
info = lambda j: {"title": "Game", "dest": "Frame", "art": None, "icon": None} # noqa: E731
|
||||
panel = ActivityPanel(SimpleNamespace(jobs=jm, job_followups=lambda j: [], copy=None, show_log_file=None,
|
||||
transit_info=info))
|
||||
panel.root.width = 400
|
||||
updated = []
|
||||
monkeypatch.setattr(C, "update", lambda *controls: updated.append(controls))
|
||||
@@ -454,8 +457,9 @@ def test_activity_progress_ticks_only_touch_the_progress_controls(monkeypatch):
|
||||
assert updated[-1] == (panel.root,) # the first time: everything
|
||||
run.fraction = 0.5
|
||||
panel.refresh()
|
||||
bar = panel._live[run.id][0]
|
||||
assert bar.value == 0.5 and panel.root not in updated[-1] and bar in updated[-1]
|
||||
transit = panel._live[run.id][0] # the install transit: (step 3 + half the upload) / 6 steps
|
||||
assert transit.frac == 3.5 / 6 and panel.root not in updated[-1] and transit.control in updated[-1]
|
||||
assert transit.detail.value == "50%"
|
||||
assert panel.list.controls == first_list # nothing rebuilt
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""GUI wording rules: every user-visible literal goes through tr(), one vocabulary, "…" only as a real ellipsis."""
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
UI = Path(__file__).resolve().parents[1] / "src" / "frameport" / "ui"
|
||||
|
||||
# helpers whose arguments at these positions are shown to the user
|
||||
TEXT_ARGS = {"Text": (0,), "body": (0,), "meta": (0,), "title": (0,), "h2": (0,), "primary": (0,),
|
||||
"secondary": (0,), "ghost": (0,), "danger": (0,), "pill": (0,), "callout": (0,), "section": (0,),
|
||||
"kv": (0,), "switch": (0,), "empty_state": (1, 2), "Check": (1, 2), "icon_btn": (1,),
|
||||
"confirm": (1, 2, 3), "toast": (0,), "tip": (0,), "submit": (0,), "dialog": (0,), "primary_menu": (0,)}
|
||||
TEXT_KWARGS = {"tooltip", "hint_text", "label", "dialog_title", "action", "subtitle", "text"}
|
||||
# literals that aren't words to translate: the product name and the wordmark's halves
|
||||
ALLOWED = {"FramePort", "Frame", "Port"}
|
||||
|
||||
|
||||
def _ui_files():
|
||||
return sorted(UI.rglob("*.py"))
|
||||
|
||||
|
||||
def _name(func: ast.AST) -> str:
|
||||
return func.attr if isinstance(func, ast.Attribute) else func.id if isinstance(func, ast.Name) else ""
|
||||
|
||||
|
||||
def _is_word_literal(node: ast.AST) -> bool:
|
||||
if isinstance(node, ast.JoinedStr): # f"…" with words in its literal parts
|
||||
return any(isinstance(v, ast.Constant) and re.search(r"[A-Za-z]{2}", v.value) for v in node.values)
|
||||
return isinstance(node, ast.Constant) and isinstance(node.value, str) and node.value not in ALLOWED \
|
||||
and not node.value.startswith(("http://", "https://")) and bool(re.search(r"[A-Za-z]{2}", node.value))
|
||||
|
||||
|
||||
def _tr_texts():
|
||||
"""(file, line or help key, text) for every tr()/tr_n() literal in ui/ and every help text."""
|
||||
for path in _ui_files():
|
||||
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
||||
if isinstance(node, ast.Call) and _name(node.func) in ("tr", "tr_n"):
|
||||
for a in node.args[:2 if _name(node.func) == "tr_n" else 1]:
|
||||
if isinstance(a, ast.Constant) and isinstance(a.value, str):
|
||||
yield path, node.lineno, a.value
|
||||
from frameport.ui.help import HELP
|
||||
|
||||
for key, text in HELP.items():
|
||||
yield UI / "help.py", key, text
|
||||
|
||||
|
||||
def test_visible_literals_are_translated():
|
||||
found = []
|
||||
for path in _ui_files():
|
||||
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
name = _name(node.func)
|
||||
pos = TEXT_ARGS.get(name, ())
|
||||
if name == "Text" and not (isinstance(node.func, ast.Attribute) and _name(node.func.value) == "ft"):
|
||||
pos = ()
|
||||
args = [node.args[i] for i in pos if i < len(node.args)]
|
||||
args += [k.value for k in node.keywords if k.arg in TEXT_KWARGS]
|
||||
found += [f"{path.relative_to(UI)}:{node.lineno}" for a in args if _is_word_literal(a)]
|
||||
assert not found, "user-visible text not wrapped in tr(): " + ", ".join(found)
|
||||
|
||||
|
||||
def test_no_three_dots_or_eg_in_ui_text():
|
||||
texts = list(_tr_texts())
|
||||
dots = [f"{p.name}:{line}" for p, line, text in texts if "..." in text]
|
||||
eg = [f"{p.name}:{line}" for p, line, text in texts if re.search(r"\be\.g\.", text)]
|
||||
assert not dots, "use … (one character) instead of ...: " + ", ".join(dots)
|
||||
assert not eg, "write \"for example\" in user text: " + ", ".join(eg)
|
||||
|
||||
|
||||
def test_one_vocabulary():
|
||||
rules = {r"(?i)\bthis computer\b": "this PC", r"(?i)\bconfigs?\b": "recipe", r"(?i)\bexecutable\b": "program",
|
||||
r"(?i)\bforce kill\b": "Force quit", r"\d %": "no space before %",
|
||||
r"(?i)colour|cancelled": "US spelling", r"Steam ids": "Steam IDs", r"ARM64": "arm64"}
|
||||
bad = [f"{p.name}:{line} ({fix})" for p, line, text in _tr_texts()
|
||||
for rule, fix in rules.items() if re.search(rule, text)]
|
||||
assert not bad, "vocabulary: " + ", ".join(bad)
|
||||
|
||||
|
||||
def test_switch_frame_opens_a_picker_not_disconnect():
|
||||
tree = ast.parse((UI / "views" / "frame.py").read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call) and _name(node.func) == "ghost" and node.args and \
|
||||
isinstance(node.args[0], ast.Call) and node.args[0].args and \
|
||||
getattr(node.args[0].args[0], "value", None) == "Switch Frame…":
|
||||
handler = ast.unparse(node.args[2])
|
||||
assert "disconnect" not in handler and "switch_frame" in handler
|
||||
return
|
||||
raise AssertionError("no Switch Frame… button found")
|
||||
@@ -48,13 +48,31 @@ def test_check_skip_and_cached_hint(monkeypatch):
|
||||
assert library.setting("update.last_check")
|
||||
assert updates.cached_update().version == "9.9.9" # read from the cache, no network
|
||||
updates.skip("9.9.9")
|
||||
assert updates.check() is None and updates.cached_update() is None # "Later" hides this version
|
||||
assert updates.check() is None and updates.cached_update() is None # "Skip this version" hides it
|
||||
assert updates.check(force=True).version == "9.9.9" # "Check now" still finds it
|
||||
monkeypatch.setattr(cache, "http_get", lambda url, **kw: (_ for _ in ()).throw(OSError("offline")))
|
||||
(cache.cache_dir() / "app-release.json").unlink()
|
||||
assert updates.check(force=True) is None # offline: no update, no error
|
||||
|
||||
|
||||
def test_snooze_hides_a_version_until_it_runs_out(monkeypatch):
|
||||
monkeypatch.setattr(cache, "cached_json", lambda *a, **k: release())
|
||||
cache.cache_dir().mkdir(parents=True, exist_ok=True)
|
||||
(cache.cache_dir() / "app-release.json").write_text(json.dumps(release()), encoding="utf-8")
|
||||
now = [1_000_000.0]
|
||||
monkeypatch.setattr(updates.time, "time", lambda: now[0])
|
||||
updates.snooze("9.9.9", hours=24)
|
||||
assert updates.check() is None and updates.cached_update() is None # "Later": hidden for a day
|
||||
assert updates.check(force=True).version == "9.9.9" # "Check now" still finds it
|
||||
now[0] += 23 * 3600
|
||||
assert updates.check() is None
|
||||
now[0] += 2 * 3600 # a day later it is offered again
|
||||
assert updates.check().version == "9.9.9" and updates.cached_update().version == "9.9.9"
|
||||
updates.snooze("9.9.8") # a snooze for another version doesn't hide it
|
||||
assert updates.check().version == "9.9.9"
|
||||
assert not updates.hidden("9.9.9")
|
||||
|
||||
|
||||
def test_older_release_is_no_update(monkeypatch):
|
||||
monkeypatch.setattr(cache, "cached_json", lambda *a, **k: release(tag="v0.0.1"))
|
||||
assert updates.check(force=True) is None
|
||||
|
||||