diff --git a/AGENTS.md b/AGENTS.md index 683332b..4d60d51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,9 +173,12 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine download would run an empty script and count as success. - HDMI refresh boost (`hdmi`, `lib/hdmi-refresh.sh`): a sub-option of `machine` (like `kpin` and `bios`), only on Fremont with the pinned kernel - (stays visible while on); unticking `kpin` unticks it. It needs someone at the - screen: the graphical app's backend (`BACKEND=true`) must refuse it, and every step needs a "y" within 15 s - (`WIZARD_HDMI_CONFIRM_SECONDS` for scripted tests). debugfs is root-only + (stays visible while on); unticking `kpin` unticks it. It needs someone at + the screen. Terminal: every step needs a "y" within 15 s + (`WIZARD_HDMI_CONFIRM_SECONDS` for scripted tests). App: its own screen + (`hdmi-options`, `hdmi-try`, `hdmi-reset` backend commands, the EDID cached + in `$XDG_RUNTIME_DIR/steamify-hdmi`), then `apply --hdmi + =x:`; without `--hdmi` the backend refuses it. debugfs is root-only (glob it under sudo), and `edid_override` takes exactly `reset` with no newline. Build the EDID from the DDC read, not from sysfs: a live override replaces the kernel's copy. The kernel command-line value holds a path, so diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba619f..7a28907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,19 @@ one per merged pull request. - A graphical app for controller, keyboard and TV remote, driving the same components through `steamify.sh --backend`; the BIOS update with both warnings; built by GitHub and run with curl. -- **feat(ui): HDMI refresh boost in the app, off only** - - Each step needs someone at the keyboard, so it's turned on from the - menu in Konsole; the app can only turn it off. Unticking Steam Machine - support or the kernel pin unticks it, as in the menu. +- **feat(ui): HDMI refresh boost in the app** + - "Set up…" opens its own screen: the display, its resolution and the + rates on offer (its own mode, calculated tens, the HDMI 2.0 limit). The + display's own faster mode is switched to right away; every other rate + has a Try button (switch live, wait for the picture, then keep it within + 15 s or it switches back) and a tick box. Install installs the ticked + rates. All rates are loaded when the screen opens, so a try is only a + mode switch. + - Unticking Steam Machine support or the kernel pin unticks it, as in the + menu. +- **fix(ui): The details panel scrolls instead of overflowing** +- **fix: Switching back to a rate like 59.97 Hz** + - `hdmi_set_mode` compared it as a whole number and printed an error. ## 1.2.0 - 2026-09-25 diff --git a/lib/backend.sh b/lib/backend.sh index 1a52934..7a953ca 100644 --- a/lib/backend.sh +++ b/lib/backend.sh @@ -8,6 +8,14 @@ # turns on the listed components and off the others (like ticking them # in the menu), then streams one JSON object per line: plan, start, # log, done, finished. sudo asks through $SUDO_ASKPASS (no terminal). +# steamify.sh --backend apply ... --hdmi =x:,... hdmi +# HDMI refresh boost with the rates the app confirmed. +# steamify.sh --backend hdmi-options +# hdmi-options event: every HDMI output, its mode and rates to offer. +# steamify.sh --backend hdmi-try [...] +# steamify.sh --backend hdmi-reset +# switch to a rate live (hdmi-tried event), or back to the display's +# own EDID (hdmi-reset event); the app asks in between. # steamify.sh --backend bios-prepare | bios-flash # the BIOS update in two steps, so the app shows both warnings between # them (bios-ready event: current, newest, checksum, compatible). @@ -127,6 +135,7 @@ backend_apply() { case "$1" in --reapply) reapply=true ;; --boot) boot="$2"; shift ;; + --hdmi) HDMI_CHOICE+=("$2"); shift ;; *) want+=("$1") ;; esac shift @@ -167,12 +176,34 @@ backend_apply() { backend_event finished "\"failed\":$(json_list "${failed[@]}"),\"restart\":$(restart_needed && echo true || echo false)" } +backend_hdmi() { + # backend_hdmi options|try|reset [args]: the app's HDMI screen. Events + # only (no "finished"): the app goes on from its own screen. + local what="$1" rc + shift + backend_sudo || return 1 + case "$what" in + options) + sudo pacman -S --needed --noconfirm i2c-tools python >/dev/null 2>&1 + backend_event hdmi-options "\"outputs\":$(hdmi_options 2>/dev/null)" ;; + try) + hdmi_try "$@" >/dev/null 2>&1; rc=$? + backend_event hdmi-tried "\"hz\":$4,\"ok\":$([[ $rc -eq 0 ]] && echo true || echo false)" ;; + reset) + hdmi_reset "$@" >/dev/null 2>&1 + backend_event hdmi-reset ;; + esac +} + backend_main() { case "${1:-}" in status) backend_status ;; apply) shift; backend_apply "$@" ;; bios-prepare) backend_bios_prepare ;; bios-flash) backend_bios_flash ;; + hdmi-options) backend_hdmi options ;; + hdmi-try) shift; backend_hdmi try "$@" ;; + hdmi-reset) shift; backend_hdmi reset "$@" ;; *) err "Usage: steamify.sh --backend status | apply [--reapply] [--boot gamescope|desktop] ..."; return 2 ;; esac } diff --git a/lib/hdmi-refresh.sh b/lib/hdmi-refresh.sh index e85fcfc..1dda1b2 100644 --- a/lib/hdmi-refresh.sh +++ b/lib/hdmi-refresh.sh @@ -19,6 +19,9 @@ HDMI_INITRAMFS_CONF=/etc/mkinitcpio.conf.d/90-steamify-edid.conf HDMI_CONFIRM_SECONDS="${WIZARD_HDMI_CONFIRM_SECONDS:-15}" # amdgpu's HDMI 2.0 limit; the display's own limit (from its EDID) may be lower. HDMI_TMDS_MAX_KHZ=600000 +# Rates picked and tested in the app ("=x:,..."), set by +# the backend's apply --hdmi; empty in the terminal menu, which tests itself. +HDMI_CHOICE=() hdmi_connectors() { # Connected HDMI outputs, as "-" (e.g. card1-HDMI-A-1). @@ -173,6 +176,36 @@ elif cmd == 'plan': steps = sorted({top // 10 * 10, top // 100 * 100}) steps = [s for s in steps if s > best + 1 and not any(abs(s - x) < 1 for x in listed)] print(lim // 1000, round(best, 2), *steps) +elif cmd == 'options': + # JSON for the app: the display's name, the HDMI limit and the rates to + # offer at w x h: its own modes the kernel skips ("monitor"), calculated + # tens up to the limit ("calculated", ticked) and the exact limit when + # that's no ten ("limit", not ticked). + import json + e, k = load(args[0]), load(args[1]); w, h, cur = int(args[2]), int(args[3]), float(args[4]) + lim, t = tmds_khz(e), shortest(e, w, h) + name = '' + for o in range(54, 126, 18): + if e[o:o + 3] == b'\0\0\0' and e[o + 3] == 0xfc: + name = e[o + 5:o + 18].split(b'\n')[0].decode('ascii', 'replace').strip() + rates = [] + if t: + fits = [x for x in timings(e) if x[2] == w and x[7] == h and x[1] <= lim] + known = [x[0] for x in timings(k) if x[2] == w and x[7] == h] + listed = [x[0] for x in fits] + best = max(listed + [cur]) + for x in sorted(fits): + if x[0] > cur + 1 and not any(abs(x[0] - y) < 0.5 for y in known): + rates.append({'hz': round(x[0], 2), 'mhz': round(x[1] / 1000, 1), 'kind': 'monitor', 'pick': True}) + size = (t[2] + t[3]) * (t[7] + t[8]) + vmax = max_vrefresh(e) or int(max(listed + [cur])) + top = min(int(lim * 1000 // size), vmax) + tens = [r for r in range(10, top + 1, 10) if r > best + 1 and not any(abs(r - y) < 1 for y in listed)][-3:] + for r in tens: + rates.append({'hz': r, 'mhz': round(size * r / 1e6, 1), 'kind': 'calculated', 'pick': True}) + if top % 10 and top > best + 1: + rates.append({'hz': top, 'mhz': round(size * top / 1e6, 1), 'kind': 'limit', 'pick': False}) + print(json.dumps({'name': name, 'limit': lim // 1000, 'rates': rates})) elif cmd == 'build': e = load(args[0]); w, h = int(args[2]), int(args[3]); rates = [int(r) for r in args[4:]] t = shortest(e, w, h) @@ -278,7 +311,8 @@ hdmi_set_mode() { kscreen-doctor "output.$1.mode.$id" >/dev/null 2>&1 || return 1 sleep 2 rate="$(hdmi_kscreen current "$1" | cut -d' ' -f3)" - [[ "${rate%.*}" -ge $(( $4 - 1 )) ]] + # Whole hertz: the rate asked for can be e.g. 59.97. + [[ "${rate%.*}" -ge $(( ${4%.*} - 1 )) ]] } hdmi_confirm() { @@ -370,9 +404,89 @@ hdmi_tune() { HDMI_RESULT="$w $h ${ok_rates[*]}" } +# --- For the app: options, live tries, and installing what was confirmed --- +# The EDIDs read by hdmi_options, reused by the tries and the install. +HDMI_CACHE="${XDG_RUNTIME_DIR:-/tmp}/steamify-hdmi" + +hdmi_options() { + # JSON array: per connected HDMI output its name, mode and rate options. + local conn out cur w h hz opts sep="" + mkdir -p "$HDMI_CACHE" + printf '[' + for conn in $(hdmi_connectors); do + out="${conn#card*-}" + # A live EDID left by an earlier try would look like the display's own. + hdmi_status || hdmi_override_live "$conn" reset 2>/dev/null + cur="$(hdmi_kscreen current "$out")" + [[ -n "$cur" ]] || continue + read -r w h hz <<< "$cur" + hdmi_read_edid "$conn" "$HDMI_CACHE/$out.orig" || continue + opts="$(hdmi_edid_tool options "$HDMI_CACHE/$out.orig" "/sys/class/drm/$conn/edid" "$w" "$h" "$hz")" || continue + # Every rate on offer loaded now, while the list is on screen: a try + # is then only a mode switch (the current mode stays in the EDID). + hdmi_status || hdmi_preload "$conn" "$w" "$h" "$opts" + printf '%s{"connector":"%s","output":"%s","width":%s,"height":%s,"hz":%s,"info":%s}' "$sep" "$conn" "$out" "$w" "$h" "$hz" "$opts" + sep="," + done + printf ']' +} + +hdmi_preload() { + # hdmi_preload : load the EDID + # with the calculated rates, as hdmi_try would on the first try. + local conn="$1" out="${1#card*-}" + local -a rates + read -ra rates <<< "$(python3 -c 'import json, sys +print(" ".join(str(r["hz"]) for r in json.loads(sys.argv[1])["rates"] if r["kind"] != "monitor"))' "$4")" + hdmi_edid_tool build "$HDMI_CACHE/$out.orig" "$HDMI_CACHE/$out.test" "$2" "$3" "${rates[@]}" && + hdmi_override_live "$conn" "$HDMI_CACHE/$out.test" && + cp "$HDMI_CACHE/$out.test" "$HDMI_CACHE/$out.loaded" +} + +hdmi_try() { + # hdmi_try [...]: switch to hz, with + # an EDID holding the given calculated rates (loaded once per set). + local conn="$1" w="$2" h="$3" hz="$4" out="${1#card*-}" + shift 4 + [[ -f "$HDMI_CACHE/$out.orig" ]] || hdmi_read_edid "$conn" "$HDMI_CACHE/$out.orig" + hdmi_edid_tool build "$HDMI_CACHE/$out.orig" "$HDMI_CACHE/$out.test" "$w" "$h" "$@" || return 1 + if ! cmp -s "$HDMI_CACHE/$out.test" "$HDMI_CACHE/$out.loaded" 2>/dev/null; then + hdmi_override_live "$conn" "$HDMI_CACHE/$out.test" || return 1 + cp "$HDMI_CACHE/$out.test" "$HDMI_CACHE/$out.loaded" + fi + hdmi_set_mode "$out" "$w" "$h" "$hz" +} + +hdmi_reset() { + # hdmi_reset : the display's own EDID again. + hdmi_override_live "$1" reset + rm -f "$HDMI_CACHE/${1#card*-}.loaded" + hdmi_set_mode "${1#card*-}" "$2" "$3" "$4" >/dev/null +} + +hdmi_choose() { + # For HDMI_CHOICE ("=x:,..." from the app, rates it + # confirmed): builds the final EDIDs in like hdmi_tune does. + local tmp="$1" item out mode rates w h + local -a r + for item in "${HDMI_CHOICE[@]}"; do + out="${item%%=*}"; mode="${item#*=}"; rates="${mode#*:}"; mode="${mode%%:*}" + w="${mode%x*}"; h="${mode#*x}" + IFS=',' read -ra r <<< "$rates" + HDMI_CHOSEN_CONN="$(basename /sys/class/drm/card*-"$out")" + if [[ -f "$HDMI_CACHE/$out.orig" ]]; then cp "$HDMI_CACHE/$out.orig" "$tmp/$out.orig" + else hdmi_read_edid "$HDMI_CHOSEN_CONN" "$tmp/$out.orig" || return 1; fi + hdmi_edid_tool build "$tmp/$out.orig" "$tmp/$out.final" "$w" "$h" "${r[@]}" && + hdmi_override_live "$HDMI_CHOSEN_CONN" "$tmp/$out.final" || return 1 + [[ ${#r[@]} -gt 0 ]] && hdmi_set_mode "$out" "$w" "$h" "${r[-1]}" >/dev/null + HDMI_RESULTS+=("$out|$w $h ${r[*]}") + done +} + hdmi_enable() { - if [[ "${BACKEND:-false}" == true ]]; then - err "HDMI refresh boost needs you at the screen: run it from the Steamify menu in Konsole." + local -a HDMI_RESULTS=() + if [[ "${BACKEND:-false}" == true && ${#HDMI_CHOICE[@]} -eq 0 ]]; then + err "HDMI refresh boost needs you at the screen: pick and test the rates in the app first." return 1 fi if [[ "${XDG_CURRENT_DESKTOP:-}" != *KDE* ]] || ! command -v kscreen-doctor >/dev/null; then @@ -389,24 +503,31 @@ hdmi_enable() { ok "Already set up for the connected display(s). Untick and tick it again to re-test." return 0 fi - warn "Set up for another display; testing the one connected now." + warn "Set up for another display; setting up the one connected now." hdmi_disable || return 1 fi sudo pacman -S --needed --noconfirm i2c-tools python >/dev/null 2>&1 || { err "Installing i2c-tools failed."; return 1; } - local tmp conn out param="" files=() rc=0 + local tmp conn out param="" files=() rc=0 res tmp="$(mktemp -d)" - for conn in $(hdmi_connectors); do - out="${conn#card*-}" - hdmi_tune "$conn" "$tmp" || { rc=1; continue; } - [[ -n "$HDMI_RESULT" ]] || continue + if [[ ${#HDMI_CHOICE[@]} -gt 0 ]]; then + # Picked and tested in the app. + hdmi_choose "$tmp" || rc=1 + else + for conn in $(hdmi_connectors); do + hdmi_tune "$conn" "$tmp" || { rc=1; continue; } + [[ -n "$HDMI_RESULT" ]] && HDMI_RESULTS+=("${conn#card*-}|$HDMI_RESULT") + done + fi + for res in "${HDMI_RESULTS[@]}"; do + out="${res%%|*}" sudo install -Dm644 "$tmp/$out.final" "$HDMI_FW_DIR/steamify-$out.bin" || { rc=1; continue; } state_set hdmi "$out" "$(hdmi_edid_tool id "$tmp/$out.orig")" - state_set hdmi "$out-mode" "$HDMI_RESULT" + state_set hdmi "$out-mode" "${res#*|}" param+="${param:+,}$out:edid/steamify-$out.bin" files+=("$HDMI_FW_DIR/steamify-$out.bin") done - rm -rf "$tmp" + rm -rf "$tmp" "$HDMI_CACHE" [[ -n "$param" ]] || return $rc # amdgpu loads from the initramfs (the kms hook), before the root diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml index 6510928..4b17a5a 100644 --- a/ui/qml/Main.qml +++ b/ui/qml/Main.qml @@ -71,9 +71,9 @@ ApplicationWindow { kpin: { label: "Pin the kernel", hint: "Fixes rebooting after shutdown", body: "Newer CachyOS kernels make the Steam Machine reboot instead of shutting down. Untick once CachyOS fixes that.", changes: ["linux-cachyos from Steamify's release (signature checked)", "Kept in /var/cache/steamify/kernel", "Added to IgnorePkg"] }, - hdmi: { label: "HDMI refresh boost", hint: "Higher refresh over HDMI · turn on from Steamify in Konsole", - body: "The pinned kernel keeps many HDMI displays at 60 Hz. This finds the highest refresh rate your display runs at the desktop resolution. Each step needs you to confirm the picture, so it's turned on from the Steamify menu in Konsole; here you can only turn it off.", - changes: ["The display's EDID with the confirmed rates", "In the initramfs, drm.edid_firmware on the kernel command line", "Off: the display uses its own EDID again"] }, + hdmi: { label: "HDMI refresh boost", hint: "Higher refresh rates over HDMI", + body: "The pinned kernel keeps many HDMI displays at 60 Hz. Turning this on shows which refresh rates your display can run at the desktop resolution; you pick them, and each one is tried for 15 seconds so you can check the picture before it's installed.", + changes: ["The display's EDID with the rates you confirmed", "In the initramfs, drm.edid_firmware on the kernel command line", "Off: the display uses its own EDID again"] }, bios: { label: "Update BIOS", hint: "", body: "Installs Valve's newest Steam Machine BIOS, at your own risk. It checks Valve's checksum and asks fwupd whether the file fits this machine, then warns you twice before anything is written.", changes: ["Valve's fremont-hw-support package (checksum checked)", "fwupd writes the BIOS during the next restart", "Keep the power on until the machine has fully started again"] } @@ -101,6 +101,85 @@ ApplicationWindow { property bool biosChecking: false // the invisible check before the warnings property int biosFocus: 0 // warning 1: 0 = Cancel, 1 = continue property real holdProgress: 0 // warning 2: hold A/OK for 5 s + // HDMI refresh boost: try rates one by one, then install the kept ones. + property var hdmi: null // the first HDMI output from hdmi-options + property bool hdmiLoading: false + property var hdmiResult: ({}) // hz -> "ok" | "fail" (tried) + property var hdmiPick: ({}) // hz -> ticked for Install + property int hdmiSel: 0 // rate row; rates.length = the Install button + property real hdmiHz: 0 // the rate being tried (e.g. 99.98) + property real hdmiNow: 0 // the rate on screen: the last one kept + property double hdmiBackAt: 0 // when a test returned to the list + property string hdmiPhase: "" // switching | settle | ask | back + property int hdmiSettle: 8 // seconds for the display to show a picture again + property int hdmiLeft: 15 // seconds to keep it + property string hdmiChoice: "" // for apply: =x: + property string hdmiNote: "" + property var afterBusy: null // a backend call waiting for the last one + function whenIdle(fn) { if (backend.busy) afterBusy = fn; else fn(); } + Connections { target: backend; function onBusyChanged() { if (!backend.busy && afterBusy) { var f = afterBusy; afterBusy = null; f(); } } } + readonly property var hdmiRates: hdmi && hdmi.info ? hdmi.info.rates : [] + function hdmiCalc() { + // The calculated rates (not the display's own): what the test EDID holds. + return hdmiRates.filter(function (r) { return r.kind !== "monitor"; }).map(function (r) { return r.hz; }); + } + function hdmiKept() { + // The ticked calculated rates (the display's own modes always come along). + return hdmiRates.filter(function (r) { return r.kind !== "monitor" && hdmiPick[r.hz]; }) + .map(function (r) { return r.hz; }).sort(function (a, b) { return a - b; }); + } + function hdmiUntried() { return hdmiKept().filter(function (hz) { return hdmiResult[hz] !== "ok"; }); } + function hdmiTick(r) { + if (r.kind === "monitor") return; + var p = Object.assign({}, hdmiPick); p[r.hz] = !p[r.hz]; hdmiPick = p; + } + readonly property bool hdmiCanInstall: hdmiKept().length > 0 || hdmiRates.some(function (r) { return r.kind === "monitor"; }) + function startHdmi() { + hdmi = null; hdmiChoice = ""; hdmiNote = ""; sessionPassword = ""; + if (!askPassword("hdmi")) hdmiLoad(); + } + function hdmiLoad() { + hdmiLoading = true; hdmi = null; hdmiSel = 0; hdmiResult = ({}); hdmiPick = ({}); screen = "hdmi"; + whenIdle(function () { backend.hdmiOptions(sessionPassword); }); + } + function hdmiSwitch(hz) { + whenIdle(function () { backend.hdmiTry(sessionPassword, hdmi.connector, hdmi.width, hdmi.height, hz, hdmiCalc().join(",")); }); + } + function hdmiTry(hz) { + hdmiHz = hz; hdmiPhase = "switching"; screen = "hdmitest"; + hdmiSwitch(hz); + } + function hdmiMark(result) { + var r = Object.assign({}, hdmiResult); r[hdmiHz] = result; hdmiResult = r; + // Kept: ticked for Install; didn't work: unticked. + var p = Object.assign({}, hdmiPick); p[hdmiHz] = result === "ok"; hdmiPick = p; + } + function hdmiKeep() { if (hdmiPhase !== "ask" && hdmiPhase !== "settle") return; hdmiMark("ok"); hdmiNow = hdmiHz; hdmiPhase = ""; hdmiBackAt = Date.now(); screen = "hdmi"; } + function hdmiReject() { + // Back to the last rate that worked (at first: the one from before). + if (hdmiPhase !== "ask" && hdmiPhase !== "settle" && hdmiPhase !== "failed") return; + hdmiMark("fail"); hdmiPhase = "back"; + hdmiSwitch(hdmiNow); + } + function hdmiInstall() { + if (!hdmiCanInstall) return; + hdmiChoice = hdmi.output + "=" + hdmi.width + "x" + hdmi.height + ":" + hdmiKept().join(","); + var w = Object.assign({}, want); w.hdmi = true; w.machine = true; w.kpin = true; want = w; + screen = "menu"; goReview(false); + } + function hdmiCancel() { + var tried = Object.keys(hdmiResult).length > 0; + hdmiPhase = ""; hdmiChoice = ""; + if (tried && hdmi) whenIdle(function () { backend.hdmiReset(sessionPassword, hdmi.connector, hdmi.width, hdmi.height, hdmi.hz); }); + sessionPassword = ""; screen = "menu"; + } + Timer { + interval: 1000; repeat: true; running: screen === "hdmitest" && (hdmiPhase === "settle" || hdmiPhase === "ask") + onTriggered: { + if (hdmiPhase === "settle") { hdmiSettle--; if (hdmiSettle <= 0) hdmiPhase = "ask"; return; } + hdmiLeft--; if (hdmiLeft <= 0) hdmiReject(); + } + } property bool holding: false readonly property var bios: status.bios || null function biosHint() { @@ -131,13 +210,15 @@ ApplicationWindow { Connections { target: backend; function onStatusChanged() { if (screen === "menu" || screen === "done") syncFromStatus(); } } function toggle(id) { + hdmiNote = ""; var w = Object.assign({}, want); w[id] = !w[id]; if (id === "gaming" && !w.gaming) { w.single = false; } if (id === "single" && w.single) w.gaming = true; - // HDMI refresh boost needs someone at the keyboard to confirm each - // step: it's only turned on from the terminal menu. - if (id === "hdmi" && w.hdmi && !nowOn("hdmi")) return; + // Turning HDMI refresh boost on goes through its own screen: pick + // the rates and try each one first. + if (id === "hdmi" && w.hdmi && !nowOn("hdmi") && !hdmiChoice) { startHdmi(); return; } + if (id === "hdmi" && !w.hdmi) hdmiChoice = ""; if (id === "machine") w.kpin = w.machine; if (id === "kpin" && w.kpin) w.machine = true; if (id === "hdmi" && w.hdmi) { w.machine = true; w.kpin = true; } @@ -173,7 +254,8 @@ ApplicationWindow { for (var i = 0; i < plan.length; i++) s[plan[i].id] = "wait"; steps = s; screen = "applying"; - backend.apply(wantedIds(), want.gaming ? boot : "", reapply, password || ""); + backend.apply(wantedIds(), want.gaming ? boot : "", reapply, password || "", want.hdmi ? hdmiChoice : ""); + hdmiChoice = ""; } function askPassword(forWhat) { pending = forWhat; @@ -182,12 +264,15 @@ ApplicationWindow { } function onApplyPressed() { if (plan.length === 0) return; + // Just typed for the HDMI screen: not asked again. + if (hdmiChoice && sessionPassword) { var p = sessionPassword; sessionPassword = ""; startApply(p); return; } if (!askPassword("apply")) startApply(""); } function submitPassword() { if (!backend.checkPassword(pw.text)) { wrongPassword = true; pw.selectAll(); return; } var p = pw.text; pw.text = ""; if (pending === "bios") { sessionPassword = p; biosCheck(); } + else if (pending === "hdmi") { sessionPassword = p; hdmiLoad(); } else startApply(p); } // --- BIOS: check -> warning 1 -> warning 2 -> flash --- @@ -223,6 +308,32 @@ ApplicationWindow { if (ev.event === "start") { s[ev.id] = "run"; steps = s; } else if (ev.event === "done") { s[ev.id] = ev.ok ? "ok" : "fail"; steps = s; doneCount++; } else if (ev.event === "log") { logModel.append({ line: ev.line }); if (logModel.count > 400) logModel.remove(0); } + else if (ev.event === "hdmi-options") { + hdmiLoading = false; hdmiSel = 0; + hdmi = ev.outputs && ev.outputs.length ? ev.outputs[0] : null; + hdmiNow = hdmi ? hdmi.hz : 0; + // Straight to the display's own faster mode (the safe one), with + // the next rate's Try selected for after that. + var rs = hdmi && hdmi.info ? hdmi.info.rates : []; + var own = rs.findIndex(function (r) { return r.kind === "monitor"; }); + if (own >= 0) { hdmiSel = Math.min(own + 1, rs.length - 1); hdmiTry(rs[own].hz); } + } + else if (ev.event === "hdmi-tried") { + if (hdmiPhase === "back") { hdmiPhase = ""; hdmiBackAt = Date.now(); screen = "hdmi"; return; } + // Displays take a few seconds to show a picture after a mode + // change: the countdown starts after that. + // The display's own mode always works: kept without asking. + var ownMode = hdmiRates.some(function (r) { return r.kind === "monitor" && Math.abs(r.hz - hdmiHz) < 0.01; }); + if (ev.ok && ownMode) { hdmiPhase = "ask"; hdmiKeep(); } + else if (ev.ok) { hdmiPhase = "settle"; hdmiSettle = 8; hdmiLeft = 15; } + else { hdmiPhase = "failed"; hdmiReject(); } + } + else if (ev.event === "hdmi-reset") { } + else if (ev.event === "finished" && (screen === "hdmi" || screen === "hdmitest")) { + // Only on an error (sudo): the HDMI commands end without one. + runError = ev.error === "wrong-password" ? "The password didn't work." : "sudo isn't available."; + failed = []; restartNeeded = false; sessionPassword = ""; screen = "done"; + } else if (ev.event === "bios-ready") { biosChecking = false; biosInfo = ev; biosFocus = 0; holdProgress = 0; screen = "bios1"; } else if (ev.event === "finished" && biosChecking) { // The check found nothing to do or a problem: say so. @@ -264,7 +375,7 @@ ApplicationWindow { else if (a === "back") { reapply = false; screen = "menu"; } } else if (screen === "password") { if (a === "accept" || a === "apply") submitPassword(); - else if (a === "back") { pw.text = ""; screen = "review"; } + else if (a === "back") { pw.text = ""; if (pending === "hdmi") hdmiCancel(); else screen = "review"; } } else if (screen === "bios1") { if (a === "left") biosFocus = 0; else if (a === "right") biosFocus = 1; @@ -274,6 +385,20 @@ ApplicationWindow { if (a === "back") biosCancel(); else if (a === "hold") holding = true; else if (a === "release") { holding = false; if (holdProgress < 1) holdProgress = 0; } + } else if (screen === "hdmi") { + if (hdmiLoading) { if (a === "back") hdmiCancel(); return; } + var n = hdmiRates.length; + if (a === "up") hdmiSel = Math.max(0, hdmiSel - 1); + else if (a === "down") hdmiSel = Math.min(n, hdmiSel + 1); + else if (a === "accept") { if (hdmiSel === n) hdmiInstall(); else hdmiTick(hdmiRates[hdmiSel]); } + else if (a === "reapply" && hdmiSel < n && Math.abs(hdmiNow - hdmiRates[hdmiSel].hz) >= 0.5) hdmiTry(hdmiRates[hdmiSel].hz); + else if (a === "apply") hdmiInstall(); + // A second Back meant for the test (it takes a moment to switch + // back) would otherwise leave the whole screen. + else if (a === "back" && Date.now() - hdmiBackAt > 1500) hdmiCancel(); + } else if (screen === "hdmitest") { + if (a === "accept" || a === "apply") hdmiKeep(); + else if (a === "back") hdmiReject(); } else if (screen === "done") { if (a === "accept" && restartNeeded) backend.restart(); else if (a === "back" || a === "accept") { biosRun = false; syncFromStatus(); screen = "menu"; } @@ -303,7 +428,7 @@ ApplicationWindow { else if (k === Qt.Key_Right) act("right"); else if (k === Qt.Key_Space) act("accept"); else if (k === Qt.Key_Return || k === Qt.Key_Enter) act(inputType === "remote" || screen !== "menu" ? "accept" : "apply"); - else if (k === Qt.Key_Escape || k === Qt.Key_Back || k === Qt.Key_Backspace) act("back"); + else if (k === Qt.Key_Escape || k === Qt.Key_Back || k === Qt.Key_Backspace) { if (!e.isAutoRepeat) act("back"); } else if (k === Qt.Key_R) act("reapply"); else return; e.accepted = true; @@ -334,7 +459,7 @@ ApplicationWindow { color: primary ? t.accent : "#232c38" border.width: focusRing ? 2 : 0; border.color: t.textHi Row { id: br; anchors.centerIn: parent; spacing: 10 - Glyph { k: parent.parent.k; dark: parent.parent.primary; anchors.verticalCenter: parent.verticalCenter } + Glyph { k: parent.parent.k; visible: k !== ""; dark: parent.parent.primary; anchors.verticalCenter: parent.verticalCenter } Text { text: parent.parent.text; color: parent.parent.primary ? t.ink : t.text; font.family: t.body; font.pixelSize: 15; font.weight: Font.DemiBold; anchors.verticalCenter: parent.verticalCenter } } MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: parent.clicked() } @@ -381,7 +506,7 @@ ApplicationWindow { anchors.left: parent.left; anchors.leftMargin: 40; anchors.verticalCenter: parent.verticalCenter; spacing: 14 Image { source: iconUrl; width: 40; height: 40; sourceSize: Qt.size(80, 80); anchors.verticalCenter: parent.verticalCenter } Text { text: "Steamify"; color: t.text; font.family: t.display; font.pixelSize: 28; font.weight: Font.Bold; anchors.verticalCenter: parent.verticalCenter } - Text { text: screen === "menu" ? "v" + (status.version || "") : "/ " + ({review: "Review", password: "Password", applying: biosRun ? "BIOS update" : "Applying", done: "Done", bios1: "BIOS update", bios2: "BIOS update"})[screen] + Text { text: screen === "menu" ? "v" + (status.version || "") : "/ " + ({review: "Review", password: "Password", applying: biosRun ? "BIOS update" : "Applying", done: "Done", bios1: "BIOS update", bios2: "BIOS update", hdmi: "HDMI refresh boost", hdmitest: "HDMI refresh boost"})[screen] color: t.faint; font.family: screen === "menu" ? t.mono : t.body; font.pixelSize: screen === "menu" ? 13 : 15; anchors.verticalCenter: parent.verticalCenter } } Rectangle { @@ -433,6 +558,8 @@ ApplicationWindow { readonly property int rowIndex: rows.findIndex(function (r) { return r.id === modelData.id; }) readonly property bool selected: shown && rowIndex === sel readonly property bool on: modelData.kind === "choice" ? true : !!want[modelData.id] + // HDMI refresh boost is set up on its own screen: a button while off. + readonly property bool setup: modelData.id === "hdmi" && !modelData.on onSelectedChanged: if (selected) Qt.callLater(list.showSelected) width: list.width - 12; x: 2 height: shown ? 62 : 0 @@ -473,12 +600,19 @@ ApplicationWindow { Text { anchors.centerIn: parent; text: parent.modelData[1]; color: boot === parent.modelData[0] ? t.ink : t.mute; font.family: t.body; font.pixelSize: 13; font.weight: Font.DemiBold } MouseArea { anchors.fill: parent; onClicked: boot = parent.modelData[0] } } } } } // toggle: "now on/off" left of the switch, switch on the edge - Rectangle { id: sw; visible: row.modelData.kind === "toggle"; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter + Rectangle { id: sw; visible: row.modelData.kind === "toggle" && !row.setup; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter width: 46; height: 26; radius: 13; color: row.on ? t.accent : "#343f50" Behavior on color { ColorAnimation { duration: 120 } } Rectangle { width: 20; height: 20; radius: 10; color: "#f4f7fb"; y: 3; x: row.on ? 23 : 3; Behavior on x { NumberAnimation { duration: 120 } } } } - Text { visible: row.modelData.kind === "toggle"; anchors.right: sw.left; anchors.rightMargin: 12; anchors.verticalCenter: parent.verticalCenter + Text { visible: row.modelData.kind === "toggle" && !row.setup; anchors.right: sw.left; anchors.rightMargin: 12; anchors.verticalCenter: parent.verticalCenter text: row.modelData.on ? "now on" : "now off"; color: row.modelData.on ? t.good : t.faint; font.family: t.mono; font.pixelSize: 12 } + // HDMI refresh boost while off: opens its screen + Rectangle { visible: row.setup; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter + width: sut.implicitWidth + 24; height: 30; radius: 8; color: hdmiChoice && want.hdmi ? t.goodBg : "#1b2b40" + Text { id: sut; anchors.centerIn: parent; font.family: t.body; font.pixelSize: 13; font.weight: Font.DemiBold + color: hdmiChoice && want.hdmi ? t.good : "#7cc4ff" + text: hdmiChoice && want.hdmi ? (hdmiChoice.split(":")[1] ? hdmiChoice.split(":")[1].split(",").join(", ") + " Hz ✓" : "Own modes ✓") : "Set up…" } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: { sel = row.rowIndex; startHdmi(); } } } // action Rectangle { visible: row.modelData.kind === "action"; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter width: at.implicitWidth + 24; height: 30; radius: 8; color: t.warnBg @@ -555,7 +689,7 @@ ApplicationWindow { } Row { anchors.right: parent.right; anchors.rightMargin: 40; anchors.verticalCenter: parent.verticalCenter; spacing: 16 - Text { readonly property int n: computePlan().length; text: n === 0 ? "Everything is the way you want it" : n + (n === 1 ? " change" : " changes"); color: t.faint; font.family: t.body; font.pixelSize: 14; anchors.verticalCenter: parent.verticalCenter } + Text { readonly property int n: computePlan().length; text: hdmiNote ? hdmiNote : (n === 0 ? "Everything is the way you want it" : n + (n === 1 ? " change" : " changes")); color: t.faint; font.family: t.body; font.pixelSize: 14; anchors.verticalCenter: parent.verticalCenter } Btn { k: g.apply; text: "Review & apply"; primary: true; focusRing: sel === rows.length; height: 44; onClicked: goReview(false) } } } @@ -757,6 +891,101 @@ ApplicationWindow { } } + // ================= HDMI: pick the rates ================= + Item { + visible: screen === "hdmi" + anchors.top: header.bottom; anchors.bottom: parent.bottom; width: parent.width + Text { visible: hdmiLoading; anchors.centerIn: parent; text: "Reading your display…"; color: t.mute; font.family: t.body; font.pixelSize: 18 } + Text { visible: !hdmiLoading && !hdmi; anchors.centerIn: parent; horizontalAlignment: Text.AlignHCenter + text: "No HDMI display found at the desktop.\nThis only works from the Plasma desktop, not in gaming mode."; color: t.mute; font.family: t.body; font.pixelSize: 18 } + Column { + visible: !hdmiLoading && !!hdmi + x: 40; y: 28; width: 740; spacing: 12 + Text { text: hdmi ? (hdmi.info.name || "HDMI display") + " · " + hdmi.output : ""; color: t.text; font.family: t.display; font.pixelSize: 30; font.weight: Font.DemiBold } + Text { text: hdmi ? hdmi.width + "×" + hdmi.height : ""; color: t.mute; font.family: t.mono; font.pixelSize: 14 } + Text { visible: hdmiRates.length === 0; width: 740; wrapMode: Text.WordWrap; topPadding: 12; color: t.soft; font.family: t.body; font.pixelSize: 16 + text: "This display already runs its fastest rate that fits HDMI at this resolution. Nothing to add." } + Repeater { + model: hdmiRates + Rectangle { + required property var modelData; required property int index + readonly property bool own: modelData.kind === "monitor" + readonly property string res: own ? "own" : (hdmiResult[modelData.hz] || "") + width: 740; height: 56; radius: 12 + color: hdmiSel === index ? t.cardSel : t.card; border.width: hdmiSel === index ? 2 : 0; border.color: t.accent + readonly property bool ticked: own || !!hdmiPick[modelData.hz] + readonly property bool running: Math.abs(hdmiNow - modelData.hz) < 0.5 + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: { hdmiSel = parent.index; hdmiTick(parent.modelData); } } + Row { anchors.left: parent.left; anchors.leftMargin: 18; anchors.verticalCenter: parent.verticalCenter; spacing: 16 + Rectangle { width: 24; height: 24; radius: 6; anchors.verticalCenter: parent.verticalCenter + color: parent.parent.ticked ? (parent.parent.own ? "#343f50" : t.accent) : "transparent"; border.width: parent.parent.ticked ? 0 : 2; border.color: "#56627a" + Text { anchors.centerIn: parent; text: parent.parent.parent.ticked ? "✓" : ""; color: parent.parent.parent.own ? t.soft : t.ink; font.pixelSize: 15; font.weight: Font.Bold } } + Text { text: parent.parent.modelData.hz + " Hz"; width: 100; color: t.textHi; font.family: t.body; font.pixelSize: 18; font.weight: Font.DemiBold; anchors.verticalCenter: parent.verticalCenter } + Text { width: 380; anchors.verticalCenter: parent.verticalCenter; color: t.soft; font.family: t.body; font.pixelSize: 14 + text: ({ monitor: "The display's own mode · always included", calculated: "Calculated from the display's timing", limit: "Right at the HDMI 2.0 limit · not recommended" })[parent.parent.modelData.kind] } + } + Row { anchors.right: parent.right; anchors.rightMargin: 14; anchors.verticalCenter: parent.verticalCenter; spacing: 12 + Chip { anchors.verticalCenter: parent.verticalCenter; visible: parent.parent.res === "own" || parent.parent.res === "fail" + text: ({ own: "Included", fail: "✗ Didn't work" })[parent.parent.res] || "" + fg: parent.parent.res === "fail" ? t.bad : (parent.parent.res === "ok" ? t.good : "#b8c3d1") + bgc: parent.parent.res === "fail" ? t.badBg : (parent.parent.res === "ok" ? t.goodBg : t.line) } + // The rate on screen now: a green marker instead of Try. + Rectangle { visible: parent.parent.running; height: 38; radius: 12; width: rt.implicitWidth + 32; color: t.goodBg + border.width: hdmiSel === parent.parent.index ? 2 : 0; border.color: t.good; anchors.verticalCenter: parent.verticalCenter + Text { id: rt; anchors.centerIn: parent; text: "Running now"; color: t.good; font.family: t.body; font.pixelSize: 15; font.weight: Font.DemiBold } } + Btn { visible: !parent.parent.running; height: 38; focusRing: hdmiSel === parent.parent.index; anchors.verticalCenter: parent.verticalCenter + k: hdmiSel === parent.parent.index ? g.reapply : ""; text: parent.parent.res === "" || parent.parent.res === "own" ? "Try" : "Try again" + onClicked: { hdmiSel = parent.parent.index; hdmiTry(parent.parent.modelData.hz); } } + } + } + } + } + Rectangle { + visible: !hdmiLoading && !!hdmi + x: parent.width - 468; y: 28; width: 428; height: 250; radius: 16; color: t.card + Column { anchors.fill: parent; anchors.margins: 24; spacing: 12 + Text { text: "HOW IT WORKS"; color: t.faint; font.family: t.body; font.pixelSize: 12; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } + Text { width: parent.width; wrapMode: Text.WordWrap; color: t.soft; font.family: t.body; font.pixelSize: 15; lineHeight: 1.35 + text: "Tick the rates to install (" + g.ok + "). Try one first (" + g.reapply + "): it's shown, and you press " + g.ok + " to keep it. No answer, or a black screen, switches back.\n\nInstall adds the ticked rates and the display's own modes; they stay after a restart." } + } + } + Rectangle { + anchors.bottom: parent.bottom; width: parent.width; height: 64; color: t.bar + Rectangle { width: parent.width; height: 1; color: t.line } + Btn { anchors.left: parent.left; anchors.leftMargin: 40; anchors.verticalCenter: parent.verticalCenter; k: g.back; text: "Back to the menu"; onClicked: hdmiCancel() } + Text { anchors.right: instBtn.left; anchors.rightMargin: 16; anchors.verticalCenter: parent.verticalCenter; visible: !!hdmi && (!hdmiCanInstall || hdmiUntried().length > 0) + text: !hdmiCanInstall ? "Tick a rate first" : "Not tried: " + hdmiUntried().join(", ") + " Hz" + color: hdmiCanInstall ? t.warn : t.faint; font.family: t.body; font.pixelSize: 14 } + Btn { id: instBtn; anchors.right: parent.right; anchors.rightMargin: 40; anchors.verticalCenter: parent.verticalCenter; k: hdmiSel === hdmiRates.length ? g.ok : g.apply + visible: !hdmiLoading && !!hdmi && hdmiRates.length > 0; primary: hdmiCanInstall; opacity: hdmiCanInstall ? 1 : 0.5 + focusRing: hdmiSel === hdmiRates.length + text: "Install" + (hdmiKept().length ? " " + hdmiKept().join(", ") + " Hz" : ""); onClicked: hdmiInstall() } + } + } + + // ================= HDMI: keep this rate? ================= + Item { + visible: screen === "hdmitest" + anchors.top: header.bottom; anchors.bottom: parent.bottom; width: parent.width + Rectangle { + anchors.centerIn: parent; width: 640; height: ht.implicitHeight + 64; radius: 20; color: t.card; border.width: 1; border.color: t.line + Column { id: ht; anchors.fill: parent; anchors.margins: 32; spacing: 18 + Text { text: hdmiPhase === "ask" || hdmiPhase === "settle" ? "Keep " + hdmiHz + " Hz?" : (hdmiPhase === "back" ? "Switching back…" : "Switching to " + hdmiHz + " Hz…") + color: t.text; font.family: t.display; font.pixelSize: 36; font.weight: Font.DemiBold } + Text { width: parent.width; wrapMode: Text.WordWrap; color: t.soft; font.family: t.body; font.pixelSize: 16; lineHeight: 1.3 + text: hdmiPhase === "ask" || hdmiPhase === "settle" ? "Check the picture: sharp, stable, no flicker or dropouts. Your display's menu can show the rate it gets." + : "The screen may go black for a moment." } + Column { visible: hdmiPhase === "ask" || hdmiPhase === "settle"; width: parent.width; spacing: 8 + Text { text: hdmiPhase === "settle" ? "Waiting for the picture… the countdown starts in " + hdmiSettle + " s" : "Switching back in " + hdmiLeft + " s"; color: t.mute; font.family: t.body; font.pixelSize: 14 } + Rectangle { width: parent.width; height: 10; radius: 5; color: t.line + Rectangle { height: 10; radius: 5; color: t.accent; width: parent.width * hdmiLeft / 15; Behavior on width { NumberAnimation { duration: 900 } } } } } + Row { visible: hdmiPhase === "ask" || hdmiPhase === "settle"; spacing: 12 + Btn { k: g.back; text: "Switch back"; onClicked: hdmiReject() } + Btn { k: g.ok; text: "Keep " + hdmiHz + " Hz"; primary: true; onClicked: hdmiKeep() } } + } + } + } + // ================= DONE ================= Item { visible: screen === "done" diff --git a/ui/steamify-ui b/ui/steamify-ui index 8f6a7c3..bd74bcc 100755 --- a/ui/steamify-ui +++ b/ui/steamify-ui @@ -245,8 +245,8 @@ class Backend(QObject): self.busyChanged.emit() p.start() - @Slot("QVariant", str, bool, str) - def apply(self, ids, boot, reapply, password): + @Slot("QVariant", str, bool, str, str) + def apply(self, ids, boot, reapply, password, hdmi): try: if hasattr(ids, "toVariant"): # a JS array from QML ids = ids.toVariant() @@ -255,6 +255,8 @@ class Backend(QObject): args.append("--reapply") if boot: args += ["--boot", boot] + if hdmi: # rates confirmed on the HDMI screen + args += ["--hdmi", hdmi] self._start(args + [str(i) for i in (ids or [])], password) except Exception as e: # Never leave the app on the progress screen. @@ -272,6 +274,21 @@ class Backend(QObject): """Only after both warnings in the app.""" self._start(["bios-flash"], password) + @Slot(str) + def hdmiOptions(self, password): + """Reads the HDMI displays: ends with an hdmi-options event.""" + self._start(["hdmi-options"], password) + + @Slot(str, str, int, int, float, str) + def hdmiTry(self, password, conn, w, h, hz, rates): + """Switches to hz live: an hdmi-tried event says whether it worked.""" + self._start(["hdmi-try", conn, str(w), str(h), "%g" % hz] + [r for r in rates.split(",") if r], password) + + @Slot(str, str, int, int, float) + def hdmiReset(self, password, conn, w, h, hz): + """The display's own EDID and the old rate again (hdmi-reset event).""" + self._start(["hdmi-reset", conn, str(w), str(h), "%g" % hz], password) + def _read(self, p): self._buf += bytes(p.readAllStandardOutput()) while b"\n" in self._buf: