fix(qt): drop always-on-top, pin the mini's size, and report seeks to MPRIS

Four things from the owner's smoke, all polish, all measured on real Plasma
Wayland rather than inferred.

ALWAYS-ON-TOP IS GONE. It shipped as owner ruling K1's default — new work
the reference promises in three comments and implements nowhere — and it does
not work: a Wayland client cannot raise itself above other clients. There is
no such request in the core protocol; it takes a compositor policy or a KWin
rule, and Qt has nothing to send. The owner's call was explicit, so the
button, the property, the invokable and the pref are all removed rather than
left as a control that lies. The capsule is nine buttons again.

THE MINI'S SIZE IS NOW FIXED PER SURFACE, min == max. A Plasma tiling
shortcut resized the mini to half the screen, the debounced persist wrote
1720 px into the shared ui_prefs.json, and every expanded surface opened
giant from then on — with no way back, because this port never gave the mini
a resize handle. So the only thing that could ever write those prefs was the
window manager, i.e. the bug. The owner asked for exactly this: force the
sizes even when the OS says otherwise, stay sensitive to position. min == max
gives both — a compositor may still move the window; a resize is refused.

That drops the reference's resizable expanded mini and the persisted pair
with it (A-6b / AC-15). `saveGeometry` and `geometry_to_persist` stay, with
UT-3: they are the right rule for the day a real resize handle exists, and
deleting a tested guard to re-derive it later is how this comes back.

The micro capsule now centres on the CARD, not on its 17 px header. The
reference centres it on the header, but in micro that header is a strip at
the top of a 45 px card while the capsule is 26 px tall, so it sat visibly
high.

A SEEK NOW REPORTS ITS NEW POSITION TO MPRIS. Position was only refreshed on
the play/pause edge, so a desktop widget extrapolated from a base the seek
had invalidated — the owner measured ~40 s of lag. The shared crate exposes
no Seeked emitter and set_playback already carries a position, so the seek
re-asserts the state at its target. The shipping Slint build has the same
gap; closing it there is a shared-crate conversation, not this one.

Not fixed, and it cannot be here: the mini does not remember its POSITION.
Wayland gives a client no way to place itself. Contract ruling K4 said so;
the owner's session confirms it. It would work on X11.

cc
This commit is contained in:
vicrodh committed 2026-08-04 12:50:01 -06:00
1 parent d003d67b30
commit b7706bdf38
6 files changed
+77 -111

No files matched your search

+12 -1
View File
@@ -349,12 +349,23 @@ Rectangle {
// The capsule, with the EXTRA 8 px inset the full/compact
// mount does not have (:364).
//
// Vertically centred on the CARD, not on this 17 px header
// — owner call, 2026-08-04. The reference centres it on the
// header, but in micro the header is a 17 px strip at the
// TOP of a 45 px card while the capsule is 26 px tall, so
// the reference's own arithmetic leaves it overflowing
// upward and visibly high. `root` is the footer, which in
// micro IS the whole card (§13-D10), so its centre is the
// card's centre. Anchoring across the parent boundary is
// safe here and NOT the §8 rule-1 hazard: `root` is an id,
// not a `parent.<something>` walk.
Loader {
z: 1
active: root.windowHovered
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenter: root.verticalCenter
sourceComponent: Component {
MiniWindowControls {
activeSurface: QbzMini.surface
+37 -57
View File
@@ -37,23 +37,13 @@ Window {
// — owner ruling K1, §13-D15, and it DEFAULTS ON.
//
// The AOT term is NEW WORK rather than a port: §4.8's two probes found
// three comments in the reference promising the affordance
// (state.slint:4497, miniplayer.rs:170-171, MiniWindowControls.slint:2) and
// no code anywhere that sets it. The property is seeded from
// `mini_always_on_top` at bridge construction and toggled by the capsule's
// pin button.
//
// ⚠ THE ONE RUNTIME RISK, and it is §16 U-6: reassigning `flags` on a
// MAPPED window can make Qt destroy and recreate the platform window, which
// on Wayland loses the compositor-chosen position. §4.8 pre-specifies the
// fallback rather than leaving it to be improvised — evaluate the flags at
// enter() only and keep the pref and the button state — and the fallback is
// NOT taken: driven over RFB, toggling the pin left the window's geometry,
// its contents and its input focus untouched (no flicker, no jump, no
// re-created surface). A real compositor is still the owner's smoke item;
// the pref persists either way, so the next open honours it regardless.
// NO always-on-top term. It shipped under owner ruling K1 and was
// REMOVED on 2026-08-04 after the owner smoked it on real Plasma Wayland:
// the hint did nothing. A Wayland client cannot raise itself above other
// clients — there is no such request in the core protocol; it takes a
// compositor policy or a KWin rule, and Qt has nothing to send. The owner
// chose removal over a button that lies.
flags: Qt.Window | Qt.FramelessWindowHint
| (QbzMini.alwaysOnTop ? Qt.WindowStaysOnTopHint : 0)
// Literal, never translated (app.slint:283 uses a plain string, not @tr).
title: "QBZ Mini Player"
@@ -97,47 +87,44 @@ Window {
// The expanded size is the PERSISTED one, exposed by the bridge because
// Rust cannot reach a QQuickWindow (A-6b). The reference reads the same two
// prefs on every geometry apply (miniplayer.rs:404).
readonly property int winWidth: root.expanded ? Math.max(QbzMini.miniWidth, 340) : 380
// THE SIZE IS FIXED PER SURFACE, and min == max so the window manager
// cannot change it. Owner ruling, 2026-08-04, from a real failure: a
// Plasma tiling shortcut (Win+arrow) resized the mini to HALF THE SCREEN,
// the debounced persist wrote 1720 px into the shared ui_prefs.json, and
// every expanded surface opened giant from then on with no way back —
// the mini has no resize grips, so nothing in the app could undo it.
//
// Their ask was exact: force the sizes even when the OS says otherwise,
// but stay sensitive to POSITION. min == max does precisely that: a
// compositor may still move the window, and a tiling request that would
// resize it is refused.
//
// This drops the reference's user-resizable expanded mini, and with it
// the persisted pair (contract A-6b / AC-15). No loss in practice: this
// port never gave the mini a resize handle, so the only thing that could
// ever write those prefs was the WM — i.e. the bug. `QbzMini.miniWidth`
// and `miniHeight` are therefore no longer read here; the runaway values
// an earlier session persisted are ignored and self-heal.
readonly property int winWidth: 380
readonly property int winHeight: root.expanded
? Math.max(QbzMini.miniHeight, 420)
? 540
: (root.surfaceId === 0 ? 57 : 178)
// The ONLY floor that moves. Min WIDTH is 340 in every surface
// (miniplayer.rs:408-411).
readonly property int winMinHeight: root.expanded ? 420 : (root.surfaceId === 0 ? 57 : 170)
width: root.winWidth
height: root.winHeight
minimumWidth: 340
minimumHeight: root.winMinHeight
minimumWidth: root.winWidth
maximumWidth: root.winWidth
minimumHeight: root.winHeight
maximumHeight: root.winHeight
// --- The geometry persist (§4.7, A-14, §13-D14) ------------------------
// Qt has no `WindowEvent::Resized`, so the resize handler is this debounce.
// The template is the main window's own (qml/Main.qml:377-385), and the
// 400 ms interval is the same.
//
// It is DEBOUNCED because the alternative is what the reference does: a
// whole-file ui_prefs.json load + save per qualifying resize event, with no
// dirty check (`crates/qbz/src/miniplayer.rs:460-463`), which in Qt would
// be blocking file IO on the GUI thread inside a resize drag (§7-M10).
//
// It needs NO arming latch of its own, unlike Main.qml's 1200 ms one: the
// two guards inside `mini_qt::geometry_to_persist` already reject exactly
// the frames a latch would — `surface >= 2` drops micro and compact, whose
// sizes are constants, and `height >= 320` drops the mid-transition frames
// a surface switch produces (a 178 px compact frame is precisely what that
// filter is for). Note the filter is 320 and the stored floor is 420 — two
// different numbers, both behavioural (§15 trap 17).
Timer {
id: geometrySaveTimer
interval: 400
repeat: false
// A Timer has no `parent` (§8 rule 2, §15 trap 29) — the window is
// reached through its own id.
onTriggered: QbzMini.saveGeometry(root.width, root.height)
}
// NO GEOMETRY PERSIST. The size is fixed above and the WM cannot change
// it, so there is nothing left to debounce and save — and the persist is
// exactly what turned one stray tiling shortcut into a permanently giant
// mini. `QbzMini.saveGeometry` and `mini_qt::geometry_to_persist` stay in
// the tree with their unit test (UT-3): they are the correct rule for the
// day a real resize handle exists, and deleting a tested guard to re-derive
// it later is how this class of bug comes back.
onWidthChanged: geometrySaveTimer.restart()
onHeightChanged: geometrySaveTimer.restart()
// A compositor close (Alt+F4, a panel's close entry) behaves EXACTLY like
// the capsule's expand button — §13-D13, and it closes a hole the reference
@@ -147,13 +134,6 @@ Window {
// (§3.1), so `accepted = false` is load-bearing twice.
onClosing: function (close) {
close.accepted = false
// Flush a pending resize (§4.7). Only when one is actually pending —
// an unconditional call would put a prefs write on every close, and the
// bridge's own dirty check would then be the only thing stopping it.
if (geometrySaveTimer.running) {
geometrySaveTimer.stop()
QbzMini.saveGeometry(root.width, root.height)
}
QbzMini.exit()
}
@@ -187,13 +187,19 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMini.exit()
}
// K1. The pin the reference's own comments promise.
CapBtn {
name: "pin"
active: QbzMini.alwaysOnTop
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMini.toggleAlwaysOnTop()
}
// NO PIN. Owner ruling K1 shipped always-on-top as NEW work
// (the reference promises it in three comments and implements
// it nowhere), and the owner smoked it on real Plasma Wayland
// on 2026-08-04: `Qt.WindowStaysOnTopHint` did not keep the
// window above others. Their call was explicit — "si no
// podemos conseguir el keep on top, entonces mejor quitar el
// botón" — so the button, the property, the invokable and the
// pref are gone rather than left as a control that lies.
//
// The mechanism, so nobody re-adds it blind: on Wayland a
// client cannot raise itself above other clients. There is no
// "stay on top" in the core protocol; it needs a compositor
// policy or a KWin window rule. Qt has nothing to send.
CapBtn {
name: "layers"
active: QbzMini.backgroundBlur
-27
View File
@@ -58,13 +58,6 @@ pub mod qbz_mini {
// The static artwork-derived card background (`mini_background_blur`).
// Written by `toggle_background_blur` (contract A-10).
#[qproperty(bool, background_blur)]
// K1 / contract A-5, and it is NEW WORK rather than a port: the Slint
// miniplayer has three comments promising always-on-top and zero code
// implementing it (§4.8). Seeded from `mini_always_on_top`, DEFAULT
// TRUE, and consumed by `MiniWindow.qml`'s `flags` expression as
// `| (QbzMini.alwaysOnTop ? Qt.WindowStaysOnTopHint : 0)`. Written by
// `toggle_always_on_top` (contract A-11).
#[qproperty(bool, always_on_top)]
// The navigable queue: the current track first, then the FULL
// unfiltered upcoming list. Built by `mini_qt::mini_queue_doc` and
// published from the ONE queue funnel (`queue_qt::publish`), never from
@@ -108,13 +101,6 @@ pub mod qbz_mini {
#[qinvokable]
fn toggle_background_blur(self: Pin<&mut QbzMini>);
/// Flip + persist always-on-top (contract A-11, K1). The capsule's
/// `a-pin` button is its only caller. Reassigning `flags` on a MAPPED
/// window is the one runtime risk §4.8 names (U-6); the pref persists
/// either way, so the next open honours it even if a platform were to
/// refuse the live change.
#[qinvokable]
fn toggle_always_on_top(self: Pin<&mut QbzMini>);
/// Persist the expanded window size (contract A-14). Qt has no
/// `WindowEvent::Resized`, so `MiniWindow.qml`'s 400 ms debounce calls
@@ -194,7 +180,6 @@ pub struct QbzMiniRust {
open: bool,
surface: i32,
background_blur: bool,
always_on_top: bool,
mini_queue_json: QString,
mini_width: f32,
mini_height: f32,
@@ -210,7 +195,6 @@ impl Default for QbzMiniRust {
open: false,
surface: crate::mini_qt::initial_surface(),
background_blur: crate::mini_qt::initial_background_blur(),
always_on_top: crate::mini_qt::initial_always_on_top(),
mini_queue_json: QString::from(crate::mini_qt::MINI_QUEUE_EMPTY),
mini_width: crate::mini_qt::initial_width(),
mini_height: crate::mini_qt::initial_height(),
@@ -402,17 +386,6 @@ impl qbz_mini::QbzMini {
crate::mini_qt::save_background_blur(value);
}
/// Contract A-11 / K1 — the capsule's `a-pin` toggle.
///
/// The reference has no counterpart to copy: §4.8's two probes found three
/// COMMENTS promising the affordance and no code. Flipping this property
/// re-evaluates `MiniWindow.qml`'s `flags` binding, which is where the
/// `Qt.WindowStaysOnTopHint` term actually lives.
pub fn toggle_always_on_top(mut self: Pin<&mut Self>) {
let value = !self.always_on_top;
self.as_mut().set_always_on_top(value);
crate::mini_qt::save_always_on_top(value);
}
/// Contract A-14 — the debounced resize persist.
///
-19
View File
@@ -32,21 +32,11 @@ const KEY_WIDTH: &str = "mini_width";
const KEY_HEIGHT: &str = "mini_height";
const KEY_BACKGROUND_BLUR: &str = "mini_background_blur";
const KEY_DEFAULT_VIEW: &str = "mini_default_view";
/// K1 / contract §4.7 — the ONE key in this table with no Slint reader.
/// Always-on-top does not exist in the reference (§4.8: three comments promise
/// it, zero code implements it), so this key is written and read by the Qt side
/// alone. It still goes through `save_pref`'s additive single-key patch like
/// every other one, because the DOCUMENT is co-owned even where the key is not.
const KEY_ALWAYS_ON_TOP: &str = "mini_always_on_top";
/// Defaults, from `crates/qbz/src/ui_prefs.rs:677-688`.
const DEFAULT_SURFACE: i32 = 2;
const DEFAULT_WIDTH: f32 = 380.0;
const DEFAULT_HEIGHT: f32 = 540.0;
/// K1: always-on-top ships DEFAULT ON (contract §4.7's last row). A miniplayer
/// that sinks behind the browser is the class-defining complaint, and the
/// reference's own comments describe pinning as intended behaviour.
const DEFAULT_ALWAYS_ON_TOP: bool = true;
/// Resize-persistence guards and floors (`miniplayer.rs:455`, `:459`, `:461-462`).
/// **Note the two different numbers**: 320 is the mid-transition-frame filter,
@@ -240,10 +230,6 @@ pub(crate) fn initial_background_blur() -> bool {
crate::settings_qt::pref_bool(KEY_BACKGROUND_BLUR, false)
}
/// K1 — the seed for `QbzMini.always_on_top` (contract A-5).
pub(crate) fn initial_always_on_top() -> bool {
crate::settings_qt::pref_bool(KEY_ALWAYS_ON_TOP, DEFAULT_ALWAYS_ON_TOP)
}
pub(crate) fn initial_width() -> f32 {
crate::settings_qt::pref_f32(KEY_WIDTH, DEFAULT_WIDTH)
@@ -266,11 +252,6 @@ pub(crate) fn save_background_blur(value: bool) {
crate::settings_qt::save_pref(KEY_BACKGROUND_BLUR, serde_json::Value::Bool(value));
}
/// Persist the pin (`QbzMini.toggle_always_on_top`, A-11 / K1).
pub(crate) fn save_always_on_top(value: bool) {
crate::settings_qt::save_pref(KEY_ALWAYS_ON_TOP, serde_json::Value::Bool(value));
}
/// Persist the expanded geometry, ALREADY clamped by `geometry_to_persist`.
///
/// Two single-key patches, not one struct write: `ui_prefs.json` is co-owned
+15
View File
@@ -1617,7 +1617,22 @@ pub async fn seek_frac(runtime: &Arc<AppRuntime<LoggingAdapter>>, frac: f32) {
let target = (frac.clamp(0.0, 1.0) * event.duration as f32) as u64;
if let Err(e) = runtime.core().seek(target) {
log::warn!("[qbz-qt] seek failed: {e}");
return;
}
// Tell the desktop where we jumped to. Without this the MPRIS position is
// only ever refreshed on the play/pause edge, so a widget extrapolates
// from a base that is now wrong by the whole seek distance — the owner
// measured ~40 s of lag on 2026-08-04 after seeking forward.
//
// The shared crate exposes no `Seeked` emitter (`types.rs`), and
// `set_playback` already carries a position, so the honest fix inside the
// current API is to re-assert the state at the new position. The SHIPPING
// Slint build has the same gap and the same cure would apply there — that
// is a shared-crate conversation, not this one.
crate::media_controls_qt::push_playback_state(
runtime.core().player().get_playback_event().is_playing,
target,
);
}
pub async fn set_volume(runtime: &Arc<AppRuntime<LoggingAdapter>>, volume: f32) {