mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 04:04:09 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d995b815f8 | ||
|
|
e0208687b6 | ||
|
|
c699e13104 | ||
|
|
9b12b7d74e | ||
|
|
c7c7c1f772 | ||
|
|
2486a601e3 |
No files matched your search
+4
-3
@@ -38,7 +38,7 @@ The curved layout chains screens edge to edge, like monitors on a desk: the midd
|
||||
|
||||
A resize handle has to be able to shrink a screen from any direction, so the dragged corner follows the laser along the screen's diagonal rather than taking the larger of its horizontal and vertical reach. Pushing and pulling a carried screen moves it along the line from your head, because the 3D mouse's virtual controller sits just in front of the bar, below the screen's centre, so the line from the device points mostly upward.
|
||||
|
||||
Wherever ft-screens needs to know where a laser points (showing the controls, the resize tab, the roll knob), it uses the laser's own pose, the render model's `tip` component, rather than the controller's pose. On the Frame's controllers the tip points 40° below the pose's forward axis, so rays from the pose missed what the laser was actually on. The 3D mouse's virtual controller has no tip, and its laser runs along its pose.
|
||||
Wherever ft-screens needs to know where a laser points (showing the controls, the resize tab, the roll knob), it uses the laser's own pose, the render model's `tip` component, rather than the controller's pose. On the Frame's controllers the tip points 40° below the pose's forward axis, so rays from the pose missed what the laser was actually on. The 3D mouse's virtual controller has no tip, and its laser runs along its pose. ft-screens reads the tip with `GetComponentState`: `GetComponentStateForDevicePath` without an input source handle fails for every component while a VR game runs, so in games the rays came from the pose, 40° too high.
|
||||
|
||||
`ComputeOverlayIntersection` ignores `SetOverlayIntersectionMask`, and a control can't be allowed to cover part of its screen, so the resize tab sits entirely outside the corner.
|
||||
|
||||
@@ -62,7 +62,7 @@ A profile's screen part is the custom arrangement under a name: each screen's po
|
||||
|
||||
`IVRApplications::GetCurrentSceneProcessId()` is 0 when no game is running (the Frame's home environment isn't a scene app) and the game's process ID while one is. ft-screens checks it twice a second, turns the flag off while a game runs, and by default hides the screens unless the dashboard is open. Flatscreen games run inside Steam's gamescope overlay and aren't scene apps, which is why "only with the dashboard open" is offered as a controller setting.
|
||||
|
||||
The reset button needs to work in a game, where the screens have the flag off. So ft-screens turns the flag on for that button's overlay alone while a hand controller aims within about one button's width of it, and off half a second after the aim leaves a zone twice as wide. ft-screens finds the aim from the controllers' laser poses, which it reads anyway to show the controls, so it doesn't need SteamVR's laser to be on first. The game loses the controllers only while you aim at the button.
|
||||
In a game, Frametop's panels work like SteamVR's own floating windows: point a controller at one and its laser comes on, point away and the game has the controllers again. ft-screens turns the flag on for a panel while a hand controller's laser pose meets it, its controls, or a floating window's popups. It finds that from the poses it already reads to show the controls, so SteamVR's laser doesn't have to be on first. Leaving takes a margin two control-sizes wide and 0.3 s, a drag or a held button keeps the flag on, and the keyboard, a single overlay, uses SteamVR's `ComputeOverlayIntersection`. The 3D mouse doesn't need any of this: it has its own laser mode.
|
||||
|
||||
## Floating windows
|
||||
|
||||
@@ -185,6 +185,8 @@ KWin renders with OpenGL through zink on Turnip, Vulkan on the same GPU vrcompos
|
||||
|
||||
The nested session also runs the system's XDG autostart entries, being a KDE session. Discover's update notifier started `plasma-discover --mode update` in it (520 to 620 MB resident and about 9% of a core, plus `flatpak-system-helper` and AppStream downloads), and IBus started a daemon, the kimpanel panel and its GTK extension that nothing can use: KWin hands text input to the one input method it starts (`ft-textinput`), and the session drops `QT_IM_MODULE`, `GTK_IM_MODULE` and `XMODIFIERS`. The session hides both for this desktop only, with `Hidden=true` copies in its own autostart folder. The geoclue demo agent stays: it's what answers apps' location requests to Geoclue outside GNOME, and it costs nothing while idle. Orca's entry only starts in GNOME-family desktops.
|
||||
|
||||
Plasma 6.2.5 keeps each panel on a screen number (`lastScreen` in `plasma-org.kde.plasma.desktop-appletsrc`), and the numbers rank the enabled outputs by priority, so 0 is the primary screen. A panel whose number is past the screen count gets no view, and Plasma never moves it: the remap it runs at every start only moves a panel whose number has no desktop, and this desktop keeps a desktop for every output it has seen, spares included. So the taskbar was lost when the number of screens went down, and once it was found saved on a spare output, number 8 of a desktop with three screens ([#18](https://github.com/DeeJanuz/frametop/issues/18)). Before Plasma starts, the session runs `session/fix-panels.py`, which moves any panel numbered past the screen count, with its system tray's containment, to screen 0, keeping its widgets and settings. A panel stays put when screen 0 already has one on that edge, and comes back by itself if the screens do. The file is backed up to `<file>.ft-bak` first. Plasma's scripting can't do this while it runs (`panel.screen` is read-only in 6.2.5), so a lost taskbar comes back at the desktop's next start. `scripts/doctor.sh` and `scripts/report.sh` list the panels and their screens.
|
||||
|
||||
Remote desktop is a chain (krdp, then FreeRDP inside Xvnc) because nothing on SteamOS serves KWin over VNC directly. Kept connected all the time, it cost about a core with nobody watching: krdpserver 55 to 78% (it encodes H.264 in software with openh264: VA-API finds no driver for the Frame's GPU in the container), FreeRDP 16 to 27%, Xvnc 6 to 11%, and the bridge's layout check every 5 seconds another 4%. krdp creates its screencast session per RDP connection and drops it when the connection closes (`SessionController::onNewConnection` in krdp 6.7), so an idle krdpserver costs nothing and can stay up; only the RDP connection has to go. The bridge connects FreeRDP when a VNC client appears and disconnects 45 seconds after the last one leaves. Xvnc has no hook for its clients, so the bridge counts established connections to its port with `ss`, woken early by Xvnc's log output; looking with `ss` once a second cost about 0.9% of a core in bash, against about 0.1% this way. `Xvnc -inetd` from a systemd socket would start a server per connection and lose sharing between viewers. The layout check (`ft-layout remote-view`, which scans `/proc` for plasmashell and runs `kscreen-doctor -j`) now runs only while FreeRDP runs, and then only after `kwinoutputconfig.json` or `frametop-layout.json` changes, with one check a minute in case a change touched neither.
|
||||
|
||||
Program names stay within 15 characters, because Linux truncates process names there and the scripts find programs with `pgrep -x` and `pkill -x`. That's why the prefix is `ft-`.
|
||||
@@ -224,6 +226,5 @@ On the Frame, SteamVR is part of the OS image (`/opt/steamvr`, the `deckard-stea
|
||||
|
||||
- A controller button that shows the screens during a game. Games own the controllers, so this needs SteamVR input actions for ft-screens.
|
||||
- Drawing KWin's cursor on the screens.
|
||||
- Plasma can lose its panels when the number of screens goes down, because they're saved against a screen that no longer exists. Removing `plasma-org.kde.plasma.desktop-appletsrc` and `plasmashellrc` from `~/.config/frametop` brings the default panels back.
|
||||
- Frame pacing and GPU cost with several busy screens haven't been measured.
|
||||
- Real standby on a stand, with rendering and tracking paused, not just the backlight off. SteamVR has no call for it, and its activity level follows the proximity sensor.
|
||||
+2
-2
@@ -44,7 +44,7 @@ Every screen is an overlay named `frametop.screen.N` with five controls:
|
||||
- `.curve` bends the screen into a cylinder around you, using your current distance as the radius, or makes it flat again.
|
||||
- `.roll` rolls the screen when you drag it sideways, like a knob. It snaps level within 2.5°, and scrolling on it turns 5° per notch.
|
||||
- `.resize`, the tab on the bottom right corner, sets the width. Screens go down to 15 cm wide.
|
||||
- `.reset`, left of the bar, puts every screen back in its layout around where you are now, like Meta+Shift+R (`ft-layout apply`). In a VR game, where the screens leave the controllers to the game, aiming a controller at it turns SteamVR's laser on for that button alone, so the trigger clicks it; the game gets the controllers back half a second after you aim away.
|
||||
- `.reset`, left of the bar, puts every screen back in its layout around where you are now, like Meta+Shift+R (`ft-layout apply`).
|
||||
|
||||
The controls are sized from both the screen's width and its distance from you, follow the surface of a curved screen, and stay invisible until a laser or the 3D mouse's cursor lands on one or comes within about 1.5 times a button's size of it. While invisible they're still there, fully transparent, so SteamVR's laser can find them. They're translucent until a laser is on them, like SteamVR's own window controls.
|
||||
|
||||
@@ -62,7 +62,7 @@ The Visibility & pins tab of Frametop Display Settings decides when the screens
|
||||
In the last three modes the hotkey shows the screens anyway. A screen can also be hidden on its own (Screens shown on the same tab, or `ft-layout hide N`): it stays hidden whatever the mode or the hotkey says, until it's shown again there. Windows on it stay put, and a new window that would open on it floats instead (ft-floatd). Profiles use this to show only some screens. Two more settings on the same tab cover VR games, which ft-screens detects as SteamVR scene apps:
|
||||
|
||||
- During VR games, the Always mode hides the screens unless the dashboard is open (the default), or leaves them up.
|
||||
- Controllers on the screens. Visible screens can keep SteamVR's laser mouse on, so controllers work them with the dashboard closed, but that also takes the controllers away from a game. By default this is off while a VR game runs, and the 3D mouse or the dashboard works the screens. The other choices are always on, or only with the dashboard open, which also suits flatscreen games since they aren't scene apps.
|
||||
- Controllers on the screens. Visible screens can keep SteamVR's laser mouse on, so controllers work them with the dashboard closed, but that also takes the controllers away from a game. By default this is off while a VR game runs, and the 3D mouse or the dashboard works the screens. Pointing a controller at a screen, a floating window, or the keyboard still turns its laser on, like SteamVR's own floating windows, and pointing away gives the game the controllers back. The other choices are always on, or only with the dashboard open, which also suits flatscreen games since they aren't scene apps.
|
||||
|
||||
Input from the lasers reaches KWin through ft-screens' own seat. Keys come from the input relay, from pass-through keyboards and any key a pointer device passes through. Typing follows your last click: after a click on a screen it goes to the desktop, even with the SteamVR dashboard open, and after a mouse click on any other panel (the dashboard, Steam, an app like Spotify) it goes there instead. While it goes to the desktop, the relay grabs pass-through keyboards so gamescope, which reads every keyboard itself, doesn't type them into the Steam app too. A program that watches every keyboard for a hotkey loses a grabbed one; with `SHARE_KEYS=1` in `~/.config/frametop.conf`, their keys also go to `@frametop_keys` for it. That's off by default, since any local process that binds the name first would get everything typed into the desktop. Hidden screens don't take typing.
|
||||
|
||||
|
||||
+15
-1
@@ -459,7 +459,21 @@ void Hide() {
|
||||
bool Shown() { return g_shown; }
|
||||
|
||||
void SetLasers(bool on) {
|
||||
if (Create()) vr::VROverlay()->SetOverlayFlag(g_overlay, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, on);
|
||||
static vr::VROverlayHandle_t set = vr::k_ulOverlayHandleInvalid; // the overlay `was` is for
|
||||
static bool was = false;
|
||||
if (!Create() || (set == g_overlay && was == on)) return;
|
||||
set = g_overlay, was = on;
|
||||
vr::VROverlay()->SetOverlayFlag(g_overlay, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, on);
|
||||
}
|
||||
|
||||
bool Aimed(const vr::HmdMatrix34_t &laser) {
|
||||
if (!g_shown) return false;
|
||||
vr::VROverlayIntersectionParams_t in{};
|
||||
in.eOrigin = vr::TrackingUniverseStanding;
|
||||
in.vSource = {laser.m[0][3], laser.m[1][3], laser.m[2][3]};
|
||||
in.vDirection = {-laser.m[0][2], -laser.m[1][2], -laser.m[2][2]};
|
||||
vr::VROverlayIntersectionResults_t out{};
|
||||
return vr::VROverlay()->ComputeOverlayIntersection(g_overlay, &in, &out);
|
||||
}
|
||||
|
||||
void Poll(void (*handle)(const Event &, void *), void *data) {
|
||||
|
||||
@@ -26,6 +26,8 @@ const vr::HmdMatrix34_t &Pose();
|
||||
void EndDragBy(uint32_t device);
|
||||
// Controllers' lasers work the panel with the dashboard closed, like the screens'.
|
||||
void SetLasers(bool on);
|
||||
// A laser (its pose, aiming along -z) points at the panel.
|
||||
bool Aimed(const vr::HmdMatrix34_t &laser);
|
||||
// The panel's input: key presses and releases, and Closed for its Close key.
|
||||
void Poll(void (*handle)(const Event &, void *), void *data);
|
||||
void Destroy();
|
||||
|
||||
+99
-35
@@ -12,9 +12,7 @@
|
||||
// - a resize tab on the bottom right corner: drag it to set the width (the height
|
||||
// follows the screen's resolution).
|
||||
// - a reset button left of the bar: every screen back in its layout, around where you
|
||||
// are now (`ft-layout apply`, like Meta+Shift+R). Where the screens leave the
|
||||
// controllers to a VR game, aiming a controller at it turns SteamVR's laser mouse on for
|
||||
// that button alone (UpdateResetLaser), so it can be clicked in a game.
|
||||
// are now (`ft-layout apply`, like Meta+Shift+R).
|
||||
// The controls are translucent, like SteamVR's own, and brighten under a laser. They
|
||||
// are invisible until a laser (a controller's, or the 3D mouse's) lands on or passes very close to
|
||||
// one of them (UpdateControls).
|
||||
@@ -44,7 +42,10 @@
|
||||
// default it's off while a game (a scene app) runs: the screens stay up over the game,
|
||||
// the controllers stay in it, and the 3D mouse (its own laser mode) or the dashboard
|
||||
// works the screens. Modes: always, outside_games (default), dashboard (never on its
|
||||
// own; also for flatscreen games, which aren't scene apps).
|
||||
// own; also for flatscreen games, which aren't scene apps). Where the mode leaves the
|
||||
// controllers to the game, pointing a controller at a panel (a screen, a floating window
|
||||
// and its popups, their controls, the keyboard) turns the laser on for it until you
|
||||
// point away, like SteamVR's own floating windows (UpdateAim).
|
||||
// - during a VR game the screens hide unless the dashboard is open (g_inGames, default),
|
||||
// or stay visible over it; the hotkey still shows them.
|
||||
// - paused ("pause on", from the input relay when Frametop pauses for a VR game,
|
||||
@@ -189,8 +190,12 @@ Mat TipOffset(vr::TrackedDeviceIndex_t dev) {
|
||||
Tip tip{model, Identity(), false, now};
|
||||
vr::RenderModel_ControllerMode_State_t mode{};
|
||||
vr::RenderModel_ComponentState_t state{};
|
||||
if (model[0] && vr::VRRenderModels()->GetComponentStateForDevicePath(model, vr::k_pch_Controller_Component_Tip,
|
||||
vr::k_ulInvalidInputValueHandle, &mode, &state))
|
||||
// GetComponentState, not GetComponentStateForDevicePath: without an input source handle
|
||||
// the latter fails for every component while a VR game runs, and the rays came from the
|
||||
// pose, 40 degrees above the laser. The tip doesn't move with the buttons.
|
||||
vr::VRControllerState_t buttons{};
|
||||
if (model[0] && vr::VRRenderModels()->GetComponentState(model, vr::k_pch_Controller_Component_Tip, &buttons, &mode,
|
||||
&state))
|
||||
tip.offset = state.mTrackingToComponentLocal, tip.found = true;
|
||||
cache[dev] = tip;
|
||||
return tip.offset;
|
||||
@@ -237,7 +242,7 @@ constexpr double kRollSnap = 2.5; // degrees from level where rolling snaps l
|
||||
constexpr double kRollStep = 5; // degrees per scroll notch on the roll button
|
||||
constexpr float kChromeIdle = 0.55f; // the controls' opacity without a laser on them
|
||||
constexpr long kControlsLinger = 35; // ticks (~0.4 s) the controls stay after a laser leaves
|
||||
constexpr long kResetLinger = 45; // ticks (~0.5 s) the reset button keeps the laser mouse on
|
||||
constexpr long kAimLinger = 25; // ticks (~0.3 s) a panel keeps the laser on after the aim leaves it
|
||||
long g_tick = 0; // ft_vr_poll calls
|
||||
bool g_vr = false; // connected to SteamVR (ft-screens --no-vr runs without it)
|
||||
constexpr vr::TrackedDeviceIndex_t kNone = vr::k_unTrackedDeviceIndexInvalid;
|
||||
@@ -275,8 +280,7 @@ struct Screen {
|
||||
float controls = 0; // the controls' fade, 0 (hidden) .. 1
|
||||
bool controlsUp = false; // the controls' overlays are shown
|
||||
long nearUntil = 0; // a laser was near the controls until this tick
|
||||
long resetNearUntil = 0; // a hand controller aimed at the reset button until this tick
|
||||
bool resetLaser = false; // the reset button has MakeOverlaysInteractiveIfVisible
|
||||
long aimUntil = 0; // a hand controller pointed at it until this tick (UpdateAim)
|
||||
vr::TrackedDeviceIndex_t pinTarget = kNone; // moving: rides on this controller when let go
|
||||
vr::TrackedDeviceIndex_t onWrist = kNone; // moving: the laser is in this controller's ring
|
||||
bool barLit = false;
|
||||
@@ -910,26 +914,20 @@ void UpdateAttention() {
|
||||
|
||||
|
||||
// Controllers' lasers on the screens (see the top): the flag follows the mode and whether a
|
||||
// VR game runs.
|
||||
// VR game runs, and where the mode leaves the controllers to the game, whether one points at
|
||||
// the panel (UpdateAim).
|
||||
bool LasersByMode() { return g_lasers == Lasers::Always || (g_lasers == Lasers::OutsideGames && !g_gameRunning); }
|
||||
long g_keyboardAimUntil = 0;
|
||||
|
||||
void UpdateLasers() {
|
||||
const bool want = g_lasers == Lasers::Always || (g_lasers == Lasers::OutsideGames && !g_gameRunning);
|
||||
const bool byMode = LasersByMode();
|
||||
for (auto &[i, s] : g_screens) {
|
||||
const bool want = byMode || (s.visible && g_tick < s.aimUntil);
|
||||
if (s.lasers == want) continue;
|
||||
s.lasers = want;
|
||||
vr::VROverlay()->SetOverlayFlag(s.overlay, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, want);
|
||||
}
|
||||
}
|
||||
|
||||
// The reset button in a VR game (or the dashboard mode), where the screens don't keep
|
||||
// SteamVR's laser mouse on: aiming a hand controller at it turns the laser mouse on for that
|
||||
// button alone, so the trigger clicks it, and the game gets the controllers back
|
||||
// kResetLinger ticks after the aim leaves it (a wider zone than the one that turns it on).
|
||||
void UpdateResetLaser(Screen &s) {
|
||||
const bool want = s.resetButton != vr::k_ulOverlayHandleInvalid && s.visible && !s.lasers &&
|
||||
g_tick < s.resetNearUntil;
|
||||
if (want == s.resetLaser) return;
|
||||
s.resetLaser = want;
|
||||
vr::VROverlay()->SetOverlayFlag(s.resetButton, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, want);
|
||||
if (keyboard::Shown()) keyboard::SetLasers(byMode || g_tick < g_keyboardAimUntil);
|
||||
}
|
||||
|
||||
// The distance from a laser's line to a point ahead of it, or -1 when it's behind.
|
||||
@@ -947,11 +945,10 @@ double RayDistance(const Mat &d, const Mat &c) {
|
||||
// kControlsLinger ticks after it leaves, and while in use.
|
||||
void UpdateControls() {
|
||||
std::vector<Mat> lasers;
|
||||
std::vector<bool> hands; // the laser is a hand controller's (not the 3D mouse's)
|
||||
for (vr::TrackedDeviceIndex_t i = 1; i < vr::k_unMaxTrackedDeviceCount; ++i) {
|
||||
Mat d;
|
||||
if (vr::VRSystem()->GetTrackedDeviceClass(i) == vr::TrackedDeviceClass_Controller && LaserPose(i, &d))
|
||||
lasers.push_back(d), hands.push_back(IsHandController(i));
|
||||
lasers.push_back(d);
|
||||
}
|
||||
for (auto &[i, s] : g_screens) {
|
||||
Mat p;
|
||||
@@ -977,16 +974,7 @@ void UpdateControls() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (s.resetButton != vr::k_ulOverlayHandleInvalid && !s.lasers) {
|
||||
const Mat c = Mul(p, offsets[6]);
|
||||
const double aim = s.grip * (s.resetLaser ? 2.0 : 0.9);
|
||||
for (size_t k = 0; k < lasers.size(); ++k) {
|
||||
const double r = RayDistance(lasers[k], c);
|
||||
if (hands[k] && r >= 0 && r <= aim) s.resetNearUntil = g_tick + kResetLinger;
|
||||
}
|
||||
}
|
||||
}
|
||||
UpdateResetLaser(s);
|
||||
const bool inUse = s.drag != Drag::None || std::any_of(std::begin(s.hover), std::end(s.hover), [](bool h) { return h; });
|
||||
const bool want = s.visible && (inUse || g_tick < s.nearUntil);
|
||||
// The controls stay shown while their screen is, just fully transparent when not
|
||||
@@ -1514,6 +1502,81 @@ void ReleaseAwayBy(vr::TrackedDeviceIndex_t dev, uint32_t vrButton, void (*handl
|
||||
if (g_press.buttons && dev == g_press.device) ReleaseAway(LinuxButton(vrButton), handle, data);
|
||||
}
|
||||
|
||||
// Where a laser meets a panel's surface, in the panel's u (metres along it from the centre,
|
||||
// along the arc when curved) and v (up). OpenVR curves a screen into a cylinder toward its
|
||||
// front, centred `curve` metres in front of it (see OnSurface).
|
||||
bool RayOnSurface(const Screen &s, const Mat &p, const Mat &laser, double *u, double *v) {
|
||||
const Mat inv = Inverse(p);
|
||||
const double o[3] = {inv.m[0][0] * laser.m[0][3] + inv.m[0][1] * laser.m[1][3] + inv.m[0][2] * laser.m[2][3] + inv.m[0][3],
|
||||
inv.m[1][0] * laser.m[0][3] + inv.m[1][1] * laser.m[1][3] + inv.m[1][2] * laser.m[2][3] + inv.m[1][3],
|
||||
inv.m[2][0] * laser.m[0][3] + inv.m[2][1] * laser.m[1][3] + inv.m[2][2] * laser.m[2][3] + inv.m[2][3]};
|
||||
double d[3];
|
||||
for (int i = 0; i < 3; ++i) d[i] = -(inv.m[i][0] * laser.m[0][2] + inv.m[i][1] * laser.m[1][2] + inv.m[i][2] * laser.m[2][2]);
|
||||
if (s.curve <= 0) {
|
||||
if (std::fabs(d[2]) < 1e-6) return false;
|
||||
const double t = -o[2] / d[2];
|
||||
if (t <= 0) return false;
|
||||
*u = o[0] + d[0] * t, *v = o[1] + d[1] * t;
|
||||
return true;
|
||||
}
|
||||
// x^2 + (z - r)^2 = r^2, on the screen's side of the axis (z < r).
|
||||
const double r = s.curve, oz = o[2] - r;
|
||||
const double a = d[0] * d[0] + d[2] * d[2], b = 2 * (o[0] * d[0] + oz * d[2]), c = o[0] * o[0] + oz * oz - r * r;
|
||||
const double disc = b * b - 4 * a * c;
|
||||
if (a < 1e-9 || disc < 0) return false;
|
||||
for (double t : {(-b - std::sqrt(disc)) / (2 * a), (-b + std::sqrt(disc)) / (2 * a)}) {
|
||||
const double x = o[0] + d[0] * t, z = oz + d[2] * t;
|
||||
if (t <= 0 || z >= 0) continue;
|
||||
*u = r * std::atan2(x, -z), *v = o[1] + d[1] * t;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Where the mode leaves the controllers to a VR game (see the top): a hand controller
|
||||
// pointing at a panel, its controls, or a floating window's popups keeps that panel's laser
|
||||
// on (UpdateLasers) until kAimLinger ticks after it points away, like SteamVR's own floating
|
||||
// windows. Leaving takes a wider margin than arriving, and a drag or a held button keeps it
|
||||
// on. The keyboard is one overlay, so SteamVR's own intersection test does there.
|
||||
void UpdateAim() {
|
||||
if (LasersByMode()) return;
|
||||
std::vector<Mat> lasers;
|
||||
for (vr::TrackedDeviceIndex_t i = 1; i < vr::k_unMaxTrackedDeviceCount; ++i) {
|
||||
Mat d;
|
||||
if (IsHandController(i) && LaserPose(i, &d)) lasers.push_back(d);
|
||||
}
|
||||
for (auto &[index, s] : g_screens) {
|
||||
Mat p;
|
||||
if (!s.visible || !ScreenPose(s, &p)) continue;
|
||||
if (s.drag != Drag::None || (g_press.buttons && g_press.screen == index)) {
|
||||
s.aimUntil = g_tick + kAimLinger;
|
||||
continue;
|
||||
}
|
||||
const double m = s.grip * (g_tick < s.aimUntil ? 2.0 : 0.25), h = s.heightMetres();
|
||||
// The panel and its controls: the bar row under it, the resize tab off its corner.
|
||||
const double halfW = std::max(s.metres / 2 + s.grip, s.chrome / 2 + s.chrome * 0.12 + s.grip * 2) + m;
|
||||
const double top = h / 2 + m, bottom = std::min(BarY(s) - s.grip, -(h / 2 + s.grip)) - m;
|
||||
for (const Mat &l : lasers) {
|
||||
double u, v;
|
||||
if (!RayOnSurface(s, p, l, &u, &v)) continue;
|
||||
bool on = std::fabs(u) <= halfW && v <= top && v >= bottom;
|
||||
for (const auto &[k, sub] : s.subs) {
|
||||
if (on || s.cropW <= 0) break;
|
||||
const double su = (sub.x + sub.w / 2.0 - (s.cropX + s.cropW / 2.0)) * s.mpp;
|
||||
const double sv = -(sub.y + sub.h / 2.0 - (s.cropY + s.cropH / 2.0)) * s.mpp;
|
||||
on = std::fabs(u - su) <= sub.w * s.mpp / 2 + m && std::fabs(v - sv) <= sub.h * s.mpp / 2 + m;
|
||||
}
|
||||
if (on) {
|
||||
s.aimUntil = g_tick + kAimLinger;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (keyboard::Shown())
|
||||
for (const Mat &l : lasers)
|
||||
if (keyboard::Aimed(l)) g_keyboardAimUntil = g_tick + kAimLinger;
|
||||
}
|
||||
|
||||
const char *LasersName() {
|
||||
switch (g_lasers) {
|
||||
case Lasers::Always: return "always";
|
||||
@@ -2075,6 +2138,7 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) {
|
||||
UpdateArrange();
|
||||
UpdateVisibility();
|
||||
UpdateAttention();
|
||||
UpdateAim();
|
||||
UpdateLasers();
|
||||
UpdateControls();
|
||||
UpdateGuides();
|
||||
@@ -2101,7 +2165,7 @@ bool ft_vr_keyboard_show(int index) {
|
||||
const double fx = -head.m[0][2], fz = -head.m[2][2], n = std::sqrt(fx * fx + fz * fz) + 1e-9;
|
||||
const double at[3] = {head.m[0][3] + fx / n * kKeyboardAhead, head.m[1][3] - kKeyboardBelow,
|
||||
head.m[2][3] + fz / n * kKeyboardAhead};
|
||||
keyboard::SetLasers(g_lasers == Lasers::Always || (g_lasers == Lasers::OutsideGames && !g_gameRunning));
|
||||
keyboard::SetLasers(LasersByMode());
|
||||
g_steamInFront = SteamInFront();
|
||||
if (g_steamInFront) {
|
||||
g_asidePose = FacingPose(at, head);
|
||||
|
||||
@@ -36,6 +36,10 @@ check "distrobox" on_frame 'test -x ~/.local/bin/distrobox && ~/.local/bin/distr
|
||||
check "container $FRAME_BOX" on_frame "podman ps -a --filter name=^$FRAME_BOX\$ --format '{{.Image}} {{.Status}}' | grep ."
|
||||
check "repo on the Frame" on_frame 'pwd'
|
||||
check "free space in ~" on_frame "df -h ~ | awk 'NR==2{print \$4\" free\"}'"
|
||||
# A taskbar saved on a screen the desktop doesn't have is hidden; the desktop's next start
|
||||
# moves it to the first screen (session/fix-panels.py).
|
||||
check "taskbar on a screen" on_frame 'set -o pipefail; [ -f session/fix-panels.py ] || { echo "not checked (older checkout)"; exit 0; }
|
||||
python3 session/fix-panels.py --check | paste -sd ";" | sed "s/;/; /g"'
|
||||
|
||||
echo "what Frametop needs from SteamOS:"
|
||||
if [ "$FRAME_LOCAL" = 1 ]; then
|
||||
|
||||
@@ -42,6 +42,25 @@ for i, s in enumerate(d.get("screens", []), 1):
|
||||
print("visibility:", d.get("visibility"))
|
||||
PY
|
||||
|
||||
section "Plasma panels and outputs"
|
||||
python3 "$repo/session/fix-panels.py" --check 2>&1 || true
|
||||
python3 - "$repo" <<'PY' 2>&1 || true
|
||||
import json, subprocess, sys
|
||||
sys.path.insert(0, sys.argv[1] + "/layout")
|
||||
import ft_layout
|
||||
env = ft_layout.nested_env()
|
||||
if not env:
|
||||
sys.exit(print("desktop not running: no live outputs or panels"))
|
||||
outs = json.loads(subprocess.run(["kscreen-doctor", "-j"], capture_output=True, text=True, env=env, timeout=10).stdout or "{}")
|
||||
for o in outs.get("outputs", []):
|
||||
size = o.get("size") or {}
|
||||
print(f"output {o.get('name')}: enabled={o.get('enabled')} priority={o.get('priority')} {size.get('width')}x{size.get('height')}")
|
||||
js = "print(JSON.stringify(panels().map(p => ({id: p.id, screen: p.screen, location: p.location}))))"
|
||||
r = subprocess.run(["qdbus6", "org.kde.plasmashell", "/PlasmaShell", "org.kde.PlasmaShell.evaluateScript", js],
|
||||
capture_output=True, text=True, env=env, timeout=10)
|
||||
print("live panels:", (r.stdout or r.stderr).strip())
|
||||
PY
|
||||
|
||||
section "Input relay (last 60 lines)"
|
||||
journalctl --user -u frametop-input-relay -n 60 --no-pager -o short 2>/dev/null
|
||||
section "Pointer helper (last 60 lines)"
|
||||
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bring back a taskbar that Plasma saved against a screen this desktop doesn't have.
|
||||
|
||||
Plasma 6.2.5 ties each panel to a screen number (lastScreen in its containment), and the
|
||||
numbers rank the enabled outputs by priority: 0 is the primary screen. A panel whose
|
||||
number is past the screen count gets no view and stays hidden; Plasma never moves it,
|
||||
even on a later start (sanitizeScreenLayout() only remaps a panel whose number has no
|
||||
desktop, and the Frametop desktop keeps a desktop for every spare output it has seen).
|
||||
So a taskbar saved on a spare output (issue #18), or on a screen that a smaller layout
|
||||
dropped, was lost until the config was deleted.
|
||||
|
||||
The session runs this before Plasma starts, so nothing races Plasma for the file. Each
|
||||
panel numbered at or past the screen count moves to screen 0, with its system tray's
|
||||
containment, and keeps its widgets and settings. A panel is left where it is when screen
|
||||
0 already has a panel on that edge: it comes back by itself if the screens do. The file
|
||||
is backed up once per repair (<file>.ft-bak), and the changes go through kwriteconfig6.
|
||||
|
||||
fix-panels.py [--screens N] [--file APPLETSRC] [--check]
|
||||
--screens the desktop's screen count (default: the configured layout's)
|
||||
--file default: $XDG_CONFIG_HOME/plasma-org.kde.plasma.desktop-appletsrc, with
|
||||
XDG_CONFIG_HOME defaulting to the Frametop desktop's ~/.config/frametop
|
||||
--check change nothing; list the panels, and exit 1 if one is lost
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
EDGES = {3: "top", 4: "bottom", 5: "left", 6: "right"} # Plasma::Types::Location
|
||||
SYSTRAY = "org.kde.plasma.private.systemtray"
|
||||
GROUP = re.compile(r"\[([^\]]*)\]")
|
||||
|
||||
|
||||
def parse(text):
|
||||
"""KConfig text -> {(group, subgroup, ...): {key: value}}."""
|
||||
groups, cur = {}, None
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("["):
|
||||
cur = tuple(GROUP.findall(line))
|
||||
groups.setdefault(cur, {})
|
||||
elif cur is not None and "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
groups[cur][re.sub(r"\[\$.*\]$", "", k.strip())] = v.strip()
|
||||
return groups
|
||||
|
||||
|
||||
def number(v, default=-1):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def panels(groups):
|
||||
"""The panels, by containment id: location, lastScreen, and the ids of their system
|
||||
trays' own containments (which sit on the same edge and screen as the panel)."""
|
||||
trays, found = {}, {}
|
||||
for g, keys in groups.items():
|
||||
if len(g) == 5 and g[0] == "Containments" and g[2] == "Applets" and g[4] == "Configuration":
|
||||
tray = keys.get("SystrayContainmentId")
|
||||
if tray:
|
||||
trays.setdefault(g[1], []).append(tray)
|
||||
owned = {t for ts in trays.values() for t in ts}
|
||||
for g, keys in groups.items():
|
||||
if len(g) != 2 or g[0] != "Containments" or number(g[1]) <= 0 or g[1] in owned:
|
||||
continue
|
||||
loc = number(keys.get("location"), 0)
|
||||
if loc in EDGES and keys.get("plugin") != SYSTRAY:
|
||||
found[g[1]] = {"location": loc, "screen": number(keys.get("lastScreen")),
|
||||
"plugin": keys.get("plugin", "?"), "trays": trays.get(g[1], [])}
|
||||
return dict(sorted(found.items(), key=lambda kv: number(kv[0])))
|
||||
|
||||
|
||||
def plan(groups, screens):
|
||||
"""(moves, kept): moves are (panel id, from screen, containment ids to put on screen 0);
|
||||
kept are (panel id, from screen, why) for lost panels left alone."""
|
||||
found = panels(groups)
|
||||
taken = {(p["screen"], p["location"]) for p in found.values() if 0 <= p["screen"] < screens}
|
||||
moves, kept = [], []
|
||||
for pid, p in found.items():
|
||||
if p["screen"] < screens:
|
||||
continue # on a screen this desktop has (Plasma puts -1 on the first one itself)
|
||||
if (0, p["location"]) in taken:
|
||||
kept.append((pid, p["screen"], f"the first screen already has a {EDGES[p['location']]} panel"))
|
||||
continue
|
||||
taken.add((0, p["location"]))
|
||||
moves.append((pid, p["screen"], [pid, *p["trays"]]))
|
||||
return moves, kept
|
||||
|
||||
|
||||
def default_screens():
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "layout"))
|
||||
import ft_layout
|
||||
return ft_layout.screen_count()
|
||||
|
||||
|
||||
def main(argv):
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
ap.add_argument("--screens", type=int)
|
||||
ap.add_argument("--file")
|
||||
ap.add_argument("--check", action="store_true")
|
||||
a = ap.parse_args(argv)
|
||||
config = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config/frametop")
|
||||
path = a.file or os.path.join(config, "plasma-org.kde.plasma.desktop-appletsrc")
|
||||
screens = a.screens if a.screens else default_screens()
|
||||
try:
|
||||
with open(path) as f:
|
||||
groups = parse(f.read())
|
||||
except FileNotFoundError:
|
||||
if a.check:
|
||||
print("no Plasma config yet (Plasma makes the default taskbar)")
|
||||
return 0
|
||||
moves, kept = plan(groups, screens)
|
||||
|
||||
if a.check:
|
||||
found = panels(groups)
|
||||
for pid, p in found.items():
|
||||
lost = " (lost: no such screen)" if p["screen"] >= screens else ""
|
||||
print(f"panel {pid}: screen {p['screen']}, {EDGES[p['location']]}{lost}")
|
||||
if not found:
|
||||
print("no panels")
|
||||
print(f"{screens} screen(s)")
|
||||
return 1 if moves or kept else 0
|
||||
|
||||
if moves:
|
||||
shutil.copy2(path, path + ".ft-bak")
|
||||
for pid, was, ids in moves:
|
||||
for cid in ids:
|
||||
subprocess.run(["kwriteconfig6", "--file", os.path.abspath(path), "--group", "Containments",
|
||||
"--group", cid, "--key", "lastScreen", "0"], check=True)
|
||||
print(f"frametop: panel {pid} was saved on screen {was}, which this desktop doesn't have "
|
||||
f"({screens} screen(s)); moved it to the first screen (backup: {path}.ft-bak)", file=sys.stderr)
|
||||
for pid, was, why in kept:
|
||||
print(f"frametop: panel {pid} is saved on screen {was}, which this desktop doesn't have; "
|
||||
f"left there: {why}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -272,4 +272,9 @@ fi
|
||||
# with both, apps would open twice.
|
||||
kwriteconfig6 --file "$XDG_CONFIG_HOME/ksmserverrc" --group General --key loginMode emptySession
|
||||
|
||||
# Plasma keeps a panel on a screen number, and never moves one whose screen this desktop
|
||||
# doesn't have, like a spare output or a screen a smaller layout dropped. Put such a panel
|
||||
# back on the first (primary) screen before Plasma reads the file (session/fix-panels.py).
|
||||
python3 "$here/fix-panels.py" --screens "$screens" || true
|
||||
|
||||
dbus-run-session startplasma-wayland
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for session/fix-panels.py: issue #18's config (the taskbar saved on spare output 8
|
||||
of a 3-screen desktop), panels that are fine, an edge that's taken, and a real run of
|
||||
kwriteconfig6 on a copy. Nothing here touches the running desktop or its config.
|
||||
|
||||
python3 session/tests/test_fix_panels.py
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.realpath(__file__))
|
||||
SCRIPT = os.path.join(HERE, "..", "fix-panels.py")
|
||||
spec = importlib.util.spec_from_file_location("fix_panels", SCRIPT)
|
||||
fp = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(fp)
|
||||
|
||||
|
||||
def desktop(cid, screen):
|
||||
return f"""[Containments][{cid}]
|
||||
activityId=7e1d1a3c-0000-4000-8000-000000000000
|
||||
formfactor=0
|
||||
immutability=1
|
||||
lastScreen={screen}
|
||||
location=0
|
||||
plugin=org.kde.plasma.folder
|
||||
wallpaperplugin=org.kde.image
|
||||
"""
|
||||
|
||||
|
||||
def panel(cid, screen, location=4, tray=None):
|
||||
text = f"""[Containments][{cid}]
|
||||
activityId=
|
||||
formfactor=2
|
||||
immutability=1
|
||||
lastScreen={screen}
|
||||
location={location}
|
||||
plugin=org.kde.panel
|
||||
wallpaperplugin=org.kde.image
|
||||
|
||||
[Containments][{cid}][Applets][{cid}1]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.kickoff
|
||||
"""
|
||||
if tray:
|
||||
text += f"""
|
||||
[Containments][{cid}][Applets][{cid}2]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.systemtray
|
||||
|
||||
[Containments][{cid}][Applets][{cid}2][Configuration]
|
||||
PreloadWeight=90
|
||||
SystrayContainmentId={tray}
|
||||
|
||||
[Containments][{tray}]
|
||||
activityId=
|
||||
formfactor=2
|
||||
immutability=1
|
||||
lastScreen={screen}
|
||||
location={location}
|
||||
plugin=org.kde.plasma.private.systemtray
|
||||
popupHeight=432
|
||||
"""
|
||||
return text
|
||||
|
||||
|
||||
ISSUE_18 = "\n".join([desktop(1, 0), desktop(18, 1), desktop(19, 2)] + [desktop(20 + i, 3 + i) for i in range(8)]
|
||||
+ [panel(10, 8, tray=11), "[ScreenMapping]\nitemsOnDisabledScreens=\n"])
|
||||
|
||||
|
||||
class Plan(unittest.TestCase):
|
||||
def test_issue_18(self):
|
||||
moves, kept = fp.plan(fp.parse(ISSUE_18), 3)
|
||||
self.assertEqual(moves, [("10", 8, ["10", "11"])])
|
||||
self.assertEqual(kept, [])
|
||||
|
||||
def test_panel_on_a_screen_stays(self):
|
||||
for screen in (0, 2, -1):
|
||||
moves, kept = fp.plan(fp.parse(desktop(1, 0) + panel(2, screen, tray=8)), 3)
|
||||
self.assertEqual((moves, kept), ([], []), screen)
|
||||
|
||||
def test_tray_is_not_a_panel(self):
|
||||
found = fp.panels(fp.parse(panel(2, 0, tray=8)))
|
||||
self.assertEqual(list(found), ["2"])
|
||||
self.assertEqual(found["2"]["trays"], ["8"])
|
||||
|
||||
def test_desktops_never_move(self):
|
||||
moves, _ = fp.plan(fp.parse(ISSUE_18), 1)
|
||||
self.assertEqual([m[0] for m in moves], ["10"])
|
||||
|
||||
def test_edge_taken_on_first_screen(self):
|
||||
text = panel(2, 0, tray=8) + panel(10, 8, tray=11)
|
||||
moves, kept = fp.plan(fp.parse(text), 3)
|
||||
self.assertEqual(moves, [])
|
||||
self.assertEqual([k[:2] for k in kept], [("10", 8)])
|
||||
|
||||
def test_other_edge_is_free(self):
|
||||
moves, kept = fp.plan(fp.parse(panel(2, 0) + panel(10, 4, location=3)), 3)
|
||||
self.assertEqual(moves, [("10", 4, ["10"])])
|
||||
self.assertEqual(kept, [])
|
||||
|
||||
def test_two_lost_on_one_edge(self):
|
||||
moves, kept = fp.plan(fp.parse(panel(10, 5) + panel(12, 8)), 3)
|
||||
self.assertEqual([m[0] for m in moves], ["10"])
|
||||
self.assertEqual([k[0] for k in kept], ["12"])
|
||||
|
||||
def test_screen_count_went_down(self):
|
||||
moves, _ = fp.plan(fp.parse(panel(2, 1, location=3)), 1)
|
||||
self.assertEqual(moves, [("2", 1, ["2"])])
|
||||
|
||||
|
||||
class Run(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.path = os.path.join(self.dir.name, "plasma-org.kde.plasma.desktop-appletsrc")
|
||||
with open(self.path, "w") as f:
|
||||
f.write(ISSUE_18)
|
||||
|
||||
def tearDown(self):
|
||||
self.dir.cleanup()
|
||||
|
||||
def run_script(self, *args):
|
||||
return subprocess.run([sys.executable, SCRIPT, "--file", self.path, "--screens", "3", *args],
|
||||
capture_output=True, text=True)
|
||||
|
||||
def test_check_reports_and_changes_nothing(self):
|
||||
r = self.run_script("--check")
|
||||
self.assertEqual(r.returncode, 1)
|
||||
self.assertIn("panel 10: screen 8, bottom (lost", r.stdout)
|
||||
with open(self.path) as f:
|
||||
self.assertEqual(f.read(), ISSUE_18)
|
||||
|
||||
def test_repair(self):
|
||||
r = self.run_script()
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
self.assertIn("moved it to the first screen", r.stderr)
|
||||
with open(self.path) as f:
|
||||
groups = fp.parse(f.read())
|
||||
self.assertEqual(groups[("Containments", "10")]["lastScreen"], "0")
|
||||
self.assertEqual(groups[("Containments", "11")]["lastScreen"], "0")
|
||||
self.assertEqual(groups[("Containments", "10", "Applets", "101")]["plugin"], "org.kde.plasma.kickoff")
|
||||
self.assertEqual(groups[("Containments", "25")]["lastScreen"], "8") # a spare's desktop
|
||||
with open(self.path + ".ft-bak") as f:
|
||||
self.assertEqual(f.read(), ISSUE_18)
|
||||
# A second run finds nothing to do and leaves the backup alone.
|
||||
os.remove(self.path + ".ft-bak")
|
||||
r = self.run_script()
|
||||
self.assertEqual((r.returncode, r.stderr), (0, ""))
|
||||
self.assertFalse(os.path.exists(self.path + ".ft-bak"))
|
||||
self.assertEqual(self.run_script("--check").returncode, 0)
|
||||
|
||||
def test_no_config_yet(self):
|
||||
os.remove(self.path)
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
self.assertEqual(self.run_script("--check").returncode, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user