Hide screens one at a time

ft-screens: "conceal <screen|all>" hides a screen on its own, whatever the
visibility mode or the hotkey says, until "reveal"; "concealed" lists them.
(Not "hide N": older builds read anything starting with "hide" as the hotkey.)
ft-layout keeps it per screen ("hidden" in the layout), applies it when it
arranges the screens, and has hide/show N|all and hidden. Display Settings
gets a Shown switch per screen on the Visibility tab. ft-floatd floats a new
window that opens on a hidden screen, and puts a stray window on a screen
that shows. Profiles (next) use it to show only some screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-09-30 22:25:35 -06:00
1 parent 0348821384
commit 9d91ecbcaa
8 files changed
+163 -7

No files matched your search

+1
View File
@@ -57,6 +57,7 @@ If you work in the desktop for long stretches, or leave the headset on a stand,
| Meta+Shift+F over a desktop window | Floats that window in VR as a panel of its own, or puts it back on its screen if it floats. It acts on the window under the pointer, or the active one if the pointer is over the wallpaper. Rebind it, or map it to a mouse or controller button, in Frametop Input Settings (Keyboard page, or Buttons and Controllers as Float window in VR). Float in VR is also in every window's menu (Alt+F3), and the button left of Close in a window's title bar does the same (apps that draw their own title bar, like Chromium and Electron apps, don't have it) |
| Right-click an app in the Application Launcher (or the taskbar) and pick Launch as Standalone | Starts the app with its window floating in VR, where that app last floated, or in front of you the first time. From a terminal: `float/ft-float launch org.kde.dolphin`, or `float/ft-float run <command>` |
| Meta+Shift+H in the desktop | Hides or shows all screens (also in the menu as Hide/Show Screens, and mappable). The Visibility & pins tab of Frametop Display Settings can instead show them only with the dashboard open, or while you look at your wrist |
| Switch a screen to Hidden (Frametop Display Settings, Visibility & pins → Screens shown) | Hides just that screen until you switch it back, whatever the other visibility settings say; new windows that would open on it float instead |
| Leave the headset on a stand | Its displays turn off once it has gone unused for the time set in Frametop Display Settings → Power, even if the stand covers its proximity sensor. Pick it up, or use any mouse, keyboard, or button, and they come back on |
| Play a VR game | The screens hide and your controllers stay in the game. Open the SteamVR dashboard, or press Meta+Shift+H, to see and use them. To keep them visible over games, change During VR games on the Visibility & pins tab; the controllers still stay in the game, and you use the screens with the mouse or the dashboard |
+25
View File
@@ -409,6 +409,31 @@ class Backend(QObject):
def pins(self):
return self._pins
@Property("QVariantList", notify=changed)
def screensShown(self):
"""For each screen, whether it shows (False: hidden on its own, ft-layout hide N)."""
layout = ft_layout.load_layout()
return [not ft_layout.screen_entry(layout, i).get("hidden") for i in range(ft_layout.screen_count(layout))]
@Slot(int, bool)
def setScreenShown(self, index, shown):
"""Hide screen `index` (0-based) on its own, whatever the visibility mode, or show it."""
layout = ft_layout.load_layout()
screens = layout.setdefault("screens", [])
while len(screens) <= index:
screens.append({})
if shown:
screens[index].pop("hidden", None)
else:
screens[index]["hidden"] = True
ft_layout.save_layout(layout)
self.changed.emit()
if self._running:
reply = self._ask_screens(f"{'reveal' if shown else 'conceal'} {index + 1}")
if not (reply and reply.startswith("ok")):
self.message.emit("Saved; the desktop applies it when it next starts "
"(its compositor is older than hiding screens one at a time)", False)
@Slot(str, str)
def pin(self, which, where):
"""Pin screen `which` (1-based, or "all") to "left", "right", or "head" as it is
+21
View File
@@ -802,6 +802,27 @@ Kirigami.ApplicationWindow {
}
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Screens shown" }
Repeater {
model: backend.screensShown
delegate: Controls.Switch {
required property var modelData
required property int index
Kirigami.FormData.label: "Screen " + (index + 1) + ":"
text: modelData ? "Shown" : "Hidden"
checked: modelData
onToggled: backend.setScreenShown(index, checked)
}
}
Controls.Label {
text: "A hidden screen stays hidden whatever the choices above say, and Meta+Shift+H doesn't bring it back. Windows on it stay there; new ones that would open on it float instead."
opacity: 0.7
font: Kirigami.Theme.smallFont
wrapMode: Text.Wrap
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Pinned screens" }
Repeater {
+1
View File
@@ -12,6 +12,7 @@ Built so far (2026-09-29; the KWin side tested on the headless test desktop, `sc
- The float key (2026-09-30): the input relay's `float_toggle` (Meta+Shift+F by default) and `dock_all`, through `float pointer` and `dock all` on ft-floatd. Tested on the headless desktop.
- The title bar button (2026-09-30, branch `float-titlebar`): `decoration/` (Frametop's QML window decoration, installed by the session script; `decoration/apply.sh` switches the running desktop to it or back to Breeze), and keep-below as the floating flag in the script. Tried on the live desktop: the button floats Dolphin and docks it again, the float key and `dock all` keep the flag in step, and maximized windows look right.
- Launching floating (2026-09-30, branch `float-launch`): `ft-float launch APP` and `ft-float run COMMAND`, matching the new window by process (or a child) or by desktop file name for 30 seconds, and each app's remembered place (pose relative to the primary screen, size in pixels, scale) in `~/.config/frametop-float.json`, kept whenever one of its windows stops floating. With nothing remembered, the window opens in front of you, at the primary screen's density, 0.8 to 2 m away. Launch as Standalone (`float/ft_apps.py`): the session writes the desktop file copies and puts them first in `XDG_DATA_DIRS`; ft-floatd rewrites them when apps change. Tried on the live desktop: Dolphin launched floating, came back at its remembered pose after closing, and Konsole floated from `ft-float run`; the right-click entry needs a desktop start to show. The remembered scale is kept but not applied yet (see Known problems).
- Screens hidden one at a time (2026-09-30, branch `screen-hide`): ft-screens' `conceal`/`reveal`/`concealed`, `ft-layout hide|show N`, Screens shown in Display Settings, and ft-floatd floats a new window that opens on a hidden screen (and puts strays on a screen that shows).
Not built yet: phase 2 (the ghost, tear-off by dragging, push-flush docking), the rest of phase 3 (new windows of a floating app placed where that app's last went), and phase 4.
+2 -1
View File
@@ -48,7 +48,7 @@ The Visibility & pins tab of Frametop Display Settings decides when the screens
- While you look at a chosen controller (the wrist gesture).
- Only after you show them with the hotkey.
In the last three modes the hotkey shows the screens anyway. Two more settings on the same tab cover VR games, which ft-screens detects as SteamVR scene apps:
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.
@@ -146,6 +146,7 @@ layout/ft-layout pin N|all left|right|head # pin as they are now; unpin N|all
layout/ft-layout plan # print the arrangement as JSON (no VR needed)
layout/ft-layout scale # per-screen scale, positions (as the screens are around you), and taskbar screen, to KWin
layout/ft-layout toggle # hide or show all screens
layout/ft-layout hide N|all # hide a screen on its own, whatever the visibility mode; show N|all brings it back, hidden lists them
display-settings/install.sh # menu entries and the Meta+Shift+R and Meta+Shift+H shortcuts
```
+46 -5
View File
@@ -126,6 +126,20 @@ def kscreen(*args):
return ""
def output_rects():
"""Each output's place and size in KWin's layout (logical)."""
try:
data = json.loads(kscreen("-j") or "{}")
except ValueError:
return {}
out = {}
for o in data.get("outputs", []):
pos, size, scale = o.get("pos") or {}, o.get("size") or {}, float(o.get("scale", 1)) or 1.0
if o.get("name") and size:
out[o["name"]] = (pos.get("x", 0), pos.get("y", 0), size.get("width", 0) / scale, size.get("height", 0) / scale)
return out
def output_scales():
try:
data = json.loads(kscreen("-j") or "{}")
@@ -318,6 +332,11 @@ class Daemon:
if launch:
self.float_launched(ev, launch)
return
if kind == "added" and not f and self.on_hidden_screen(ev):
# Nobody would see it there (a profile can hide every screen): it floats instead.
log(f"{wid[:9]} ({ev.get('cls')}) opened on a hidden screen")
self.float_launched(ev, None)
return
if kind == "float-request":
self.float_window(ev)
elif kind == "dock-request":
@@ -387,8 +406,16 @@ class Daemon:
for o in self.floats.values()):
self.float_window(ev)
else:
self.command(cmd="place", id=wid, output="WL-0", x=ev["frame"]["x"] % 400 + 100,
y=ev["frame"]["y"] % 300 + 100, w=ev["frame"]["w"], h=ev["frame"]["h"])
# Onto the first screen that shows (or floating, if none does).
hidden = self.concealed()
shown = [i for i in range(self.screens_n) if i + 1 not in hidden]
if not shown:
self.float_launched(ev, None)
return
name = f"WL-{shown[0]}"
x0, y0, _, _ = output_rects().get(name, (0, 0, 0, 0))
self.command(cmd="place", id=wid, output=name, x=x0 + ev["frame"]["x"] % 400 + 100,
y=y0 + ev["frame"]["y"] % 300 + 100, w=ev["frame"]["w"], h=ev["frame"]["h"])
# ------------------------------------------------------------ floating and docking
@@ -672,10 +699,24 @@ class Daemon:
return la
return None
def concealed(self):
"""The screens (1-based) hidden on their own (ft-layout hide N)."""
reply = self.screens.ask("concealed", quiet=True)
return {int(w) for w in reply.split()[1:] if w.isdigit()} if reply.startswith("ok") else set()
def on_hidden_screen(self, ev):
"""A new top-level window on a screen that's hidden on its own."""
m = re.match(r"WL-(\d+)$", ev.get("output", ""))
if not m or int(m.group(1)) >= self.screens_n:
return False
if not ev.get("normal") or ev.get("popup") or ev.get("transient") or ev.get("cls") == "ksplashqml":
return False
return int(m.group(1)) + 1 in self.concealed()
def float_launched(self, ev, launch):
"""Float a launched app's window where that app last floated (its size and scale too),
or in front of you, at the primary screen's density."""
app = ev.get("app") or launch.app
"""Float a launched app's window (or one that opened on a hidden screen) where that app
last floated (its size too), or in front of you, at the primary screen's density."""
app = ev.get("app") or (launch.app if launch else "")
known = self.places.get(app) if app else None
ref = self.reference()
place = to_world(ref, known["rel"]) if known and ref and len(known.get("rel", [])) == 12 else self.in_front()
+48
View File
@@ -28,6 +28,8 @@ you face (yaw only), like a recenter. It lives in ~/.config/frametop-layout.json
"pin": {"hand": "left", "rel": [12]}, ft-screens: riding on that controller
(left | right), or on the headset (head)
"scale": 1.0, KWin output scale (1.0 = 100%)
"hidden": true, ft-screens: hidden on its own, whatever the
visibility mode (ft-layout hide N)
"pos": [x, y, z], "face": [yaw, pitch], "roll": 0, custom layout
"rotation": "normal" | "left" | "right"}, ...], gamescope only
"panel_size": [w, h]} gamescope: last measured panel size
@@ -52,6 +54,9 @@ Usage (on the Frame host; Frametop Display Settings calls it too):
ft-layout screen-args ft-screens' --screen arguments for the session script
ft-layout remote-view the primary screen's place in the workspace, for the VNC bridge
ft-layout toggle hide or show all screens (ft-screens)
ft-layout hide N|all hide a screen on its own (it stays hidden whatever the
ft-layout show N|all visibility mode or the hotkey say), or show it again
ft-layout hidden the screens hidden on their own
ft-layout pin all|N left|right|head pin screens to a wrist or your head as they are;
unpin all|N
"""
@@ -393,6 +398,42 @@ def send_visibility(sock, layout):
sock.ask(f"ingames {v['in_games']}")
def send_hidden(sock, layout):
"""Screens hidden on their own (ft-screens' conceal/reveal)."""
for i in range(screen_count(layout)):
word = "conceal" if screen_entry(layout, i).get("hidden") else "reveal"
try:
sock.ask(f"{word} {i + 1}")
except RuntimeError as e:
log(f"screens hidden on their own: {e}") # an ft-screens from before conceal
return
def set_hidden(which, hidden):
"""Hide (or show) screen N (1-based) or "all" on its own: saved, and applied if the
desktop runs."""
layout = load_layout()
n = screen_count(layout)
picked = range(n) if which == "all" else [int(which) - 1] if which.isdigit() else []
if not picked or not all(0 <= i < n for i in picked):
raise RuntimeError(f"no screen {which} (1 to {n})")
screens = layout.setdefault("screens", [])
while len(screens) < n:
screens.append({})
for i in picked:
if hidden:
screens[i]["hidden"] = True
else:
screens[i].pop("hidden", None)
save_layout(layout)
try:
sock = screens_socket()
for i in picked:
sock.ask(f"{'conceal' if hidden else 'reveal'} {i + 1}")
except RuntimeError as e:
log(f"saved; not applied now: {e}")
def parse_get(reply):
"""ft-screens' "get": pose, size, curve, and the pin (hand and controller->screen)."""
f = reply.split()[1:]
@@ -441,6 +482,7 @@ def apply_screens(wait=0):
sock.ask(f"pin {i + 1} {pin['hand']} " + " ".join(f"{v:.5f}" for v in pin["rel"]))
except RuntimeError as e:
log(f"screen {i + 1}: {e}") # that controller isn't on
send_hidden(sock, layout)
try:
sock.ask("vrkeyboard close") # the keyboard, if open, goes too: a reset starts over
except RuntimeError:
@@ -855,6 +897,11 @@ def main(argv):
print(remote_view())
elif cmd == "toggle":
log(screens_socket().ask("toggle"))
elif cmd in ("hide", "show") and len(argv) == 3:
set_hidden(argv[2], cmd == "hide")
elif cmd == "hidden":
layout = load_layout()
print(" ".join(str(i + 1) for i in range(screen_count(layout)) if screen_entry(layout, i).get("hidden")))
elif cmd == "layouts":
layout = load_layout()
for name in layout_names(layout):
@@ -887,6 +934,7 @@ def main(argv):
while screens_up(sock) < screen_count() and time.time() < deadline:
time.sleep(1)
send_visibility(sock, load_layout())
send_hidden(sock, load_layout())
except RuntimeError as e:
log(f"visibility: {e}")
else:
+19 -1
View File
@@ -31,6 +31,9 @@
// - visibility modes: always (the hide hotkey toggles), only with the SteamVR dashboard
// open, while you look at a chosen controller (the wrist gesture), or toggle only
// (hidden until the hotkey shows them).
// - a screen hidden on its own ("conceal <screen>", from ft-layout and profiles) stays
// hidden whatever the mode or the hotkey says, until "reveal <screen>". (Not "hide
// <screen>": an older build reads anything starting with "hide" as the hotkey's hide.)
// - controllers on the screens: while visible, the screens can keep SteamVR's laser mouse
// on (VROverlayFlags_MakeOverlaysInteractiveIfVisible), so controllers use them with
// the dashboard closed. That also takes the controllers away from a VR game, so by
@@ -245,6 +248,7 @@ struct Screen {
double curve = 0; // cylinder radius in metres; 0 = flat
const void *shown = nullptr; // a frame arrived
bool visible = false; // shown in VR right now
bool alone = false; // hidden on its own (conceal <screen>), whatever the mode
float alpha = 1;
vr::TrackedDeviceIndex_t pinned = kNone; // riding on this controller
Mat pinRel = Identity(); // controller -> screen
@@ -788,7 +792,7 @@ void UpdateVisibility() {
Mat head;
const bool haveHead = DevicePose(vr::k_unTrackedDeviceIndex_Hmd, &head);
for (auto &[i, s] : g_screens) {
bool visible = s.shown && (shared || s.drag != Drag::None);
bool visible = s.shown && (shared || s.drag != Drag::None) && !s.alone;
// A floating window's panel: while a window floats on it, its output is on, and the
// window isn't minimized (and once it has a crop).
if (s.floating) visible = visible && s.floatOn && s.outputOn && !s.minimized && s.cropW > 0;
@@ -1944,6 +1948,8 @@ void ft_vr_keyboard_hide(void) {
// wrist <degrees> a pinned screen shows while you see its front within this
// gesture <left|right> <degrees> the gesture mode: look within this of that controller
// hide | show | toggle the manual switch (see g_manual)
// conceal <screen|all> | reveal <screen|all> a screen hidden on its own, whatever the mode
// concealed -> "ok [<screen> ...]" the screens hidden on their own
// controllers always|outside_games|dashboard when controllers' lasers work the screens
// ingames hide|visible during a VR game, "always" acts like "only with the dashboard"
// (hide), or stays as it is (visible)
@@ -2076,6 +2082,18 @@ void ft_vr_command(const char *cmd, char *reply, int size) {
g_gestureHand = std::strcmp(hand, "right") == 0 ? "right" : "left";
g_gestureAngle = std::clamp(w, 5.0, 90.0);
std::snprintf(reply, size, "ok");
} else if (std::strncmp(cmd, "concealed", 9) == 0) {
int len = std::snprintf(reply, size, "ok");
for (auto &[i, s] : g_screens)
if (len < size && !s.floating && s.alone) len += std::snprintf(reply + len, size - len, " %d", i + 1);
} else if (std::sscanf(cmd, "conceal %15s", word) == 1 || std::sscanf(cmd, "reveal %15s", word) == 1) {
const bool hide = cmd[0] == 'c';
const Screen *one = std::strcmp(word, "all") ? Find(std::atoi(word)) : nullptr;
if (std::strcmp(word, "all") && (!one || one->floating))
return (void)std::snprintf(reply, size, "error no screen %s", word);
each(word, [&](Screen &s) { s.alone = hide; });
UpdateVisibility();
std::snprintf(reply, size, "ok");
} else if (!std::strncmp(cmd, "hide", 4) || !std::strncmp(cmd, "show", 4) || !std::strncmp(cmd, "toggle", 6)) {
const bool always = EffectiveMode() == Mode::Always;
const bool shownNow = always ? !g_manual : g_manual;