Compare commits

..

39 commits

Author SHA1 Message Date
ddc2e4cb81 Ignore .preview-config/ 2026-04-28 08:20:41 +02:00
f0c6abc6d0 Default to ShiftDark in the look-and-feel package
Switch from BreezeLight to ShiftDark and enable the breeze-dark icon
theme and window decoration to match.
2026-04-28 00:13:49 +02:00
75bcbb4718 Add Shift color schemes
Replace KDE blue (#3DAEE9) with a glacial teal (#2EB8A8) accent.
Neutral surfaces and semantic status colors unchanged from Breeze.
Both dark and light variants install to ${KDE_INSTALL_DATADIR}/color-schemes.
2026-04-28 00:13:36 +02:00
d8c8f61c57 Fix app drawer not scrolling in convergence mode
interactive starts false at atYBeginning so HomeScreenState can
own the swipe-up gesture that opens the drawer on phones. No such
gesture exists in convergence mode, leaving the grid permanently
non-interactive. Short-circuit the condition when convergence
mode is active.
2026-04-27 20:51:47 +02:00
1c0efb522f Keep status bar opaque in convergence mode
In mobile mode the status bar goes transparent when on the
homescreen so the wallpaper shows through. That is correct for
phone UX. In convergence/desktop mode it clashes with the always-
opaque dark dock: the top area looks light while the bottom is
dark. Return transparentBackground=false when convergence mode
is enabled so the panel stays consistently opaque.
2026-04-27 14:28:28 +02:00
c520a8e960 Use system theme colors in app drawer and dock
Remove hardcoded Complementary override and white color literals
from AppDrawerHeader. In mobile mode the Plasma containment
framework already provides Complementary context; in convergence
mode the drawerOverlay Window starts a fresh Window context.
Both cases now propagate correctly without an explicit override.

Also set Kirigami.Theme.Window on the convergence dock
FavouritesBar so icon labels and hover highlights follow the
active system palette instead of the wallpaper context.
2026-04-27 14:28:21 +02:00
8a4087bf96 Replace KDE logo with Shift brand icon
Three white circles on dark background. Replaces start-here-kde in
the dock home button and the phone navigation bar.
2026-04-27 13:51:25 +02:00
f07c56716a Use monochrome gamepad icon for gaming tile
input-gaming is a full-color Breeze icon with hard-coded gradients;
it cannot be made monochrome by a delegate-level mask. Switch to
input-gamepad, which uses fill:currentColor and matches the line-art
style of every other quick-settings tile icon.
2026-04-25 10:34:17 +02:00
76a4a7111e Merge feature/gaming-shell-mode into master 2026-04-25 10:22:50 +02:00
bbb22b9289 Remove dead code before merge
Drop the empty loadFlatpakGames() stub — desktop .desktop files
with the Game category are already covered by loadDesktopGames().

Remove InstalledRole: the field was always true and nothing in QML
consumed it.

gamepadAt() is an internal helper; drop Q_INVOKABLE so it isn't
part of the public QML surface.
2026-04-25 10:19:04 +02:00
69b8484025 Hide HUD while a game window is fullscreen
A mapped LayerShell surface prevents KWin from using DRM direct
scanout for fullscreen windows. Setting showing=false fades out
the HUD then sets visible=false, unmapping the Wayland surface
and allowing KWin to bypass the compositor render loop entirely
for the game frame.

The HUD reappears when the game exits or is minimised. Error
toasts still work because a failed launch never produces a
fullscreen window, so showingWindow remains false.
2026-04-25 09:48:50 +02:00
0ce8d6eb46 Add HUD toasts and quick-resume shortcut
Connect to gameLaunched and gameLaunchFailed signals. Launch
toasts dismiss after 3 s; error toasts after 5 s with a red
background. The window grows downward from the HUD pill to
accommodate the toast pill.

When recentGamesChanged fires, the most recently played game
is stored in quickResumeGame. While set, the HUD pill widens
leftward to show the game name and a play button that calls
launchByStorageId directly — no need to open Game Center.
2026-04-25 09:48:42 +02:00
739cc33049 Update Game Center with pinning and per-game settings
Star badge on pinned tiles (passive Kirigami.Icon, no event
handlers to avoid pointer-feedback loops). Pin/Unpin action
and per-game FPS cap + overlay rows added to the details dialog.

Per-game FPS and overlay rows use ButtonGroup with a Global
option that deletes the per-game key and falls back to the
global quick-settings value.

Remove isCurrent from info button visibility; add Menu and I
key handlers on the grid for keyboard/gamepad access to details.
2026-04-25 09:48:34 +02:00
b42ef44e23 Add MangoHud overlay, FPS cap, and game pinning
Inject MangoHud when launching desktop and command-line games.
MANGOHUD_CONFIG is built per-launch via a QProcess instance so
env vars are isolated to each child process — qputenv is not used.

Global overlay toggle and FPS cap (Off/30/40/60) are stored as
properties on GameLauncherProvider and reflected in the quick
settings panel. Per-game overrides stored in plasmamobilerc under
[GamingPerGame/<storageId>] take precedence over the globals at
launch time.

Games can be pinned to the top of the grid. The pinned set is
persisted in plasmamobilerc [GamingPinned] and restored on start.
applyFilter() uses stable_sort so pinned games float to the top
while alphabetical order is preserved within each group.
2026-04-25 09:48:25 +02:00
26945e83fa Show Waydroid apps in Game Center
Read the Waydroid allowlist from plasmamobilerc and treat matching
Waydroid launchers as their own source in the gaming shell.

Surface that source in Game Center so Android titles get their own tab,
source chip, and empty-state guidance.
2026-04-24 09:40:53 +02:00
a8e76002f4 Improve Waydroid app management
Handle unavailable Waydroid sessions more cleanly and add KCM controls for launching apps, Game Shell visibility, and compatibility overrides.
2026-04-24 09:35:28 +02:00
eb177e0394 Harden VDF token parsing
Handle unterminated quoted strings as invalid tokens and stop returning raw pointers to entries in the game list.
2026-04-22 12:42:55 +02:00
4dd0e17afb Track launch state in Game Center
Record recent launches only after the launcher reports success instead of
optimistically on every attempt. For desktop entries, wait for the
ApplicationLauncherJob result; for detached commands, use startDetached's
return value and show an inline error when startup fails.

Parse Steam libraryfolders and app manifests as KeyValues instead of
matching individual lines. This makes Steam discovery less brittle when
the files contain comments, nested blocks, or different formatting.

Keep Continue Playing visible alongside Running and show a temporary
launching state so the overlay does not pretend a task already exists.
Rename the GameMode status text to say what the shell actually knows.
2026-04-22 11:36:49 +02:00
d913c2ab0a Improve SDL gamepad prompts and feedback
Use SDL button labels so shell prompts match the connected
controller instead of always using Xbox-style wording. Expose
trigger rumble, touchpad count, gyro, and accelerometer support
through the gamepad wrapper and surface the relevant capability
labels in the overlay.

Enable background gamepad events and add short rumble feedback
for major shell actions such as opening quick settings, opening
the exit prompt, and launching a game.
2026-04-21 09:57:51 +02:00
55f778ebe8 Refine gaming quick settings visuals and toggles
Expose missing gaming controls directly in the panel so they
remain reachable in convergence mode. Add toggles for DND,
Launch Hint, Night Color, and Perf Overlay with gamepad focus
order updates.

Restyle brightness and volume sliders with rounded Plasma-themed
tracks and handles to remove harsh bar visuals while keeping
existing behavior unchanged.
2026-04-21 09:08:49 +02:00
62243b7f64 Add gaming session control backends
Add PowerProfileControl and GameModeControl singletons and wire
them into gaming mode lifecycle handling. When gaming mode turns
on, keep DND on, switch to the performance profile when available,
and request GameMode. Restore previous state when gaming mode
turns off.

Add an overlayEnabled property in GameLauncherProvider so the
launcher can toggle MangoHud environment variables from QML.
2026-04-21 09:08:03 +02:00
bcb985c652 Add quick settings panel and game sources
Slide-out panel with brightness, volume, Wi-Fi, Bluetooth,
and airplane mode. Fully gamepad-navigable; D-pad and stick
input routed to the panel while open. System status bar
(clock, battery, connectivity) added to game center header.

Lutris games discovered from ~/.local/share/lutris/pga.db,
Heroic from ~/.config/heroic/store_cache/ JSON files.
Deduplication extended to cover all launcher sources.

Desktop entries launched via KIO::ApplicationLauncherJob to
expand Exec field codes. Generic commands parsed with
KShell::splitArgs. SQLite uses RAII cleanup guard.

Clamped SDL axis, rumble, and LED values. Renamed rumble
params from frequency to intensity. GamepadManager uses
singleton factory. setPlayerIndex checks SDL return.

FavouritesBar popup uses screen virtualX/Y for multi-monitor.
2026-04-20 14:09:33 +02:00
4a9a5b2a97 Add empty state, gamepad task controls, and X: Close
Show a PlaceholderMessage in the grid when no games are found,
with context-aware text for empty library vs no search results.

Map gamepad A to activate and X to close in RunningGamesView.
Update the legend bar to show X: Close when running tasks are
visible.
2026-04-20 11:39:58 +02:00
daa8fc7d8a Extract common launch logic into launchEntry
Both launch() and launchByStorageId() duplicated the process
spawn, signal emission, and timestamp bookkeeping. Move it to
a single private launchEntry(GameEntry&) method.
2026-04-20 11:39:50 +02:00
a1044567cd Map shoulder buttons, Start, and right stick
LB/RB cycle through source filter tabs (All/Steam/Desktop).
Start toggles focus between the search field and the grid.
Right stick Y-axis smoothly scrolls the grid at 60 Hz with
speed proportional to deflection. Update the gamepad legend
to show the new bindings.
2026-04-20 11:34:25 +02:00
764a8708f6 Deduplicate games found from multiple sources
When a game appears as both a Steam manifest and an XDG
desktop entry, drop the desktop duplicate. Steam entries are
preferred because they carry cover artwork and route through
the Steam launcher for Proton compatibility.
2026-04-20 11:24:03 +02:00
d58f691c7a Add left-stick navigation and launch fade transition
Expose SDL_GamepadAxis as an Axis enum in GamepadManager so
QML can identify axis events by name.

Convert left-stick deflection into repeated grid navigation
events with a 150ms interval and 0.4 deadzone. First movement
fires immediately when the stick crosses the threshold.

Wrap game launches in a brief fade-to-black curtain (250ms)
before dismissing the overlay, giving visual feedback that the
launch is in progress.
2026-04-20 11:23:55 +02:00
2a171f3964 Fix overlay rendering, hover blink, and dead-end state
The cover art tile used layer.effect with a plain Item instead
of a ShaderEffect, which broke the overlay rendering. Replace
with a clipped Rectangle wrapper.

Remove Behavior on color from the grid delegate background —
GridView currentIndex changes on hover caused the highlight
color to flash visibly between cells.

Fix dangling taskList reference in the gamepad ButtonA and
DPadDown handlers (taskList lives inside RunningGamesView).

Auto-reopen Game Center when the last window closes in gaming
mode so the user is never stranded on a bare wallpaper.
2026-04-20 10:32:30 +02:00
976c770af4 Add cover art, search, filters, and recently played
Grid tiles now show Steam library artwork when available,
falling back to icon+label for games without cover art.
Cell proportions adjusted to 2:3 for portrait covers.

Search bar filters the library by name. Source tabs filter
by All/Steam/Desktop. Both properties live in C++ so QML
just binds filterString and sourceFilter.

"Continue Playing" row shows the last 5 launched games
with artwork, persisted across sessions via plasmamobilerc.

Remove orphaned GameTile.qml (replaced by inline delegate).
2026-04-20 10:03:41 +02:00
1bf8cfb2ba Add gamingshellplugin with SDL3 and game library
SDL3-backed gamepad manager polls at 60Hz, handles hotplug,
exposes battery/rumble/LED per device. Game launcher aggregates
XDG desktop entries, Steam appmanifests, and Flatpak into a
single sorted model.

Game Center uses the new model instead of folio's search model.
D-pad/A/B/Y navigate and launch. Guide button toggles the
overlay. Battery and controller status shown in the HUD.
2026-04-20 09:46:41 +02:00
70fa861b30 Harden gaming mode and fix startup crash
Strut windows committed zero height to layer-shell during
early init, causing a protocol error and session exit. Wrap
height and exclusionZone in Math.max(1, ...) for top bar
and dock.

Persisted gamingModeEnabled=true with hardcoded
gameCenterOpen=false hid panels with no overlay visible.
Initialize gameCenterOpen from the stored setting.

Fix HUD visibility regression from an emergency visible:true
override. Various null-guard and positioning fixes from
static analysis across the gaming shell QML.
2026-04-20 09:46:32 +02:00
50ea5976ce Fix HUD crash, clarify exit dialog, hide hint tile
LayerShell windows with non-spanning anchors (AnchorTop|AnchorRight)
crash the compositor when made invisible because Qt briefly resizes
the surface to 0 before unmapping it. Wrap GamingHUD in a Loader so
the window is destroyed rather than hidden.

Replace the generic 'Yes / Cancel' exit dialog with 'Keep Playing /
Leave' in both the Game Center header and the QS tile, matching the
mental model of a couch user.

The 'Launch Hint' QS tile is now hidden (available: false) when
gaming mode is off so it does not clutter the regular action drawer.
2026-04-19 14:10:51 +02:00
6ac9c58d93 Add Game Center overlay
Full-screen layer-shell overlay containing a game library grid
(XDG Game category, filtered via ApplicationListSearchModel) and
a panel of running tasks built on TaskManager.TasksModel.

Keyboard-navigable with directional focus between the running
tasks row and the game grid. A persistent HUD button lets the
user return after launching a game. Exiting gaming mode requires
an explicit confirmation dialog.

The overlay is a Window with LayerShell.LayerTop so it sits above
running application windows without covering system notifications.
2026-04-19 13:51:20 +02:00
b0739dd9a7 Suppress shell chrome in gaming mode
Hide the navigation panel, status bar, and app drawer while
gamingModeEnabled is active. The KWin convergent-windows script
skips its window policy so game windows are not forcibly tiled
or maximized.

The Home button in gaming mode re-opens the Game Center overlay
rather than the app drawer. A configurable hint nudges the user
toward the HUD button after launching a game.
2026-04-19 13:51:08 +02:00
d901815c9d Add gaming mode setting and quick settings tiles
Add a gamingModeEnabled bool and a gamingDismissHintEnabled bool to
the shell settings plugin, persisted in plasmamobilerc [General],
following the same pattern as convergenceModeEnabled.

Two new quick settings tiles:
- org.kde.plasma.quicksetting.gaming: toggles gaming mode on/off,
  shows explicit on/off label matching the convergence tile style.
- org.kde.plasma.quicksetting.gaminghint: shown while gaming mode
  is active; dismisses the Game Center hint.

Both tiles are added to the default group in quicksettings config.
2026-04-19 13:51:00 +02:00
e9dbfa5ea1 Enable keyboard control in convergence dock
Make the convergence dock fully usable from the keyboard.

Tab now reaches Home, favorites, running tasks, and Overview.
Left and right move across section boundaries, and Enter/Space
triggers the same actions as mouse clicks.

Also add accessible role/name/press actions for these controls
so screen readers expose meaningful button semantics.
2026-04-19 11:10:53 +02:00
e72beb7296 Show status bar down-arrow at rest in convergence 2026-04-19 09:22:53 +02:00
c4a2574bef Fade the dock thumbnail popup in and out
The popup snapped to visible/hidden instantly while every other
surface in the shell uses animated transitions. Add an opacity
fade over shortDuration so it matches the rest of the motion
language. State cleanup waits for the fade-out to finish.
2026-04-19 09:01:28 +02:00
e8bb4f2483 Use theme colors for the convergence dock
The dock background was a hardcoded dark fill because upstream
icon labels are white (for floating over wallpaper). Switching
to Theme.backgroundColor alone just made labels vanish on light
themes, so it kept getting reverted.

Fix the whole stack at once: background uses the Window color
set, hover highlights derive from Theme.textColor instead of
white, and delegate labels switch to Theme.textColor in
convergence mode (still white over wallpaper on mobile).
2026-04-19 08:56:44 +02:00
60 changed files with 6693 additions and 139 deletions

1
.gitignore vendored
View file

@ -20,4 +20,5 @@ build
*.kdev4 *.kdev4
/build* /build*
.prefix/ .prefix/
.preview-config/
preview.sh preview.sh

View file

@ -112,6 +112,7 @@ set_package_properties(PlasmaWaylandProtocols PROPERTIES
find_package(LibKWorkspace CONFIG REQUIRED) find_package(LibKWorkspace CONFIG REQUIRED)
find_package(Libudev REQUIRED) find_package(Libudev REQUIRED)
find_package(SDL3 REQUIRED)
find_package(KWinDBusInterface) find_package(KWinDBusInterface)
set_package_properties(KWinDBusInterface PROPERTIES DESCRIPTION "KWin DBus interface" set_package_properties(KWinDBusInterface PROPERTIES DESCRIPTION "KWin DBus interface"
@ -140,6 +141,8 @@ plasma_install_package(lookandfeel org.kde.breeze.mobile look-and-feel lookandfe
plasma_install_package(shell org.kde.plasma.mobileshell shells) plasma_install_package(shell org.kde.plasma.mobileshell shells)
add_subdirectory(bin) add_subdirectory(bin)
add_subdirectory(color-schemes)
add_subdirectory(icons)
add_subdirectory(components) add_subdirectory(components)
add_subdirectory(containments) add_subdirectory(containments)
add_subdirectory(devices) add_subdirectory(devices)

7
Nebra.code-workspace Normal file
View file

@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}

View file

@ -0,0 +1,8 @@
# SPDX-FileCopyrightText: 2026 Shift contributors
# SPDX-License-Identifier: GPL-2.0-or-later
install(FILES
ShiftDark.colors
ShiftLight.colors
DESTINATION ${KDE_INSTALL_DATADIR}/color-schemes
)

View file

@ -0,0 +1,156 @@
# SPDX-FileCopyrightText: Andrew Lake <jamboarder@gmail.com>
# SPDX-FileCopyrightText: Marco Martin <notmart@gmail.com>
# SPDX-FileCopyrightText: Nate Graham <nate@kde.org>
# SPDX-FileCopyrightText: Noah Davis <noahadvs@gmail.com>
# SPDX-FileCopyrightText: Neal Gompa <ngompa@kde.org>
# SPDX-FileCopyrightText: David Redondo <kde@david-redondo.de>
# SPDX-FileCopyrightText: Thomas Duckworth <tduck973564@gmail.com>
# SPDX-License-Identifier: LGPL-2.0-or-later
[ColorEffects:Disabled]
Color=56,56,56
ColorAmount=0
ColorEffect=0
ContrastAmount=0.65
ContrastEffect=1
IntensityAmount=0.1
IntensityEffect=2
[ColorEffects:Inactive]
ChangeSelectionColor=true
Color=112,111,110
ColorAmount=0.025
ColorEffect=2
ContrastAmount=0.1
ContrastEffect=2
Enable=false
IntensityAmount=0
IntensityEffect=0
[Colors:Button]
BackgroundAlternate=16,82,74
BackgroundNormal=41,44,48
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=46,184,168
ForegroundInactive=161,169,177
ForegroundLink=38,196,180
ForegroundNegative=218,68,83
ForegroundNeutral=246,116,0
ForegroundNormal=252,252,252
ForegroundPositive=39,174,96
ForegroundVisited=155,89,182
[Colors:Complementary]
BackgroundAlternate=16,82,74
BackgroundNormal=32,35,38
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=46,184,168
ForegroundInactive=161,169,177
ForegroundLink=38,196,180
ForegroundNegative=218,68,83
ForegroundNeutral=246,116,0
ForegroundNormal=252,252,252
ForegroundPositive=39,174,96
ForegroundVisited=155,89,182
[Colors:Header]
BackgroundAlternate=32,35,38
BackgroundNormal=41,44,48
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=46,184,168
ForegroundInactive=161,169,177
ForegroundLink=38,196,180
ForegroundNegative=218,68,83
ForegroundNeutral=246,116,0
ForegroundNormal=252,252,252
ForegroundPositive=39,174,96
ForegroundVisited=155,89,182
[Colors:Header][Inactive]
BackgroundAlternate=41,44,48
BackgroundNormal=32,35,38
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=46,184,168
ForegroundInactive=161,169,177
ForegroundLink=38,196,180
ForegroundNegative=218,68,83
ForegroundNeutral=246,116,0
ForegroundNormal=252,252,252
ForegroundPositive=39,174,96
ForegroundVisited=155,89,182
[Colors:Selection]
BackgroundAlternate=16,82,74
BackgroundNormal=46,184,168
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=252,252,252
ForegroundInactive=161,169,177
ForegroundLink=253,188,75
ForegroundNegative=176,55,69
ForegroundNeutral=198,92,0
ForegroundNormal=252,252,252
ForegroundPositive=23,104,57
ForegroundVisited=155,89,182
[Colors:Tooltip]
BackgroundAlternate=32,35,38
BackgroundNormal=41,44,48
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=46,184,168
ForegroundInactive=161,169,177
ForegroundLink=38,196,180
ForegroundNegative=218,68,83
ForegroundNeutral=246,116,0
ForegroundNormal=252,252,252
ForegroundPositive=39,174,96
ForegroundVisited=155,89,182
[Colors:View]
BackgroundAlternate=29,31,34
BackgroundNormal=20,22,24
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=46,184,168
ForegroundInactive=161,169,177
ForegroundLink=38,196,180
ForegroundNegative=218,68,83
ForegroundNeutral=246,116,0
ForegroundNormal=252,252,252
ForegroundPositive=39,174,96
ForegroundVisited=155,89,182
[Colors:Window]
BackgroundAlternate=41,44,48
BackgroundNormal=32,35,38
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=46,184,168
ForegroundInactive=161,169,177
ForegroundLink=38,196,180
ForegroundNegative=218,68,83
ForegroundNeutral=246,116,0
ForegroundNormal=252,252,252
ForegroundPositive=39,174,96
ForegroundVisited=155,89,182
[General]
ColorScheme=ShiftDark
Name=Shift Dark
shadeSortColumn=true
[KDE]
contrast=4
[WM]
activeBackground=39,44,49
activeBlend=252,252,252
activeForeground=252,252,252
inactiveBackground=32,36,40
inactiveBlend=161,169,177
inactiveForeground=161,169,177

View file

@ -0,0 +1,155 @@
# SPDX-FileCopyrightText: Andrew Lake <jamboarder@gmail.com>
# SPDX-FileCopyrightText: Marco Martin <notmart@gmail.com>
# SPDX-FileCopyrightText: Nate Graham <nate@kde.org>
# SPDX-FileCopyrightText: Noah Davis <noahadvs@gmail.com>
# SPDX-FileCopyrightText: Neal Gompa <ngompa@kde.org>
# SPDX-FileCopyrightText: David Redondo <kde@david-redondo.de>
# SPDX-License-Identifier: LGPL-2.0-or-later
[ColorEffects:Disabled]
Color=56,56,56
ColorAmount=0
ColorEffect=0
ContrastAmount=0.65
ContrastEffect=1
IntensityAmount=0.1
IntensityEffect=2
[ColorEffects:Inactive]
ChangeSelectionColor=true
Color=112,111,110
ColorAmount=0.025
ColorEffect=2
ContrastAmount=0.1
ContrastEffect=2
Enable=false
IntensityAmount=0
IntensityEffect=0
[Colors:Button]
BackgroundAlternate=163,218,212
BackgroundNormal=252,252,252
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=46,184,168
ForegroundInactive=112,125,138
ForegroundLink=15,105,96
ForegroundNegative=218,68,83
ForegroundNeutral=246,116,0
ForegroundNormal=35,38,41
ForegroundPositive=39,174,96
ForegroundVisited=155,89,182
[Colors:Complementary]
BackgroundAlternate=27,30,32
BackgroundNormal=42,46,50
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=46,184,168
ForegroundInactive=161,169,177
ForegroundLink=38,196,180
ForegroundNegative=218,68,83
ForegroundNeutral=246,116,0
ForegroundNormal=252,252,252
ForegroundPositive=39,174,96
ForegroundVisited=155,89,182
[Colors:Header]
BackgroundAlternate=239,240,241
BackgroundNormal=222,224,226
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=46,184,168
ForegroundInactive=112,125,138
ForegroundLink=15,105,96
ForegroundNegative=218,68,83
ForegroundNeutral=246,116,0
ForegroundNormal=35,38,41
ForegroundPositive=39,174,96
ForegroundVisited=155,89,182
[Colors:Header][Inactive]
BackgroundAlternate=227,229,231
BackgroundNormal=239,240,241
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=46,184,168
ForegroundInactive=112,125,138
ForegroundLink=15,105,96
ForegroundNegative=218,68,83
ForegroundNeutral=246,116,0
ForegroundNormal=35,38,41
ForegroundPositive=39,174,96
ForegroundVisited=155,89,182
[Colors:Selection]
BackgroundAlternate=163,218,212
BackgroundNormal=46,184,168
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=255,255,255
ForegroundInactive=112,125,138
ForegroundLink=253,188,75
ForegroundNegative=176,55,69
ForegroundNeutral=198,92,0
ForegroundNormal=255,255,255
ForegroundPositive=23,104,57
ForegroundVisited=155,89,182
[Colors:Tooltip]
BackgroundAlternate=239,240,241
BackgroundNormal=247,247,247
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=46,184,168
ForegroundInactive=112,125,138
ForegroundLink=15,105,96
ForegroundNegative=218,68,83
ForegroundNeutral=246,116,0
ForegroundNormal=35,38,41
ForegroundPositive=39,174,96
ForegroundVisited=155,89,182
[Colors:View]
BackgroundAlternate=247,247,247
BackgroundNormal=255,255,255
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=46,184,168
ForegroundInactive=112,125,138
ForegroundLink=15,105,96
ForegroundNegative=218,68,83
ForegroundNeutral=246,116,0
ForegroundNormal=35,38,41
ForegroundPositive=39,174,96
ForegroundVisited=155,89,182
[Colors:Window]
BackgroundAlternate=227,229,231
BackgroundNormal=239,240,241
DecorationFocus=46,184,168
DecorationHover=46,184,168
ForegroundActive=46,184,168
ForegroundInactive=112,125,138
ForegroundLink=15,105,96
ForegroundNegative=218,68,83
ForegroundNeutral=246,116,0
ForegroundNormal=35,38,41
ForegroundPositive=39,174,96
ForegroundVisited=155,89,182
[General]
ColorScheme=ShiftLight
Name=Shift Light
shadeSortColumn=true
[KDE]
contrast=4
[WM]
activeBackground=227,229,231
activeBlend=227,229,231
activeForeground=35,38,41
inactiveBackground=239,240,241
inactiveBlend=239,240,241
inactiveForeground=112,125,138

View file

@ -14,3 +14,4 @@ add_subdirectory(wallpaperimageplugin)
add_subdirectory(dpmsplugin) add_subdirectory(dpmsplugin)
add_subdirectory(screenbrightnessplugin) add_subdirectory(screenbrightnessplugin)
add_subdirectory(raiselockscreenplugin) add_subdirectory(raiselockscreenplugin)
add_subdirectory(gamingshellplugin)

View file

@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: 2026 Marco Allegretti
# SPDX-License-Identifier: EUPL-1.2
find_package(SDL3 REQUIRED)
find_package(Qt6 REQUIRED COMPONENTS Sql DBus)
ecm_add_qml_module(gamingshellplugin URI org.kde.plasma.private.mobileshell.gamingshellplugin GENERATE_PLUGIN_SOURCE)
target_sources(gamingshellplugin PRIVATE
gamepadmanager.cpp
gamepaddevice.cpp
gamelauncherprovider.cpp
powerprofilecontrol.cpp
gamemodecontrol.cpp
)
target_link_libraries(gamingshellplugin PRIVATE
Qt::Core
Qt::DBus
Qt::Qml
Qt::Quick
Qt::Sql
KF6::I18n
KF6::KIOGui
KF6::Service
KF6::CoreAddons
KF6::ConfigCore
SDL3::SDL3
)
ecm_finalize_qml_module(gamingshellplugin)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#pragma once
#include <QAbstractListModel>
#include <QDateTime>
#include <QList>
#include <QSet>
#include <QString>
#include <QTimer>
#include <QVariantMap>
#include <qqmlregistration.h>
#include <KConfigWatcher>
#include <KSharedConfig>
class GameLauncherProvider : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(int count READ count NOTIFY countChanged)
Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged)
Q_PROPERTY(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged)
Q_PROPERTY(QString sourceFilter READ sourceFilter WRITE setSourceFilter NOTIFY sourceFilterChanged)
Q_PROPERTY(bool overlayEnabled READ overlayEnabled WRITE setOverlayEnabled NOTIFY overlayEnabledChanged)
Q_PROPERTY(bool mangohudAvailable READ mangohudAvailable NOTIFY mangohudAvailableChanged)
Q_PROPERTY(int fpsLimit READ fpsLimit WRITE setFpsLimit NOTIFY fpsLimitChanged)
Q_PROPERTY(bool launchPending READ launchPending NOTIFY launchPendingChanged)
Q_PROPERTY(QString pendingLaunchName READ pendingLaunchName NOTIFY launchPendingChanged)
Q_PROPERTY(QString lastLaunchError READ lastLaunchError NOTIFY lastLaunchErrorChanged)
public:
explicit GameLauncherProvider(QObject *parent = nullptr);
enum Roles {
NameRole = Qt::UserRole + 1,
IconRole,
SourceRole, // "desktop", "waydroid", "steam", "flatpak"
StorageIdRole, // .desktop file name or launch URI
LaunchCommandRole,
LaunchMethodRole,
ArtworkRole, // path to banner/grid image (empty if none)
LastPlayedTextRole,
PinnedRole,
};
Q_ENUM(Roles)
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
int count() const;
bool loading() const;
QString filterString() const;
void setFilterString(const QString &filter);
QString sourceFilter() const;
void setSourceFilter(const QString &source);
bool overlayEnabled() const;
void setOverlayEnabled(bool enabled);
bool mangohudAvailable() const;
int fpsLimit() const;
void setFpsLimit(int limit);
bool launchPending() const;
QString pendingLaunchName() const;
QString lastLaunchError() const;
Q_INVOKABLE void refresh();
Q_INVOKABLE void launch(int index);
Q_INVOKABLE void launchByStorageId(const QString &storageId);
Q_INVOKABLE QVariantMap gameDetails(const QString &storageId) const;
Q_INVOKABLE bool openSourceApp(const QString &source);
Q_INVOKABLE void clearLastPlayed(const QString &storageId);
Q_INVOKABLE QVariantList recentGames(int limit = 5) const;
Q_INVOKABLE void clearPendingLaunch();
Q_INVOKABLE void clearLastLaunchError();
Q_INVOKABLE void togglePin(const QString &storageId);
Q_INVOKABLE int perGameFpsLimit(const QString &storageId) const;
Q_INVOKABLE void setPerGameFpsLimit(const QString &storageId, int limit);
Q_INVOKABLE int perGameOverlayState(const QString &storageId) const;
Q_INVOKABLE void setPerGameOverlayState(const QString &storageId, int state);
Q_SIGNALS:
void countChanged();
void loadingChanged();
void filterStringChanged();
void sourceFilterChanged();
void overlayEnabledChanged();
void mangohudAvailableChanged();
void fpsLimitChanged();
void launchPendingChanged();
void lastLaunchErrorChanged();
void gameLaunched(const QString &name);
void gameLaunchFailed(const QString &name, const QString &error);
void recentGamesChanged();
private:
struct GameEntry {
QString name;
QString icon;
QString source;
QString storageId;
QString launchCommand;
QString artwork;
QDateTime lastPlayed;
};
void loadDesktopGames();
void loadSteamGames();
void loadLutrisGames();
void loadHeroicGames();
void deduplicateGames();
void loadRecentTimestamps();
void saveRecentTimestamp(const QString &storageId, const QDateTime &when);
void applyFilter();
void launchEntry(GameEntry &entry);
bool launchWithMangohud(const QString &program, const QStringList &args, bool overlayEnabled, int fpsLimit, qint64 *pid = nullptr);
QString launchMethodForEntry(const GameEntry &entry) const;
QString formatLastPlayed(const QDateTime &when) const;
// Returns the current m_allGames index for the storage id.
// Callers must re-lookup after any mutation that can rebuild or reorder the list.
int findEntryIndexByStorageId(const QString &storageId) const;
void markLaunchSucceeded(const QString &storageId, const QString &name);
void markLaunchFailed(const QString &name, const QString &error);
void setPendingLaunch(const QString &name);
void loadPinnedGames();
QList<GameEntry> m_allGames;
QList<GameEntry> m_games; // filtered view
QString m_filterString;
QString m_sourceFilter; // empty = all, or a specific source such as "desktop" or "waydroid"
KSharedConfigPtr m_config;
bool m_loading = false;
bool m_overlayEnabled = false;
int m_fpsLimit = 0;
bool m_mangohudAvailable = false;
QString m_mangohudPath;
QSet<QString> m_pinnedGames;
bool m_launchPending = false;
QString m_pendingLaunchName;
QString m_lastLaunchError;
QTimer m_pendingLaunchTimer;
KConfigWatcher::Ptr m_configWatcher;
};

View file

@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#include "gamemodecontrol.h"
#include <QDBusConnection>
#include <QDBusReply>
#include <QDebug>
#include <unistd.h> // getpid()
static const QString s_service = QStringLiteral("com.feralinteractive.GameMode");
static const QString s_path = QStringLiteral("/com/feralinteractive/GameMode");
static const QString s_iface = QStringLiteral("com.feralinteractive.GameMode");
GameModeControl::GameModeControl(QObject *parent)
: QObject(parent)
, m_iface(new QDBusInterface(s_service, s_path, s_iface, QDBusConnection::sessionBus(), this))
{
m_available = m_iface->isValid();
if (!m_available) {
qDebug() << "GameModeControl: Feral GameMode not available";
}
}
bool GameModeControl::available() const
{
return m_available;
}
bool GameModeControl::active() const
{
return m_active;
}
void GameModeControl::requestStart()
{
if (!m_available || m_active) {
return;
}
QDBusReply<int> reply = m_iface->call(QStringLiteral("RegisterGame"), static_cast<int>(getpid()));
if (reply.isValid() && reply.value() == 0) {
m_active = true;
Q_EMIT activeChanged();
} else {
qWarning() << "GameModeControl: RegisterGame failed:" << reply.error().message();
}
}
void GameModeControl::requestEnd()
{
if (!m_available || !m_active) {
return;
}
QDBusReply<int> reply = m_iface->call(QStringLiteral("UnregisterGame"), static_cast<int>(getpid()));
if (reply.isValid() && reply.value() == 0) {
m_active = false;
Q_EMIT activeChanged();
} else {
qWarning() << "GameModeControl: UnregisterGame failed:" << reply.error().message();
}
}

View file

@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#pragma once
#include <QDBusInterface>
#include <QObject>
#include <qqmlregistration.h>
/**
* D-Bus client for Feral GameMode (com.feralinteractive.GameMode).
*
* Calling requestStart() tells the daemon to apply performance
* optimizations (CPU governor, I/O priority, GPU perf mode, etc.)
* for the calling process. requestEnd() reverses them.
*
* GameMode is optional if the daemon is not installed the calls
* are silently ignored.
*/
class GameModeControl : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(bool available READ available NOTIFY availableChanged)
Q_PROPERTY(bool active READ active NOTIFY activeChanged)
public:
explicit GameModeControl(QObject *parent = nullptr);
bool available() const;
bool active() const;
Q_INVOKABLE void requestStart();
Q_INVOKABLE void requestEnd();
Q_SIGNALS:
void availableChanged();
void activeChanged();
private:
QDBusInterface *m_iface = nullptr;
bool m_available = false;
bool m_active = false;
};

View file

@ -0,0 +1,240 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#include "gamepaddevice.h"
#include <SDL3/SDL_gamepad.h>
#include <SDL3/SDL_power.h>
#include <SDL3/SDL_properties.h>
#include <SDL3/SDL_sensor.h>
#include <algorithm>
static QString gamepadButtonLabelToString(SDL_GamepadButtonLabel label)
{
switch (label) {
case SDL_GAMEPAD_BUTTON_LABEL_A:
return QStringLiteral("A");
case SDL_GAMEPAD_BUTTON_LABEL_B:
return QStringLiteral("B");
case SDL_GAMEPAD_BUTTON_LABEL_X:
return QStringLiteral("X");
case SDL_GAMEPAD_BUTTON_LABEL_Y:
return QStringLiteral("Y");
case SDL_GAMEPAD_BUTTON_LABEL_CROSS:
return QStringLiteral("Cross");
case SDL_GAMEPAD_BUTTON_LABEL_CIRCLE:
return QStringLiteral("Circle");
case SDL_GAMEPAD_BUTTON_LABEL_SQUARE:
return QStringLiteral("Square");
case SDL_GAMEPAD_BUTTON_LABEL_TRIANGLE:
return QStringLiteral("Triangle");
default:
return QStringLiteral("?");
}
}
GamepadDevice::GamepadDevice(SDL_Gamepad *pad, int id, QObject *parent)
: QObject(parent)
, m_pad(pad)
, m_id(id)
{
refreshBattery();
}
GamepadDevice::~GamepadDevice()
{
if (m_pad) {
SDL_CloseGamepad(m_pad);
m_pad = nullptr;
}
}
int GamepadDevice::deviceId() const
{
return m_id;
}
QString GamepadDevice::name() const
{
if (!m_pad) {
return {};
}
const char *n = SDL_GetGamepadName(m_pad);
return n ? QString::fromUtf8(n) : QString();
}
QString GamepadDevice::type() const
{
if (!m_pad) {
return QStringLiteral("unknown");
}
switch (SDL_GetGamepadType(m_pad)) {
case SDL_GAMEPAD_TYPE_XBOX360:
case SDL_GAMEPAD_TYPE_XBOXONE:
return QStringLiteral("xbox");
case SDL_GAMEPAD_TYPE_PS3:
case SDL_GAMEPAD_TYPE_PS4:
case SDL_GAMEPAD_TYPE_PS5:
return QStringLiteral("playstation");
case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO:
case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT:
case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT:
case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR:
return QStringLiteral("nintendo");
default:
return QStringLiteral("generic");
}
}
int GamepadDevice::batteryPercent() const
{
return m_batteryPercent;
}
bool GamepadDevice::hasRumble() const
{
if (!m_pad) {
return false;
}
SDL_PropertiesID props = SDL_GetGamepadProperties(m_pad);
return SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, false);
}
bool GamepadDevice::hasTriggerRumble() const
{
if (!m_pad) {
return false;
}
SDL_PropertiesID props = SDL_GetGamepadProperties(m_pad);
return SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN, false);
}
bool GamepadDevice::hasLED() const
{
if (!m_pad) {
return false;
}
SDL_PropertiesID props = SDL_GetGamepadProperties(m_pad);
return SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_RGB_LED_BOOLEAN, false);
}
int GamepadDevice::touchpadCount() const
{
if (!m_pad) {
return 0;
}
return SDL_GetNumGamepadTouchpads(m_pad);
}
bool GamepadDevice::hasGyro() const
{
return m_pad && SDL_GamepadHasSensor(m_pad, SDL_SENSOR_GYRO);
}
bool GamepadDevice::hasAccelerometer() const
{
return m_pad && SDL_GamepadHasSensor(m_pad, SDL_SENSOR_ACCEL);
}
int GamepadDevice::playerIndex() const
{
if (!m_pad) {
return -1;
}
return SDL_GetGamepadPlayerIndex(m_pad);
}
void GamepadDevice::setPlayerIndex(int index)
{
if (!m_pad) {
return;
}
if (SDL_SetGamepadPlayerIndex(m_pad, index)) {
Q_EMIT playerIndexChanged();
}
}
bool GamepadDevice::rumble(int lowIntensity, int highIntensity, int durationMs)
{
if (!m_pad) {
return false;
}
auto lo = static_cast<uint16_t>(std::clamp(lowIntensity, 0, 65535));
auto hi = static_cast<uint16_t>(std::clamp(highIntensity, 0, 65535));
auto dur = static_cast<uint32_t>(std::clamp(durationMs, 0, durationMs));
return SDL_RumbleGamepad(m_pad, lo, hi, dur);
}
bool GamepadDevice::rumbleTriggers(int leftIntensity, int rightIntensity, int durationMs)
{
if (!m_pad) {
return false;
}
auto left = static_cast<uint16_t>(std::clamp(leftIntensity, 0, 65535));
auto right = static_cast<uint16_t>(std::clamp(rightIntensity, 0, 65535));
auto dur = static_cast<uint32_t>(std::clamp(durationMs, 0, durationMs));
return SDL_RumbleGamepadTriggers(m_pad, left, right, dur);
}
bool GamepadDevice::setLED(int r, int g, int b)
{
if (!m_pad) {
return false;
}
auto cr = static_cast<uint8_t>(std::clamp(r, 0, 255));
auto cg = static_cast<uint8_t>(std::clamp(g, 0, 255));
auto cb = static_cast<uint8_t>(std::clamp(b, 0, 255));
return SDL_SetGamepadLED(m_pad, cr, cg, cb);
}
QString GamepadDevice::buttonLabel(int button) const
{
if (!m_pad) {
return QStringLiteral("?");
}
switch (button) {
case SDL_GAMEPAD_BUTTON_SOUTH:
case SDL_GAMEPAD_BUTTON_EAST:
case SDL_GAMEPAD_BUTTON_WEST:
case SDL_GAMEPAD_BUTTON_NORTH:
return gamepadButtonLabelToString(SDL_GetGamepadButtonLabel(m_pad, static_cast<SDL_GamepadButton>(button)));
case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER:
return type() == QLatin1String("playstation") ? QStringLiteral("L1") : type() == QLatin1String("nintendo") ? QStringLiteral("L") : QStringLiteral("LB");
case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER:
return type() == QLatin1String("playstation") ? QStringLiteral("R1") : type() == QLatin1String("nintendo") ? QStringLiteral("R") : QStringLiteral("RB");
case SDL_GAMEPAD_BUTTON_BACK:
return type() == QLatin1String("playstation") ? QStringLiteral("Create")
: type() == QLatin1String("nintendo") ? QStringLiteral("-")
: QStringLiteral("View");
case SDL_GAMEPAD_BUTTON_START:
return type() == QLatin1String("playstation") ? QStringLiteral("Options")
: type() == QLatin1String("nintendo") ? QStringLiteral("+")
: QStringLiteral("Menu");
case SDL_GAMEPAD_BUTTON_GUIDE:
return type() == QLatin1String("playstation") ? QStringLiteral("PS")
: type() == QLatin1String("nintendo") ? QStringLiteral("Home")
: QStringLiteral("Guide");
default:
return QStringLiteral("?");
}
}
SDL_Gamepad *GamepadDevice::sdlGamepad() const
{
return m_pad;
}
void GamepadDevice::refreshBattery()
{
if (!m_pad) {
return;
}
int pct = -1;
SDL_GetGamepadPowerInfo(m_pad, &pct);
if (pct != m_batteryPercent) {
m_batteryPercent = pct;
Q_EMIT batteryPercentChanged();
}
}

View file

@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#pragma once
#include <QObject>
#include <QString>
#include <qqmlregistration.h>
struct SDL_Gamepad;
class GamepadDevice : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
Q_PROPERTY(int deviceId READ deviceId CONSTANT)
Q_PROPERTY(QString name READ name CONSTANT)
Q_PROPERTY(QString type READ type CONSTANT)
Q_PROPERTY(int batteryPercent READ batteryPercent NOTIFY batteryPercentChanged)
Q_PROPERTY(bool hasRumble READ hasRumble CONSTANT)
Q_PROPERTY(bool hasTriggerRumble READ hasTriggerRumble CONSTANT)
Q_PROPERTY(bool hasLED READ hasLED CONSTANT)
Q_PROPERTY(int touchpadCount READ touchpadCount CONSTANT)
Q_PROPERTY(bool hasGyro READ hasGyro CONSTANT)
Q_PROPERTY(bool hasAccelerometer READ hasAccelerometer CONSTANT)
Q_PROPERTY(int playerIndex READ playerIndex WRITE setPlayerIndex NOTIFY playerIndexChanged)
public:
explicit GamepadDevice(SDL_Gamepad *pad, int id, QObject *parent = nullptr);
~GamepadDevice() override;
int deviceId() const;
QString name() const;
QString type() const;
int batteryPercent() const;
bool hasRumble() const;
bool hasTriggerRumble() const;
bool hasLED() const;
int touchpadCount() const;
bool hasGyro() const;
bool hasAccelerometer() const;
int playerIndex() const;
void setPlayerIndex(int index);
Q_INVOKABLE bool rumble(int lowIntensity, int highIntensity, int durationMs);
Q_INVOKABLE bool rumbleTriggers(int leftIntensity, int rightIntensity, int durationMs);
Q_INVOKABLE bool setLED(int r, int g, int b);
Q_INVOKABLE QString buttonLabel(int button) const;
SDL_Gamepad *sdlGamepad() const;
void refreshBattery();
Q_SIGNALS:
void batteryPercentChanged();
void playerIndexChanged();
private:
SDL_Gamepad *m_pad = nullptr;
int m_id = 0;
int m_batteryPercent = -1;
};

View file

@ -0,0 +1,314 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#include "gamepadmanager.h"
#include "gamepaddevice.h"
#include <SDL3/SDL.h>
#include <SDL3/SDL_gamepad.h>
#include <SDL3/SDL_hints.h>
#include <QDebug>
#include <QQmlEngine>
#include <algorithm>
GamepadManager::GamepadManager(QObject *parent)
: QAbstractListModel(parent)
{
m_pollTimer.setInterval(16); // ~60 Hz
connect(&m_pollTimer, &QTimer::timeout, this, &GamepadManager::poll);
}
GamepadManager::~GamepadManager()
{
stop();
}
GamepadManager *GamepadManager::create(QQmlEngine *qmlEngine, QJSEngine *jsEngine)
{
Q_UNUSED(jsEngine)
static GamepadManager *s_instance = nullptr;
if (!s_instance) {
s_instance = new GamepadManager(qmlEngine);
}
return s_instance;
}
// --- QAbstractListModel ---
int GamepadManager::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : m_gamepads.size();
}
QVariant GamepadManager::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_gamepads.size()) {
return {};
}
auto *dev = m_gamepads.at(index.row());
switch (role) {
case DeviceRole:
return QVariant::fromValue(dev);
case NameRole:
return dev->name();
case TypeRole:
return dev->type();
case BatteryRole:
return dev->batteryPercent();
}
return {};
}
QHash<int, QByteArray> GamepadManager::roleNames() const
{
return {
{DeviceRole, "device"},
{NameRole, "name"},
{TypeRole, "type"},
{BatteryRole, "battery"},
};
}
// --- Properties ---
bool GamepadManager::active() const
{
return m_active;
}
void GamepadManager::setActive(bool active)
{
if (m_active == active) {
return;
}
m_active = active;
if (active) {
start();
} else {
stop();
}
Q_EMIT activeChanged();
}
int GamepadManager::count() const
{
return m_gamepads.size();
}
bool GamepadManager::hasGamepad() const
{
return !m_gamepads.isEmpty();
}
GamepadDevice *GamepadManager::primaryGamepad() const
{
return m_gamepads.isEmpty() ? nullptr : m_gamepads.first();
}
GamepadDevice *GamepadManager::gamepadAt(int index) const
{
if (index < 0 || index >= m_gamepads.size()) {
return nullptr;
}
return m_gamepads.at(index);
}
QString GamepadManager::buttonLabel(int button, int gamepadIndex) const
{
GamepadDevice *device = nullptr;
if (gamepadIndex >= 0 && gamepadIndex < m_gamepads.size()) {
device = m_gamepads.at(gamepadIndex);
} else {
device = primaryGamepad();
}
if (!device) {
switch (button) {
case SDL_GAMEPAD_BUTTON_SOUTH:
return QStringLiteral("A");
case SDL_GAMEPAD_BUTTON_EAST:
return QStringLiteral("B");
case SDL_GAMEPAD_BUTTON_WEST:
return QStringLiteral("X");
case SDL_GAMEPAD_BUTTON_NORTH:
return QStringLiteral("Y");
case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER:
return QStringLiteral("LB");
case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER:
return QStringLiteral("RB");
case SDL_GAMEPAD_BUTTON_BACK:
return QStringLiteral("Back");
case SDL_GAMEPAD_BUTTON_START:
return QStringLiteral("Start");
case SDL_GAMEPAD_BUTTON_GUIDE:
return QStringLiteral("Guide");
default:
return QStringLiteral("?");
}
}
return device->buttonLabel(button);
}
// --- Lifecycle ---
void GamepadManager::start()
{
if (m_sdlInitialized) {
return;
}
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
if (!SDL_Init(SDL_INIT_GAMEPAD)) {
qWarning() << "GamepadManager: SDL_Init failed:" << SDL_GetError();
return;
}
m_sdlInitialized = true;
SDL_SetGamepadEventsEnabled(true);
// Enumerate already-connected gamepads
int count = 0;
SDL_JoystickID *ids = SDL_GetGamepads(&count);
if (ids) {
for (int i = 0; i < count; ++i) {
addGamepad(ids[i]);
}
SDL_free(ids);
}
m_pollTimer.start();
}
void GamepadManager::stop()
{
m_pollTimer.stop();
if (!m_gamepads.isEmpty()) {
beginResetModel();
qDeleteAll(m_gamepads);
m_gamepads.clear();
endResetModel();
Q_EMIT countChanged();
Q_EMIT primaryGamepadChanged();
}
if (m_sdlInitialized) {
SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
m_sdlInitialized = false;
}
}
// --- Event polling ---
void GamepadManager::poll()
{
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_EVENT_GAMEPAD_ADDED:
addGamepad(event.gdevice.which);
break;
case SDL_EVENT_GAMEPAD_REMOVED:
removeGamepad(event.gdevice.which);
break;
case SDL_EVENT_GAMEPAD_BUTTON_DOWN: {
int idx = indexForInstanceId(event.gbutton.which);
if (idx >= 0) {
Q_EMIT buttonPressed(event.gbutton.button, idx);
}
break;
}
case SDL_EVENT_GAMEPAD_BUTTON_UP: {
int idx = indexForInstanceId(event.gbutton.which);
if (idx >= 0) {
Q_EMIT buttonReleased(event.gbutton.button, idx);
}
break;
}
case SDL_EVENT_GAMEPAD_AXIS_MOTION: {
int idx = indexForInstanceId(event.gaxis.which);
if (idx >= 0) {
float normalized = std::clamp(static_cast<float>(event.gaxis.value) / 32767.0f, -1.0f, 1.0f);
Q_EMIT axisChanged(event.gaxis.axis, normalized, idx);
}
break;
}
default:
break;
}
}
// Refresh battery state periodically (every ~5 seconds = 300 frames)
if (++m_batteryCounter >= 300) {
m_batteryCounter = 0;
for (auto *dev : std::as_const(m_gamepads)) {
dev->refreshBattery();
}
}
}
// --- Hotplug ---
void GamepadManager::addGamepad(int instanceId)
{
// Already tracked?
if (indexForInstanceId(instanceId) >= 0) {
return;
}
SDL_Gamepad *pad = SDL_OpenGamepad(instanceId);
if (!pad) {
qWarning() << "GamepadManager: failed to open gamepad" << instanceId << SDL_GetError();
return;
}
auto *device = new GamepadDevice(pad, instanceId, this);
int row = m_gamepads.size();
beginInsertRows(QModelIndex(), row, row);
m_gamepads.append(device);
endInsertRows();
Q_EMIT countChanged();
if (m_gamepads.size() == 1) {
Q_EMIT primaryGamepadChanged();
}
qDebug() << "GamepadManager: connected" << device->name() << "(" << device->type() << ")";
}
void GamepadManager::removeGamepad(int instanceId)
{
int idx = indexForInstanceId(instanceId);
if (idx < 0) {
return;
}
beginRemoveRows(QModelIndex(), idx, idx);
auto *dev = m_gamepads.takeAt(idx);
endRemoveRows();
qDebug() << "GamepadManager: disconnected" << dev->name();
dev->deleteLater();
Q_EMIT countChanged();
if (idx == 0) {
Q_EMIT primaryGamepadChanged();
}
}
int GamepadManager::indexForInstanceId(int instanceId) const
{
for (int i = 0; i < m_gamepads.size(); ++i) {
if (m_gamepads.at(i)->deviceId() == instanceId) {
return i;
}
}
return -1;
}

View file

@ -0,0 +1,106 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#pragma once
#include <QAbstractListModel>
#include <QTimer>
#include <qqmlregistration.h>
class QQmlEngine;
class QJSEngine;
class GamepadDevice;
class GamepadManager : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(bool active READ active WRITE setActive NOTIFY activeChanged)
Q_PROPERTY(int count READ count NOTIFY countChanged)
Q_PROPERTY(GamepadDevice *primaryGamepad READ primaryGamepad NOTIFY primaryGamepadChanged)
Q_PROPERTY(bool hasGamepad READ hasGamepad NOTIFY countChanged)
public:
explicit GamepadManager(QObject *parent = nullptr);
~GamepadManager() override;
static GamepadManager *create(QQmlEngine *qmlEngine, QJSEngine *jsEngine);
enum Roles {
DeviceRole = Qt::UserRole + 1,
NameRole,
TypeRole,
BatteryRole,
};
Q_ENUM(Roles)
// Buttons matching SDL_GamepadButton, re-exported for QML
enum Button {
ButtonA,
ButtonB,
ButtonX,
ButtonY,
ButtonBack,
ButtonGuide,
ButtonStart,
ButtonLeftStick,
ButtonRightStick,
ButtonLeftShoulder,
ButtonRightShoulder,
ButtonDPadUp,
ButtonDPadDown,
ButtonDPadLeft,
ButtonDPadRight,
ButtonMisc1,
};
Q_ENUM(Button)
// Axes matching SDL_GamepadAxis
enum Axis {
AxisLeftX,
AxisLeftY,
AxisRightX,
AxisRightY,
AxisLeftTrigger,
AxisRightTrigger,
};
Q_ENUM(Axis)
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
bool active() const;
void setActive(bool active);
int count() const;
bool hasGamepad() const;
GamepadDevice *primaryGamepad() const;
GamepadDevice *gamepadAt(int index) const;
Q_INVOKABLE QString buttonLabel(int button, int gamepadIndex = -1) const;
Q_SIGNALS:
void activeChanged();
void countChanged();
void primaryGamepadChanged();
void buttonPressed(int button, int gamepadIndex);
void buttonReleased(int button, int gamepadIndex);
void axisChanged(int axis, float value, int gamepadIndex);
private:
void start();
void stop();
void poll();
void addGamepad(int instanceId);
void removeGamepad(int instanceId);
int indexForInstanceId(int instanceId) const;
bool m_active = false;
bool m_sdlInitialized = false;
int m_batteryCounter = 0;
QTimer m_pollTimer;
QList<GamepadDevice *> m_gamepads;
};

View file

@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#include "powerprofilecontrol.h"
#include <QDBusArgument>
#include <QDBusConnection>
#include <QDBusReply>
#include <QDBusVariant>
#include <QDebug>
static const QString s_service = QStringLiteral("net.hadess.PowerProfiles");
static const QString s_path = QStringLiteral("/net/hadess/PowerProfiles");
static const QString s_iface = QStringLiteral("net.hadess.PowerProfiles");
static const QString s_propIface = QStringLiteral("org.freedesktop.DBus.Properties");
PowerProfileControl::PowerProfileControl(QObject *parent)
: QObject(parent)
, m_iface(new QDBusInterface(s_service, s_path, s_iface, QDBusConnection::systemBus(), this))
{
if (m_iface->isValid()) {
m_available = true;
fetchState();
// Subscribe to property changes
QDBusConnection::systemBus()
.connect(s_service, s_path, s_propIface, QStringLiteral("PropertiesChanged"), this, SLOT(onPropertiesChanged(QString, QVariantMap, QStringList)));
} else {
qDebug() << "PowerProfileControl: power-profiles-daemon not available";
}
}
QString PowerProfileControl::activeProfile() const
{
return m_activeProfile;
}
void PowerProfileControl::setActiveProfile(const QString &profile)
{
if (!m_available || profile == m_activeProfile) {
return;
}
if (!m_profiles.contains(profile)) {
return;
}
// Write via org.freedesktop.DBus.Properties.Set
QDBusInterface propIface(s_service, s_path, s_propIface, QDBusConnection::systemBus());
propIface.call(QStringLiteral("Set"), s_iface, QStringLiteral("ActiveProfile"), QVariant::fromValue(QDBusVariant(profile)));
}
QStringList PowerProfileControl::profiles() const
{
return m_profiles;
}
bool PowerProfileControl::available() const
{
return m_available;
}
void PowerProfileControl::fetchState()
{
// Read ActiveProfile
QDBusInterface propIface(s_service, s_path, s_propIface, QDBusConnection::systemBus());
QDBusReply<QDBusVariant> profileReply = propIface.call(QStringLiteral("Get"), s_iface, QStringLiteral("ActiveProfile"));
if (profileReply.isValid()) {
const QString profile = profileReply.value().variant().toString();
if (profile != m_activeProfile) {
m_activeProfile = profile;
Q_EMIT activeProfileChanged();
}
}
// Read Profiles — array of dicts, each with a "Profile" key
QDBusReply<QDBusVariant> profilesReply = propIface.call(QStringLiteral("Get"), s_iface, QStringLiteral("Profiles"));
if (profilesReply.isValid()) {
QStringList profiles;
const QVariant profilesVariant = profilesReply.value().variant();
if (profilesVariant.canConvert<QVariantList>()) {
const QVariantList list = profilesVariant.toList();
for (const QVariant &item : list) {
const QVariantMap map = item.toMap();
QString profileName;
if (map.contains(QStringLiteral("Profile"))) {
QVariant value = map.value(QStringLiteral("Profile"));
if (value.canConvert<QDBusVariant>()) {
value = value.value<QDBusVariant>().variant();
}
profileName = value.toString();
}
if (!profileName.isEmpty()) {
profiles.append(profileName);
}
}
}
if (profiles != m_profiles) {
m_profiles = profiles;
Q_EMIT profilesChanged();
}
}
}
void PowerProfileControl::onPropertiesChanged(const QString &interface, const QVariantMap &changed, const QStringList &invalidated)
{
Q_UNUSED(invalidated)
if (interface != s_iface) {
return;
}
if (changed.contains(QStringLiteral("ActiveProfile"))) {
QVariant value = changed.value(QStringLiteral("ActiveProfile"));
if (value.canConvert<QDBusVariant>()) {
value = value.value<QDBusVariant>().variant();
}
const QString profile = value.toString();
if (profile != m_activeProfile) {
m_activeProfile = profile;
Q_EMIT activeProfileChanged();
}
}
if (changed.contains(QStringLiteral("Profiles"))) {
fetchState();
}
}

View file

@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#pragma once
#include <QDBusInterface>
#include <QObject>
#include <QStringList>
#include <qqmlregistration.h>
class PowerProfileControl : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(QString activeProfile READ activeProfile WRITE setActiveProfile NOTIFY activeProfileChanged)
Q_PROPERTY(QStringList profiles READ profiles NOTIFY profilesChanged)
Q_PROPERTY(bool available READ available NOTIFY availableChanged)
public:
explicit PowerProfileControl(QObject *parent = nullptr);
QString activeProfile() const;
void setActiveProfile(const QString &profile);
QStringList profiles() const;
bool available() const;
Q_SIGNALS:
void activeProfileChanged();
void profilesChanged();
void availableChanged();
private Q_SLOTS:
void onPropertiesChanged(const QString &interface, const QVariantMap &changed, const QStringList &invalidated);
private:
void fetchState();
QDBusInterface *m_iface = nullptr;
QString m_activeProfile;
QStringList m_profiles;
bool m_available = false;
};

View file

@ -86,13 +86,15 @@ QQuickItem *AppletHost::fullRepresentationFor(const QString &pluginId)
m_containment->addApplet(applet); m_containment->addApplet(applet);
auto *item = PlasmaQuick::AppletQuickItem::itemForApplet(applet); auto *item = PlasmaQuick::AppletQuickItem::itemForApplet(applet);
m_items.insert(pluginId, item);
if (!item) { if (!item) {
qWarning() << "AppletHost: no AppletQuickItem for" << pluginId; qWarning() << "AppletHost: no AppletQuickItem for" << pluginId;
applet->deleteLater();
return nullptr; return nullptr;
} }
m_items.insert(pluginId, item);
item->setPreloadFullRepresentation(true); item->setPreloadFullRepresentation(true);
auto *fullRepItem = item->fullRepresentationItem(); auto *fullRepItem = item->fullRepresentationItem();

View file

@ -165,6 +165,7 @@ Item {
shrinkSize: 0 shrinkSize: 0
onClicked: { onClicked: {
if (!root.taskModel) return;
root.taskModel.requestActivate(root.taskModel.makeModelIndex(taskDelegate.index)); root.taskModel.requestActivate(root.taskModel.makeModelIndex(taskDelegate.index));
} }
@ -180,18 +181,27 @@ Item {
Controls.MenuItem { Controls.MenuItem {
text: taskDelegate.model.IsMinimized ? i18n("Restore") : i18n("Minimize") text: taskDelegate.model.IsMinimized ? i18n("Restore") : i18n("Minimize")
icon.name: taskDelegate.model.IsMinimized ? "window-restore" : "window-minimize" icon.name: taskDelegate.model.IsMinimized ? "window-restore" : "window-minimize"
onTriggered: root.taskModel.requestToggleMinimized(root.taskModel.makeModelIndex(taskDelegate.index)) onTriggered: {
if (!root.taskModel) return;
root.taskModel.requestToggleMinimized(root.taskModel.makeModelIndex(taskDelegate.index))
}
} }
Controls.MenuItem { Controls.MenuItem {
text: taskDelegate.model.IsMaximized ? i18n("Restore") : i18n("Maximize") text: taskDelegate.model.IsMaximized ? i18n("Restore") : i18n("Maximize")
icon.name: taskDelegate.model.IsMaximized ? "window-restore" : "window-maximize" icon.name: taskDelegate.model.IsMaximized ? "window-restore" : "window-maximize"
onTriggered: root.taskModel.requestToggleMaximized(root.taskModel.makeModelIndex(taskDelegate.index)) onTriggered: {
if (!root.taskModel) return;
root.taskModel.requestToggleMaximized(root.taskModel.makeModelIndex(taskDelegate.index))
}
} }
Controls.MenuSeparator {} Controls.MenuSeparator {}
Controls.MenuItem { Controls.MenuItem {
text: i18n("Close") text: i18n("Close")
icon.name: "window-close" icon.name: "window-close"
onTriggered: root.taskModel.requestClose(root.taskModel.makeModelIndex(taskDelegate.index)) onTriggered: {
if (!root.taskModel) return;
root.taskModel.requestClose(root.taskModel.makeModelIndex(taskDelegate.index))
}
} }
} }

View file

@ -212,7 +212,7 @@ Item {
} }
} }
// Down-arrow hover hint for convergence mode // Down-arrow hint for convergence mode
Kirigami.Icon { Kirigami.Icon {
z: 2 z: 2
source: 'arrow-down' source: 'arrow-down'
@ -224,7 +224,7 @@ Item {
anchors.bottomMargin: Kirigami.Units.smallSpacing anchors.bottomMargin: Kirigami.Units.smallSpacing
visible: ShellSettings.Settings.convergenceModeEnabled visible: ShellSettings.Settings.convergenceModeEnabled
opacity: statusBarHover.hovered ? 0.8 : 0 opacity: statusBarHover.hovered ? 0.6 : 0.2
Behavior on opacity { Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.shortDuration } NumberAnimation { duration: Kirigami.Units.shortDuration }

View file

@ -45,6 +45,8 @@ QList<QString> QuickSettingsConfig::enabledQuickSettings() const
QStringLiteral("org.kde.plasma.quicksetting.screenshot"), QStringLiteral("org.kde.plasma.quicksetting.screenshot"),
QStringLiteral("org.kde.plasma.quicksetting.powermenu"), QStringLiteral("org.kde.plasma.quicksetting.powermenu"),
QStringLiteral("org.kde.plasma.quicksetting.donotdisturb"), QStringLiteral("org.kde.plasma.quicksetting.donotdisturb"),
QStringLiteral("org.kde.plasma.quicksetting.gaming"),
QStringLiteral("org.kde.plasma.quicksetting.gaminghint"),
QStringLiteral("org.kde.plasma.quicksetting.caffeine"), QStringLiteral("org.kde.plasma.quicksetting.caffeine"),
QStringLiteral("org.kde.plasma.quicksetting.keyboardtoggle"), QStringLiteral("org.kde.plasma.quicksetting.keyboardtoggle"),
QStringLiteral("org.kde.plasma.quicksetting.hotspot")}); QStringLiteral("org.kde.plasma.quicksetting.hotspot")});

View file

@ -44,6 +44,8 @@ MobileShellSettings::MobileShellSettings(QObject *parent)
Q_EMIT actionDrawerTopRightModeChanged(); Q_EMIT actionDrawerTopRightModeChanged();
Q_EMIT convergenceModeEnabledChanged(); Q_EMIT convergenceModeEnabledChanged();
Q_EMIT autoHidePanelsEnabledChanged(); Q_EMIT autoHidePanelsEnabledChanged();
Q_EMIT gamingModeEnabledChanged();
Q_EMIT gamingDismissHintEnabledChanged();
Q_EMIT allowLogoutChanged(); Q_EMIT allowLogoutChanged();
} }
if (group.name() == LOCKSCREEN_CONFIG_GROUP) { if (group.name() == LOCKSCREEN_CONFIG_GROUP) {
@ -248,6 +250,32 @@ void MobileShellSettings::setAutoHidePanelsEnabled(bool enabled)
m_config->sync(); m_config->sync();
} }
bool MobileShellSettings::gamingModeEnabled() const
{
auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP};
return group.readEntry("gamingModeEnabled", false);
}
void MobileShellSettings::setGamingModeEnabled(bool enabled)
{
auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP};
group.writeEntry("gamingModeEnabled", enabled, KConfigGroup::Notify);
m_config->sync();
}
bool MobileShellSettings::gamingDismissHintEnabled() const
{
auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP};
return group.readEntry("gamingDismissHintEnabled", true);
}
void MobileShellSettings::setGamingDismissHintEnabled(bool enabled)
{
auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP};
group.writeEntry("gamingDismissHintEnabled", enabled, KConfigGroup::Notify);
m_config->sync();
}
void MobileShellSettings::updateNavigationBarsInPlasma() void MobileShellSettings::updateNavigationBarsInPlasma()
{ {
// Do not update panels when not in Plasma Mobile // Do not update panels when not in Plasma Mobile

View file

@ -52,6 +52,10 @@ class MobileShellSettings : public QObject
// Auto Hide Panels // Auto Hide Panels
Q_PROPERTY(bool autoHidePanelsEnabled READ autoHidePanelsEnabled WRITE setAutoHidePanelsEnabled NOTIFY autoHidePanelsEnabledChanged) Q_PROPERTY(bool autoHidePanelsEnabled READ autoHidePanelsEnabled WRITE setAutoHidePanelsEnabled NOTIFY autoHidePanelsEnabledChanged)
// Gaming mode
Q_PROPERTY(bool gamingModeEnabled READ gamingModeEnabled WRITE setGamingModeEnabled NOTIFY gamingModeEnabledChanged)
Q_PROPERTY(bool gamingDismissHintEnabled READ gamingDismissHintEnabled WRITE setGamingDismissHintEnabled NOTIFY gamingDismissHintEnabledChanged)
// logout dialog // logout dialog
Q_PROPERTY(bool allowLogout READ allowLogout READ allowLogout NOTIFY allowLogoutChanged) Q_PROPERTY(bool allowLogout READ allowLogout READ allowLogout NOTIFY allowLogoutChanged)
@ -264,6 +268,12 @@ public:
*/ */
void setAutoHidePanelsEnabled(bool enabled); void setAutoHidePanelsEnabled(bool enabled);
bool gamingModeEnabled() const;
void setGamingModeEnabled(bool enabled);
bool gamingDismissHintEnabled() const;
void setGamingDismissHintEnabled(bool enabled);
/** /**
* Whether logout button is shown in the logout/shutdown dialog. * Whether logout button is shown in the logout/shutdown dialog.
*/ */
@ -310,6 +320,8 @@ Q_SIGNALS:
void quickSettingsColumnsChanged(); void quickSettingsColumnsChanged();
void convergenceModeEnabledChanged(); void convergenceModeEnabledChanged();
void autoHidePanelsEnabledChanged(); void autoHidePanelsEnabledChanged();
void gamingModeEnabledChanged();
void gamingDismissHintEnabledChanged();
void allowLogoutChanged(); void allowLogoutChanged();
void lockscreenLeftButtonActionChanged(); void lockscreenLeftButtonActionChanged();
void lockscreenRightButtonActionChanged(); void lockscreenRightButtonActionChanged();

View file

@ -35,6 +35,17 @@ void WaydroidApplicationListModel::initializeApplications(const QList<QDBusObjec
endResetModel(); endResetModel();
} }
void WaydroidApplicationListModel::clearApplications()
{
if (m_applications.isEmpty()) {
return;
}
beginResetModel();
m_applications.clear();
endResetModel();
}
void WaydroidApplicationListModel::addApplication(const QDBusObjectPath &objectPath) void WaydroidApplicationListModel::addApplication(const QDBusObjectPath &objectPath)
{ {
beginInsertRows({}, m_applications.size(), m_applications.size()); beginInsertRows({}, m_applications.size(), m_applications.size());

View file

@ -33,6 +33,7 @@ public:
QHash<int, QByteArray> roleNames() const override; QHash<int, QByteArray> roleNames() const override;
void initializeApplications(const QList<QDBusObjectPath> &applicationObjectPaths); void initializeApplications(const QList<QDBusObjectPath> &applicationObjectPaths);
void clearApplications();
public Q_SLOTS: public Q_SLOTS:
void addApplication(const QDBusObjectPath &objectPath); void addApplication(const QDBusObjectPath &objectPath);

View file

@ -6,24 +6,38 @@
#include "waydroiddbusclient.h" #include "waydroiddbusclient.h"
#include <KConfigGroup>
#include <QClipboard> #include <QClipboard>
#include <QCoroDBusPendingReply> #include <QCoroDBusPendingReply>
#include <QDBusMessage>
#include <QGuiApplication> #include <QGuiApplication>
#include <QTimer>
using namespace Qt::StringLiterals; using namespace Qt::StringLiterals;
static const QString s_waydroidGamingGroup = QStringLiteral("WaydroidGaming");
static const QString s_gameShellPackagesKey = QStringLiteral("gameShellPackages");
WaydroidDBusClient::WaydroidDBusClient(QObject *parent) WaydroidDBusClient::WaydroidDBusClient(QObject *parent)
: QObject{parent} : QObject{parent}
, m_interface{new OrgKdePlasmashellWaydroidInterface{u"org.kde.plasmashell"_s, u"/Waydroid"_s, QDBusConnection::sessionBus(), this}} , m_interface{new OrgKdePlasmashellWaydroidInterface{u"org.kde.plasmashell"_s, u"/Waydroid"_s, QDBusConnection::sessionBus(), this}}
, m_watcher{new QDBusServiceWatcher{u"org.kde.plasmashell"_s, QDBusConnection::sessionBus(), QDBusServiceWatcher::WatchForOwnerChange, this}} , m_watcher{new QDBusServiceWatcher{u"org.kde.plasmashell"_s, QDBusConnection::sessionBus(), QDBusServiceWatcher::WatchForOwnerChange, this}}
, m_applicationListModel{new WaydroidApplicationListModel{this}} , m_applicationListModel{new WaydroidApplicationListModel{this}}
, m_config{KSharedConfig::openConfig(QStringLiteral("plasmamobilerc"))}
{ {
m_configWatcher = KConfigWatcher::create(m_config);
connect(m_configWatcher.data(), &KConfigWatcher::configChanged, this, [this](const KConfigGroup &group) {
if (group.name() == s_waydroidGamingGroup) {
m_config->reparseConfiguration();
reloadGameShellPackages();
}
});
reloadGameShellPackages();
// Check if the service is already running // Check if the service is already running
if (QDBusConnection::sessionBus().interface()->isServiceRegistered(u"org.kde.plasmashell"_s)) { if (QDBusConnection::sessionBus().interface()->isServiceRegistered(u"org.kde.plasmashell"_s)) {
m_connected = true; checkWaydroidObject();
if (m_interface->isValid()) {
connectSignals();
}
} }
connect(m_watcher, &QDBusServiceWatcher::serviceOwnerChanged, this, [this](const QString &service, const QString &oldOwner, const QString &newOwner) { connect(m_watcher, &QDBusServiceWatcher::serviceOwnerChanged, this, [this](const QString &service, const QString &oldOwner, const QString &newOwner) {
@ -31,12 +45,11 @@ WaydroidDBusClient::WaydroidDBusClient(QObject *parent)
if (newOwner.isEmpty()) { if (newOwner.isEmpty()) {
// Service stopped // Service stopped
m_connected = false; m_connected = false;
m_connectionCheckPending = false;
resetState();
} else if (oldOwner.isEmpty()) { } else if (oldOwner.isEmpty()) {
// Service started // Service started
m_connected = true; checkWaydroidObject();
if (m_interface->isValid()) {
connectSignals();
}
} }
} }
}); });
@ -44,26 +57,32 @@ WaydroidDBusClient::WaydroidDBusClient(QObject *parent)
void WaydroidDBusClient::connectSignals() void WaydroidDBusClient::connectSignals()
{ {
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::statusChanged, this, &WaydroidDBusClient::updateStatus); if (!m_signalsConnected) {
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::downloadStatusChanged, this, [this](double downloaded, double total, double speed) { m_signalsConnected = true;
Q_EMIT downloadStatusChanged(downloaded, total, speed);
}); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::statusChanged, this, &WaydroidDBusClient::updateStatus);
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::sessionStatusChanged, this, &WaydroidDBusClient::updateSessionStatus); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::downloadStatusChanged, this, [this](double downloaded, double total, double speed) {
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::systemTypeChanged, this, &WaydroidDBusClient::updateSystemType); Q_EMIT downloadStatusChanged(downloaded, total, speed);
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::ipAddressChanged, this, &WaydroidDBusClient::updateIpAddress); });
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::androidIdChanged, this, &WaydroidDBusClient::updateAndroidId); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::sessionStatusChanged, this, &WaydroidDBusClient::updateSessionStatus);
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::multiWindowsChanged, this, &WaydroidDBusClient::updateMultiWindows); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::systemTypeChanged, this, &WaydroidDBusClient::updateSystemType);
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::suspendChanged, this, &WaydroidDBusClient::updateSuspend); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::ipAddressChanged, this, &WaydroidDBusClient::updateIpAddress);
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::ueventChanged, this, &WaydroidDBusClient::updateUevent); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::androidIdChanged, this, &WaydroidDBusClient::updateAndroidId);
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::actionFinished, this, [this](const QString message) { connect(m_interface, &OrgKdePlasmashellWaydroidInterface::multiWindowsChanged, this, &WaydroidDBusClient::updateMultiWindows);
Q_EMIT actionFinished(message); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::suspendChanged, this, &WaydroidDBusClient::updateSuspend);
}); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::ueventChanged, this, &WaydroidDBusClient::updateUevent);
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::actionFailed, this, [this](const QString message) { connect(m_interface, &OrgKdePlasmashellWaydroidInterface::fakeTouchChanged, this, &WaydroidDBusClient::updateFakeTouch);
Q_EMIT actionFailed(message); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::fakeWifiChanged, this, &WaydroidDBusClient::updateFakeWifi);
}); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::actionFinished, this, [this](const QString message) {
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::errorOccurred, this, [this](const QString title, const QString message) { Q_EMIT actionFinished(message);
Q_EMIT errorOccurred(title, message); });
}); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::actionFailed, this, [this](const QString message) {
Q_EMIT actionFailed(message);
});
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::errorOccurred, this, [this](const QString title, const QString message) {
Q_EMIT errorOccurred(title, message);
});
}
initializeApplicationListModel(); initializeApplicationListModel();
updateStatus(); updateStatus();
@ -74,6 +93,118 @@ void WaydroidDBusClient::connectSignals()
updateMultiWindows(); updateMultiWindows();
updateSuspend(); updateSuspend();
updateUevent(); updateUevent();
updateFakeTouch();
updateFakeWifi();
}
void WaydroidDBusClient::checkWaydroidObject()
{
if (m_connectionCheckPending) {
return;
}
m_connectionCheckPending = true;
const QDBusMessage message =
QDBusMessage::createMethodCall(u"org.kde.plasmashell"_s, u"/Waydroid"_s, u"org.freedesktop.DBus.Introspectable"_s, u"Introspect"_s);
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(message), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, &WaydroidDBusClient::onWaydroidObjectCheckFinished);
}
void WaydroidDBusClient::onWaydroidObjectCheckFinished(QDBusPendingCallWatcher *watcher)
{
m_connectionCheckPending = false;
QDBusPendingReply<QString> reply = *watcher;
if (!reply.isValid()) {
m_connected = false;
watcher->deleteLater();
resetState();
scheduleWaydroidObjectCheck();
return;
}
m_connected = true;
watcher->deleteLater();
if (m_interface->isValid()) {
connectSignals();
}
}
void WaydroidDBusClient::handleUnavailableReply()
{
if (!m_connected) {
return;
}
m_connected = false;
resetState();
scheduleWaydroidObjectCheck();
}
void WaydroidDBusClient::resetState()
{
if (m_status != NotSupported) {
m_status = NotSupported;
Q_EMIT statusChanged();
}
if (m_sessionStatus != SessionStopped) {
m_sessionStatus = SessionStopped;
Q_EMIT sessionStatusChanged();
}
if (m_systemType != UnknownSystemType) {
m_systemType = UnknownSystemType;
Q_EMIT systemTypeChanged();
}
if (!m_ipAddress.isEmpty()) {
m_ipAddress.clear();
Q_EMIT ipAddressChanged();
}
if (!m_androidId.isEmpty()) {
m_androidId.clear();
Q_EMIT androidIdChanged();
}
if (m_multiWindows) {
m_multiWindows = false;
Q_EMIT multiWindowsChanged();
}
if (m_suspend) {
m_suspend = false;
Q_EMIT suspendChanged();
}
if (m_uevent) {
m_uevent = false;
Q_EMIT ueventChanged();
}
if (!m_fakeTouch.isEmpty()) {
m_fakeTouch.clear();
Q_EMIT fakeTouchChanged();
}
if (!m_fakeWifi.isEmpty()) {
m_fakeWifi.clear();
Q_EMIT fakeWifiChanged();
}
m_applicationListModel->clearApplications();
}
void WaydroidDBusClient::scheduleWaydroidObjectCheck()
{
if (!QDBusConnection::sessionBus().interface()->isServiceRegistered(u"org.kde.plasmashell"_s)) {
return;
}
QTimer::singleShot(1000, this, &WaydroidDBusClient::checkWaydroidObject);
} }
void WaydroidDBusClient::initializeApplicationListModel() void WaydroidDBusClient::initializeApplicationListModel()
@ -85,6 +216,7 @@ void WaydroidDBusClient::initializeApplicationListModel()
QDBusPendingReply<QList<QDBusObjectPath>> reply = *watcher; QDBusPendingReply<QList<QDBusObjectPath>> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch applications:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch applications:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -133,6 +265,10 @@ WaydroidApplicationListModel *WaydroidDBusClient::applicationListModel() const
QCoro::Task<void> WaydroidDBusClient::setMultiWindowsTask(const bool multiWindows) QCoro::Task<void> WaydroidDBusClient::setMultiWindowsTask(const bool multiWindows)
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->setMultiWindows(multiWindows); co_await m_interface->setMultiWindows(multiWindows);
} }
@ -148,6 +284,10 @@ bool WaydroidDBusClient::multiWindows() const
QCoro::Task<void> WaydroidDBusClient::setSuspendTask(const bool suspend) QCoro::Task<void> WaydroidDBusClient::setSuspendTask(const bool suspend)
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->setSuspend(suspend); co_await m_interface->setSuspend(suspend);
} }
@ -163,6 +303,10 @@ bool WaydroidDBusClient::suspend() const
QCoro::Task<void> WaydroidDBusClient::setUeventTask(const bool uevent) QCoro::Task<void> WaydroidDBusClient::setUeventTask(const bool uevent)
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->setUevent(uevent); co_await m_interface->setUevent(uevent);
} }
@ -173,6 +317,10 @@ QCoro::QmlTask WaydroidDBusClient::setUevent(const bool uevent)
QCoro::Task<void> WaydroidDBusClient::refreshSessionInfoTask() QCoro::Task<void> WaydroidDBusClient::refreshSessionInfoTask()
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->refreshSessionInfo(); co_await m_interface->refreshSessionInfo();
} }
@ -183,6 +331,10 @@ QCoro::QmlTask WaydroidDBusClient::refreshSessionInfo()
QCoro::Task<void> WaydroidDBusClient::refreshAndroidIdTask() QCoro::Task<void> WaydroidDBusClient::refreshAndroidIdTask()
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->refreshAndroidId(); co_await m_interface->refreshAndroidId();
} }
@ -193,6 +345,10 @@ QCoro::QmlTask WaydroidDBusClient::refreshAndroidId()
QCoro::Task<void> WaydroidDBusClient::refreshApplicationsTask() QCoro::Task<void> WaydroidDBusClient::refreshApplicationsTask()
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->refreshApplications(); co_await m_interface->refreshApplications();
} }
@ -201,13 +357,87 @@ QCoro::QmlTask WaydroidDBusClient::refreshApplications()
return refreshApplicationsTask(); return refreshApplicationsTask();
} }
bool WaydroidDBusClient::gameShellEnabledForPackage(const QString &packageName) const
{
return m_gameShellPackages.contains(packageName);
}
void WaydroidDBusClient::setGameShellEnabledForPackage(const QString &packageName, bool enabled)
{
QStringList packages = m_gameShellPackages;
packages.removeAll(packageName);
if (enabled) {
packages.append(packageName);
}
packages.removeDuplicates();
packages.sort();
if (packages == m_gameShellPackages) {
return;
}
KConfigGroup group(m_config, s_waydroidGamingGroup);
group.writeEntry(s_gameShellPackagesKey, packages, KConfigGroup::Notify);
m_config->sync();
m_gameShellPackages = packages;
Q_EMIT gameShellPackagesChanged();
}
bool WaydroidDBusClient::uevent() const bool WaydroidDBusClient::uevent() const
{ {
return m_uevent; return m_uevent;
} }
QCoro::Task<void> WaydroidDBusClient::setFakeTouchTask(const QString &fakeTouch)
{
if (!m_connected) {
co_return;
}
co_await m_interface->setFakeTouch(fakeTouch);
}
QCoro::QmlTask WaydroidDBusClient::setFakeTouch(const QString &fakeTouch)
{
return setFakeTouchTask(fakeTouch);
}
QString WaydroidDBusClient::fakeTouch() const
{
return m_fakeTouch;
}
QCoro::Task<void> WaydroidDBusClient::setFakeWifiTask(const QString &fakeWifi)
{
if (!m_connected) {
co_return;
}
co_await m_interface->setFakeWifi(fakeWifi);
}
QCoro::QmlTask WaydroidDBusClient::setFakeWifi(const QString &fakeWifi)
{
return setFakeWifiTask(fakeWifi);
}
QString WaydroidDBusClient::fakeWifi() const
{
return m_fakeWifi;
}
QStringList WaydroidDBusClient::gameShellPackages() const
{
return m_gameShellPackages;
}
QCoro::Task<void> WaydroidDBusClient::initializeTask(const SystemType systemType, const RomType romType, const bool forced) QCoro::Task<void> WaydroidDBusClient::initializeTask(const SystemType systemType, const RomType romType, const bool forced)
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->initialize(systemType, romType, forced); co_await m_interface->initialize(systemType, romType, forced);
} }
@ -218,6 +448,10 @@ QCoro::QmlTask WaydroidDBusClient::initialize(const SystemType systemType, const
QCoro::Task<void> WaydroidDBusClient::startSessionTask() QCoro::Task<void> WaydroidDBusClient::startSessionTask()
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->startSession(); co_await m_interface->startSession();
} }
@ -228,6 +462,10 @@ QCoro::QmlTask WaydroidDBusClient::startSession()
QCoro::Task<void> WaydroidDBusClient::stopSessionTask() QCoro::Task<void> WaydroidDBusClient::stopSessionTask()
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->stopSession(); co_await m_interface->stopSession();
} }
@ -238,6 +476,10 @@ QCoro::QmlTask WaydroidDBusClient::stopSession()
QCoro::Task<void> WaydroidDBusClient::resetWaydroidTask() QCoro::Task<void> WaydroidDBusClient::resetWaydroidTask()
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->resetWaydroid(); co_await m_interface->resetWaydroid();
} }
@ -248,6 +490,10 @@ QCoro::QmlTask WaydroidDBusClient::resetWaydroid()
QCoro::Task<void> WaydroidDBusClient::installApkTask(const QString apkFile) QCoro::Task<void> WaydroidDBusClient::installApkTask(const QString apkFile)
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->installApk(apkFile); co_await m_interface->installApk(apkFile);
} }
@ -256,8 +502,26 @@ QCoro::QmlTask WaydroidDBusClient::installApk(const QString apkFile)
return installApkTask(apkFile); return installApkTask(apkFile);
} }
QCoro::Task<void> WaydroidDBusClient::launchApplicationTask(const QString appId)
{
if (!m_connected) {
co_return;
}
co_await m_interface->launchApplication(appId);
}
QCoro::QmlTask WaydroidDBusClient::launchApplication(const QString appId)
{
return launchApplicationTask(appId);
}
QCoro::Task<void> WaydroidDBusClient::deleteApplicationTask(const QString appId) QCoro::Task<void> WaydroidDBusClient::deleteApplicationTask(const QString appId)
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->deleteApplication(appId); co_await m_interface->deleteApplication(appId);
} }
@ -266,6 +530,20 @@ QCoro::QmlTask WaydroidDBusClient::deleteApplication(const QString appId)
return deleteApplicationTask(appId); return deleteApplicationTask(appId);
} }
QCoro::Task<void> WaydroidDBusClient::refreshSupportsInfoTask()
{
if (!m_connected) {
co_return;
}
co_await m_interface->refreshSupportsInfo();
}
QCoro::QmlTask WaydroidDBusClient::refreshSupportsInfo()
{
return refreshSupportsInfoTask();
}
void WaydroidDBusClient::updateStatus() void WaydroidDBusClient::updateStatus()
{ {
auto reply = m_interface->status(); auto reply = m_interface->status();
@ -275,6 +553,7 @@ void WaydroidDBusClient::updateStatus()
QDBusPendingReply<int> reply = *watcher; QDBusPendingReply<int> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch status:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch status:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -299,6 +578,7 @@ void WaydroidDBusClient::updateSessionStatus()
QDBusPendingReply<int> reply = *watcher; QDBusPendingReply<int> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch sessionStatus:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch sessionStatus:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -323,6 +603,7 @@ void WaydroidDBusClient::updateSystemType()
QDBusPendingReply<int> reply = *watcher; QDBusPendingReply<int> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch systemType:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch systemType:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -347,6 +628,7 @@ void WaydroidDBusClient::updateIpAddress()
QDBusPendingReply<QString> reply = *watcher; QDBusPendingReply<QString> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch ipAddress:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch ipAddress:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -371,6 +653,7 @@ void WaydroidDBusClient::updateAndroidId()
QDBusPendingReply<QString> reply = *watcher; QDBusPendingReply<QString> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch androidId:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch androidId:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -395,6 +678,7 @@ void WaydroidDBusClient::updateMultiWindows()
QDBusPendingReply<bool> reply = *watcher; QDBusPendingReply<bool> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch multiWindows:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch multiWindows:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -419,6 +703,7 @@ void WaydroidDBusClient::updateSuspend()
QDBusPendingReply<bool> reply = *watcher; QDBusPendingReply<bool> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch suspend:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch suspend:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -443,6 +728,7 @@ void WaydroidDBusClient::updateUevent()
QDBusPendingReply<bool> reply = *watcher; QDBusPendingReply<bool> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch uevent:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch uevent:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -458,7 +744,72 @@ void WaydroidDBusClient::updateUevent()
}); });
} }
void WaydroidDBusClient::updateFakeTouch()
{
auto reply = m_interface->fakeTouch();
auto watcher = new QDBusPendingCallWatcher(reply, this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](auto watcher) {
QDBusPendingReply<QString> reply = *watcher;
if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch fakeTouch:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater();
return;
}
const QString fakeTouch = reply.argumentAt<0>();
if (m_fakeTouch != fakeTouch) {
m_fakeTouch = fakeTouch;
Q_EMIT fakeTouchChanged();
}
watcher->deleteLater();
});
}
void WaydroidDBusClient::updateFakeWifi()
{
auto reply = m_interface->fakeWifi();
auto watcher = new QDBusPendingCallWatcher(reply, this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](auto watcher) {
QDBusPendingReply<QString> reply = *watcher;
if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch fakeWifi:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater();
return;
}
const QString fakeWifi = reply.argumentAt<0>();
if (m_fakeWifi != fakeWifi) {
m_fakeWifi = fakeWifi;
Q_EMIT fakeWifiChanged();
}
watcher->deleteLater();
});
}
void WaydroidDBusClient::copyToClipboard(const QString text) void WaydroidDBusClient::copyToClipboard(const QString text)
{ {
qGuiApp->clipboard()->setText(text); qGuiApp->clipboard()->setText(text);
} }
void WaydroidDBusClient::reloadGameShellPackages()
{
const KConfigGroup group(m_config, s_waydroidGamingGroup);
QStringList packages = group.readEntry(s_gameShellPackagesKey, QStringList{});
packages.removeDuplicates();
packages.sort();
if (m_gameShellPackages == packages) {
return;
}
m_gameShellPackages = packages;
Q_EMIT gameShellPackagesChanged();
}

View file

@ -10,8 +10,12 @@
#include "waydroidapplicationlistmodel.h" #include "waydroidapplicationlistmodel.h"
#include "waydroiddbusobject.h" #include "waydroiddbusobject.h"
#include <KConfigWatcher>
#include <KSharedConfig>
#include <QCoroCore> #include <QCoroCore>
#include <QCoroQmlTask> #include <QCoroQmlTask>
#include <QDBusPendingCallWatcher>
#include <QDBusServiceWatcher> #include <QDBusServiceWatcher>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
@ -32,6 +36,9 @@ class WaydroidDBusClient : public QObject
Q_PROPERTY(bool multiWindows READ multiWindows WRITE setMultiWindows NOTIFY multiWindowsChanged) Q_PROPERTY(bool multiWindows READ multiWindows WRITE setMultiWindows NOTIFY multiWindowsChanged)
Q_PROPERTY(bool suspend READ suspend WRITE setSuspend NOTIFY suspendChanged) Q_PROPERTY(bool suspend READ suspend WRITE setSuspend NOTIFY suspendChanged)
Q_PROPERTY(bool uevent READ uevent WRITE setUevent NOTIFY ueventChanged) Q_PROPERTY(bool uevent READ uevent WRITE setUevent NOTIFY ueventChanged)
Q_PROPERTY(QString fakeTouch READ fakeTouch WRITE setFakeTouch NOTIFY fakeTouchChanged)
Q_PROPERTY(QString fakeWifi READ fakeWifi WRITE setFakeWifi NOTIFY fakeWifiChanged)
Q_PROPERTY(QStringList gameShellPackages READ gameShellPackages NOTIFY gameShellPackagesChanged)
Q_PROPERTY(WaydroidApplicationListModel *applicationListModel READ applicationListModel CONSTANT) Q_PROPERTY(WaydroidApplicationListModel *applicationListModel READ applicationListModel CONSTANT)
public: public:
@ -98,16 +105,25 @@ public:
QCoro::QmlTask setSuspend(const bool suspend); QCoro::QmlTask setSuspend(const bool suspend);
[[nodiscard]] bool uevent() const; [[nodiscard]] bool uevent() const;
QCoro::QmlTask setUevent(const bool uevent); QCoro::QmlTask setUevent(const bool uevent);
[[nodiscard]] QString fakeTouch() const;
QCoro::QmlTask setFakeTouch(const QString &fakeTouch);
[[nodiscard]] QString fakeWifi() const;
QCoro::QmlTask setFakeWifi(const QString &fakeWifi);
[[nodiscard]] QStringList gameShellPackages() const;
Q_INVOKABLE QCoro::QmlTask initialize(const SystemType systemType, const RomType romType, const bool forced = false); Q_INVOKABLE QCoro::QmlTask initialize(const SystemType systemType, const RomType romType, const bool forced = false);
Q_INVOKABLE QCoro::QmlTask startSession(); Q_INVOKABLE QCoro::QmlTask startSession();
Q_INVOKABLE QCoro::QmlTask stopSession(); Q_INVOKABLE QCoro::QmlTask stopSession();
Q_INVOKABLE QCoro::QmlTask resetWaydroid(); Q_INVOKABLE QCoro::QmlTask resetWaydroid();
Q_INVOKABLE QCoro::QmlTask installApk(const QString apkFile); Q_INVOKABLE QCoro::QmlTask installApk(const QString apkFile);
Q_INVOKABLE QCoro::QmlTask launchApplication(const QString appId);
Q_INVOKABLE QCoro::QmlTask deleteApplication(const QString appId); Q_INVOKABLE QCoro::QmlTask deleteApplication(const QString appId);
Q_INVOKABLE QCoro::QmlTask refreshSupportsInfo();
Q_INVOKABLE QCoro::QmlTask refreshSessionInfo(); Q_INVOKABLE QCoro::QmlTask refreshSessionInfo();
Q_INVOKABLE QCoro::QmlTask refreshAndroidId(); Q_INVOKABLE QCoro::QmlTask refreshAndroidId();
Q_INVOKABLE QCoro::QmlTask refreshApplications(); Q_INVOKABLE QCoro::QmlTask refreshApplications();
Q_INVOKABLE bool gameShellEnabledForPackage(const QString &packageName) const;
Q_INVOKABLE void setGameShellEnabledForPackage(const QString &packageName, bool enabled);
Q_INVOKABLE void copyToClipboard(const QString text); Q_INVOKABLE void copyToClipboard(const QString text);
@ -122,6 +138,9 @@ Q_SIGNALS:
void multiWindowsChanged(); void multiWindowsChanged();
void suspendChanged(); void suspendChanged();
void ueventChanged(); void ueventChanged();
void fakeTouchChanged();
void fakeWifiChanged();
void gameShellPackagesChanged();
void actionFinished(const QString message); void actionFinished(const QString message);
void actionFailed(const QString message); void actionFailed(const QString message);
@ -136,12 +155,14 @@ private Q_SLOTS:
void updateMultiWindows(); void updateMultiWindows();
void updateSuspend(); void updateSuspend();
void updateUevent(); void updateUevent();
void updateFakeTouch();
void updateFakeWifi();
private: private:
OrgKdePlasmashellWaydroidInterface *m_interface; OrgKdePlasmashellWaydroidInterface *m_interface;
QDBusServiceWatcher *m_watcher; QDBusServiceWatcher *m_watcher;
Status m_status{NotInitialized}; Status m_status{NotSupported};
SessionStatus m_sessionStatus{SessionStopped}; SessionStatus m_sessionStatus{SessionStopped};
SystemType m_systemType{UnknownSystemType}; SystemType m_systemType{UnknownSystemType};
QString m_ipAddress{""}; QString m_ipAddress{""};
@ -152,22 +173,40 @@ private:
bool m_multiWindows{false}; bool m_multiWindows{false};
bool m_suspend{false}; bool m_suspend{false};
bool m_uevent{false}; bool m_uevent{false};
QString m_fakeTouch;
QString m_fakeWifi;
KSharedConfig::Ptr m_config;
KConfigWatcher::Ptr m_configWatcher;
QStringList m_gameShellPackages;
bool m_connected{false}; bool m_connected{false};
bool m_signalsConnected{false};
bool m_connectionCheckPending{false};
void connectSignals(); void connectSignals();
void initializeApplicationListModel(); void initializeApplicationListModel();
void checkWaydroidObject();
void handleUnavailableReply();
void onWaydroidObjectCheckFinished(QDBusPendingCallWatcher *watcher);
void resetState();
void scheduleWaydroidObjectCheck();
QCoro::Task<void> initializeTask(const SystemType systemType, const RomType romType, const bool forced = false); QCoro::Task<void> initializeTask(const SystemType systemType, const RomType romType, const bool forced = false);
QCoro::Task<void> startSessionTask(); QCoro::Task<void> startSessionTask();
QCoro::Task<void> stopSessionTask(); QCoro::Task<void> stopSessionTask();
QCoro::Task<void> resetWaydroidTask(); QCoro::Task<void> resetWaydroidTask();
QCoro::Task<void> installApkTask(const QString apkFile); QCoro::Task<void> installApkTask(const QString apkFile);
QCoro::Task<void> launchApplicationTask(const QString appId);
QCoro::Task<void> deleteApplicationTask(const QString appId); QCoro::Task<void> deleteApplicationTask(const QString appId);
QCoro::Task<void> refreshSupportsInfoTask();
QCoro::Task<void> setMultiWindowsTask(const bool multiWindows); QCoro::Task<void> setMultiWindowsTask(const bool multiWindows);
QCoro::Task<void> setSuspendTask(const bool suspend); QCoro::Task<void> setSuspendTask(const bool suspend);
QCoro::Task<void> setUeventTask(const bool uevent); QCoro::Task<void> setUeventTask(const bool uevent);
QCoro::Task<void> setFakeTouchTask(const QString &fakeTouch);
QCoro::Task<void> setFakeWifiTask(const QString &fakeWifi);
QCoro::Task<void> refreshSessionInfoTask(); QCoro::Task<void> refreshSessionInfoTask();
QCoro::Task<void> refreshAndroidIdTask(); QCoro::Task<void> refreshAndroidIdTask();
QCoro::Task<void> refreshApplicationsTask(); QCoro::Task<void> refreshApplicationsTask();
void reloadGameShellPackages();
}; };

View file

@ -31,6 +31,8 @@ using namespace Qt::StringLiterals;
#define MULTI_WINDOWS_PROP_KEY "persist.waydroid.multi_windows" #define MULTI_WINDOWS_PROP_KEY "persist.waydroid.multi_windows"
#define SUSPEND_PROP_KEY "persist.waydroid.suspend" #define SUSPEND_PROP_KEY "persist.waydroid.suspend"
#define UEVENT_PROP_KEY "persist.waydroid.uevent" #define UEVENT_PROP_KEY "persist.waydroid.uevent"
#define FAKE_TOUCH_PROP_KEY "persist.waydroid.fake_touch"
#define FAKE_WIFI_PROP_KEY "persist.waydroid.fake_wifi"
static const QRegularExpression sessionRegExp(u"Session:\\s*(\\w+)"_s); static const QRegularExpression sessionRegExp(u"Session:\\s*(\\w+)"_s);
static const QRegularExpression ipAddressRegExp(u"IP address:\\s*(\\d+\\.\\d+\\.\\d+\\.\\d+)"_s); static const QRegularExpression ipAddressRegExp(u"IP address:\\s*(\\d+\\.\\d+\\.\\d+\\.\\d+)"_s);
@ -179,10 +181,12 @@ void WaydroidDBusObject::stopSession()
connect(process, &QProcess::finished, this, [this, process](int exitCode, QProcess::ExitStatus exitStatus) { connect(process, &QProcess::finished, this, [this, process](int exitCode, QProcess::ExitStatus exitStatus) {
Q_UNUSED(exitStatus); Q_UNUSED(exitStatus);
const QByteArray errorLog = process->readAllStandardError();
process->deleteLater(); process->deleteLater();
if (exitCode == 0) { if (exitCode != 0) {
qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Failed to stop the Waydroid session: " << process->readAllStandardError(); Q_EMIT errorOccurred(i18n("Failed to stop the Waydroid session."), QString::fromUtf8(errorLog));
qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Failed to stop the Waydroid session: " << errorLog;
return; return;
} }
@ -250,6 +254,27 @@ void WaydroidDBusObject::installApk(const QString apkFile)
process->start(WAYDROID_COMMAND, arguments); process->start(WAYDROID_COMMAND, arguments);
} }
void WaydroidDBusObject::launchApplication(const QString appId)
{
const QStringList arguments{u"app"_s, u"launch"_s, appId};
QProcess *process = new QProcess(this);
connect(process, &QProcess::finished, this, [this, appId, process](int exitCode, QProcess::ExitStatus exitStatus) {
const QByteArray errorLog = process->readAllStandardError();
process->deleteLater();
if (exitCode == 0 && exitStatus == QProcess::NormalExit) {
Q_EMIT actionFinished(i18n("Application has been launched"));
} else {
Q_EMIT actionFailed(i18n("Application launch failed"));
qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Error occurred while launching " << appId << ": " << errorLog;
}
});
process->start(WAYDROID_COMMAND, arguments);
}
void WaydroidDBusObject::deleteApplication(const QString appId) void WaydroidDBusObject::deleteApplication(const QString appId)
{ {
const QStringList arguments{u"app"_s, u"remove"_s, appId}; const QStringList arguments{u"app"_s, u"remove"_s, appId};
@ -375,6 +400,52 @@ void WaydroidDBusObject::setUevent(const bool uevent)
coro(this, value, uevent); coro(this, value, uevent);
} }
QString WaydroidDBusObject::fakeTouch() const
{
return m_fakeTouch;
}
void WaydroidDBusObject::setFakeTouch(const QString &fakeTouch)
{
if (m_fakeTouch == fakeTouch) {
return;
}
auto coro = [](WaydroidDBusObject *self, QString fakeTouch) -> QCoro::Task<void> {
QPointer<WaydroidDBusObject> guard(self);
if (co_await self->writePropValue(FAKE_TOUCH_PROP_KEY, fakeTouch)) {
if (guard) {
self->m_fakeTouch = fakeTouch;
Q_EMIT self->fakeTouchChanged();
}
}
};
coro(this, fakeTouch);
}
QString WaydroidDBusObject::fakeWifi() const
{
return m_fakeWifi;
}
void WaydroidDBusObject::setFakeWifi(const QString &fakeWifi)
{
if (m_fakeWifi == fakeWifi) {
return;
}
auto coro = [](WaydroidDBusObject *self, QString fakeWifi) -> QCoro::Task<void> {
QPointer<WaydroidDBusObject> guard(self);
if (co_await self->writePropValue(FAKE_WIFI_PROP_KEY, fakeWifi)) {
if (guard) {
self->m_fakeWifi = fakeWifi;
Q_EMIT self->fakeWifiChanged();
}
}
};
coro(this, fakeWifi);
}
QList<QDBusObjectPath> WaydroidDBusObject::applications() const QList<QDBusObjectPath> WaydroidDBusObject::applications() const
{ {
QList<QDBusObjectPath> paths; QList<QDBusObjectPath> paths;
@ -513,6 +584,7 @@ void WaydroidDBusObject::refreshAndroidId()
} }
} else { } else {
m_androidId = ""; m_androidId = "";
Q_EMIT actionFailed(i18n("Failed to fetch Android ID"));
qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "KAuth returned an error code:" << executeJob->error(); qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "KAuth returned an error code:" << executeJob->error();
} }
@ -539,6 +611,12 @@ QCoro::Task<void> WaydroidDBusObject::refreshPropsInfo()
const QString ueventPropValue = co_await fetchPropValue(UEVENT_PROP_KEY, "false"); const QString ueventPropValue = co_await fetchPropValue(UEVENT_PROP_KEY, "false");
m_uevent = ueventPropValue == "true"; m_uevent = ueventPropValue == "true";
Q_EMIT ueventChanged(); Q_EMIT ueventChanged();
m_fakeTouch = co_await fetchPropValue(FAKE_TOUCH_PROP_KEY, QString{});
Q_EMIT fakeTouchChanged();
m_fakeWifi = co_await fetchPropValue(FAKE_WIFI_PROP_KEY, QString{});
Q_EMIT fakeWifiChanged();
} }
QCoro::Task<QString> WaydroidDBusObject::fetchPropValue(const QString key, const QString defaultValue) QCoro::Task<QString> WaydroidDBusObject::fetchPropValue(const QString key, const QString defaultValue)

View file

@ -96,6 +96,8 @@ Q_SIGNALS:
Q_SCRIPTABLE void multiWindowsChanged(); Q_SCRIPTABLE void multiWindowsChanged();
Q_SCRIPTABLE void suspendChanged(); Q_SCRIPTABLE void suspendChanged();
Q_SCRIPTABLE void ueventChanged(); Q_SCRIPTABLE void ueventChanged();
Q_SCRIPTABLE void fakeTouchChanged();
Q_SCRIPTABLE void fakeWifiChanged();
Q_SCRIPTABLE void applicationAdded(QDBusObjectPath path); Q_SCRIPTABLE void applicationAdded(QDBusObjectPath path);
Q_SCRIPTABLE void applicationRemoved(QDBusObjectPath path); Q_SCRIPTABLE void applicationRemoved(QDBusObjectPath path);
@ -119,6 +121,10 @@ public Q_SLOTS:
Q_SCRIPTABLE void setSuspend(const bool suspend); Q_SCRIPTABLE void setSuspend(const bool suspend);
Q_SCRIPTABLE bool uevent() const; Q_SCRIPTABLE bool uevent() const;
Q_SCRIPTABLE void setUevent(const bool uevent); Q_SCRIPTABLE void setUevent(const bool uevent);
Q_SCRIPTABLE QString fakeTouch() const;
Q_SCRIPTABLE void setFakeTouch(const QString &fakeTouch);
Q_SCRIPTABLE QString fakeWifi() const;
Q_SCRIPTABLE void setFakeWifi(const QString &fakeWifi);
Q_SCRIPTABLE QList<QDBusObjectPath> applications() const; Q_SCRIPTABLE QList<QDBusObjectPath> applications() const;
Q_SCRIPTABLE void initialize(const int systemType, const int romType, const bool forced = false); Q_SCRIPTABLE void initialize(const int systemType, const int romType, const bool forced = false);
@ -126,7 +132,9 @@ public Q_SLOTS:
Q_SCRIPTABLE void stopSession(); Q_SCRIPTABLE void stopSession();
Q_SCRIPTABLE void resetWaydroid(); Q_SCRIPTABLE void resetWaydroid();
Q_SCRIPTABLE void installApk(const QString apkFile); Q_SCRIPTABLE void installApk(const QString apkFile);
Q_SCRIPTABLE void launchApplication(const QString appId);
Q_SCRIPTABLE void deleteApplication(const QString appId); Q_SCRIPTABLE void deleteApplication(const QString appId);
Q_SCRIPTABLE void refreshSupportsInfo();
Q_SCRIPTABLE void refreshSessionInfo(); Q_SCRIPTABLE void refreshSessionInfo();
Q_SCRIPTABLE void refreshAndroidId(); Q_SCRIPTABLE void refreshAndroidId();
Q_SCRIPTABLE void refreshApplications(); Q_SCRIPTABLE void refreshApplications();
@ -143,8 +151,9 @@ private:
bool m_multiWindows{false}; bool m_multiWindows{false};
bool m_suspend{false}; bool m_suspend{false};
bool m_uevent{false}; bool m_uevent{false};
QString m_fakeTouch;
QString m_fakeWifi;
void refreshSupportsInfo();
void refreshInstallationInfo(); void refreshInstallationInfo();
QCoro::Task<void> refreshPropsInfo(); QCoro::Task<void> refreshPropsInfo();

View file

@ -76,6 +76,14 @@ ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES
PATH settings PATH settings
) )
ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES
qml/gaming/GameCenterOverlay.qml
qml/gaming/GamingHUD.qml
qml/gaming/GamingQuickSettings.qml
qml/gaming/RunningGamesView.qml
PATH gaming
)
target_link_libraries(org.kde.plasma.mobile.homescreen.folio PRIVATE target_link_libraries(org.kde.plasma.mobile.homescreen.folio PRIVATE
Qt::Gui Qt::Gui
Qt::Qml Qt::Qml

View file

@ -11,6 +11,7 @@ import org.kde.kirigami as Kirigami
import org.kde.plasma.private.mobileshell as MobileShell import org.kde.plasma.private.mobileshell as MobileShell
import org.kde.plasma.private.mobileshell.state as MobileShellState import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio
import "./delegate" import "./delegate"
@ -47,8 +48,11 @@ MobileShell.GridView {
// HACK: the first swipe from the top of the app drawer is done from HomeScreenState, not the flickable // HACK: the first swipe from the top of the app drawer is done from HomeScreenState, not the flickable
// due to issues with Flickable getting its swipe stolen by SwipeArea // due to issues with Flickable getting its swipe stolen by SwipeArea
interactive: (dragging || !atYBeginning) // allow us to drag to the top // In convergence mode the drawer is a popup opened by a button no swipe-open gesture,
&& folio.HomeScreenState.swipeState !== Folio.HomeScreenState.SwipingAppDrawerGrid // so the grid must always be scrollable via mouse wheel/drag.
interactive: ShellSettings.Settings.convergenceModeEnabled
|| ((dragging || !atYBeginning) // allow us to drag to the top
&& folio.HomeScreenState.swipeState !== Folio.HomeScreenState.SwipingAppDrawerGrid)
Connections { Connections {
target: folio.HomeScreenState target: folio.HomeScreenState

View file

@ -15,8 +15,9 @@ Item {
id: root id: root
property Folio.HomeScreen folio property Folio.HomeScreen folio
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary // Do not override the colorset: in mobile mode we inherit Complementary
Kirigami.Theme.inherit: false // from the containment (wallpaper context, white text); in convergence mode
// the drawerOverlay Window gives us Window context (system-adaptive).
function addSearchText(text: string) { function addSearchText(text: string) {
searchField.text += text; searchField.text += text;
@ -57,22 +58,21 @@ Item {
background: Rectangle { background: Rectangle {
radius: Kirigami.Units.cornerRadius radius: Kirigami.Units.cornerRadius
color: Qt.rgba(255, 255, 255, (searchField.hovered || searchField.focus) ? 0.2 : 0.1) color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
Kirigami.Theme.textColor.b,
(searchField.hovered || searchField.focus) ? 0.2 : 0.1)
Behavior on color { ColorAnimation {} } Behavior on color { ColorAnimation {} }
} }
Kirigami.Theme.inherit: false
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
topPadding: Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing topPadding: Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing
bottomPadding: Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing bottomPadding: Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing
Layout.fillWidth: true Layout.fillWidth: true
horizontalAlignment: QQC2.TextField.AlignHCenter horizontalAlignment: QQC2.TextField.AlignHCenter
placeholderText: i18nc("@info:placeholder", "Search applications…") placeholderText: i18nc("@info:placeholder", "Search applications…")
placeholderTextColor: Qt.rgba(255, 255, 255, 0.8) placeholderTextColor: Kirigami.Theme.disabledTextColor
color: 'white' color: Kirigami.Theme.textColor
font.weight: Font.Bold font.weight: Font.Bold

View file

@ -125,20 +125,41 @@ MouseArea {
Rectangle { Rectangle {
id: homeButton id: homeButton
visible: root.convergenceMode visible: root.convergenceMode
activeFocusOnTab: root.convergenceMode
anchors.left: parent.left anchors.left: parent.left
anchors.top: parent.top anchors.top: parent.top
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
width: root.navButtonWidth width: root.navButtonWidth
color: homeMouseArea.containsPress color: homeMouseArea.containsPress
? Qt.rgba(1, 1, 1, 0.2) ? Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.2)
: (homeMouseArea.containsMouse ? Qt.rgba(1, 1, 1, 0.1) : "transparent") : (homeMouseArea.containsMouse ? Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.1) : "transparent")
radius: Kirigami.Units.cornerRadius radius: Kirigami.Units.cornerRadius
Accessible.role: Accessible.Button
Accessible.name: i18n("Home")
Accessible.onPressAction: MobileShellState.ShellDBusClient.openHomeScreen()
Keys.onReturnPressed: MobileShellState.ShellDBusClient.openHomeScreen()
Keys.onEnterPressed: MobileShellState.ShellDBusClient.openHomeScreen()
Keys.onSpacePressed: MobileShellState.ShellDBusClient.openHomeScreen()
Keys.onRightPressed: {
let first = repeater.itemAt(0)
if (first) { first.keyboardFocus(); return }
let firstTask = taskRepeater.itemAt(0)
if (firstTask) { firstTask.forceActiveFocus(); return }
overviewButton.forceActiveFocus()
}
KeyboardHighlight {
anchors.fill: parent
visible: homeButton.activeFocus
}
Kirigami.Icon { Kirigami.Icon {
anchors.centerIn: parent anchors.centerIn: parent
width: Math.min(parent.width, parent.height) * 0.75 width: Math.min(parent.width, parent.height) * 0.75
height: width height: width
source: "start-here-kde" source: "start-here-shift"
active: homeMouseArea.containsMouse active: homeMouseArea.containsMouse
} }
@ -155,15 +176,36 @@ MouseArea {
Rectangle { Rectangle {
id: overviewButton id: overviewButton
visible: root.convergenceMode visible: root.convergenceMode
activeFocusOnTab: root.convergenceMode
anchors.right: parent.right anchors.right: parent.right
anchors.top: parent.top anchors.top: parent.top
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
width: root.navButtonWidth width: root.navButtonWidth
color: overviewMouseArea.containsPress color: overviewMouseArea.containsPress
? Qt.rgba(1, 1, 1, 0.2) ? Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.2)
: (overviewMouseArea.containsMouse ? Qt.rgba(1, 1, 1, 0.1) : "transparent") : (overviewMouseArea.containsMouse ? Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.1) : "transparent")
radius: Kirigami.Units.cornerRadius radius: Kirigami.Units.cornerRadius
Accessible.role: Accessible.Button
Accessible.name: i18n("Overview")
Accessible.onPressAction: root.folio.triggerOverview()
Keys.onReturnPressed: root.folio.triggerOverview()
Keys.onEnterPressed: root.folio.triggerOverview()
Keys.onSpacePressed: root.folio.triggerOverview()
Keys.onLeftPressed: {
let lastTask = taskRepeater.itemAt(taskRepeater.count - 1)
if (lastTask) { lastTask.forceActiveFocus(); return }
let lastFav = repeater.itemAt(repeater.count - 1)
if (lastFav) { lastFav.keyboardFocus(); return }
homeButton.forceActiveFocus()
}
KeyboardHighlight {
anchors.fill: parent
visible: overviewButton.activeFocus
}
Kirigami.Icon { Kirigami.Icon {
anchors.centerIn: parent anchors.centerIn: parent
width: Math.min(parent.width, parent.height) * 0.75 width: Math.min(parent.width, parent.height) * 0.75
@ -320,9 +362,12 @@ MouseArea {
break; break;
case Qt.Key_Left: case Qt.Key_Left:
if (isLocationBottom) { if (isLocationBottom) {
let nextDelegate = repeater.itemAt(delegate.index - 1); let prevDelegate = repeater.itemAt(delegate.index - 1);
if (nextDelegate) { if (prevDelegate) {
nextDelegate.keyboardFocus(); prevDelegate.keyboardFocus();
event.accepted = true;
} else if (root.convergenceMode) {
homeButton.forceActiveFocus();
event.accepted = true; event.accepted = true;
} }
} }
@ -333,6 +378,14 @@ MouseArea {
if (nextDelegate) { if (nextDelegate) {
nextDelegate.keyboardFocus(); nextDelegate.keyboardFocus();
event.accepted = true; event.accepted = true;
} else if (root.convergenceMode) {
let firstTask = taskRepeater.itemAt(0);
if (firstTask) {
firstTask.forceActiveFocus();
} else {
overviewButton.forceActiveFocus();
}
event.accepted = true;
} }
} }
break; break;
@ -599,7 +652,7 @@ MouseArea {
id: thumbnailShowTimer id: thumbnailShowTimer
interval: Kirigami.Units.toolTipDelay interval: Kirigami.Units.toolTipDelay
onTriggered: { onTriggered: {
thumbnailPopup.visible = true thumbnailPopup.showing = true
} }
} }
@ -607,7 +660,7 @@ MouseArea {
id: thumbnailHideTimer id: thumbnailHideTimer
interval: 300 interval: 300
onTriggered: { onTriggered: {
thumbnailPopup.visible = false thumbnailPopup.showing = false
root.hoveredTaskIndex = -1 root.hoveredTaskIndex = -1
} }
} }
@ -620,10 +673,21 @@ MouseArea {
property var windowIds: [] property var windowIds: []
property bool isGroup: false property bool isGroup: false
property bool popupHovered: false property bool popupHovered: false
property bool showing: false
function open() { visible = true } function open() { showing = true }
function close() { visible = false } function close() { showing = false }
readonly property bool opened: visible readonly property bool opened: showing
visible: showing || fadeAnim.running
opacity: showing ? 1 : 0
Behavior on opacity {
NumberAnimation {
id: fadeAnim
duration: Kirigami.Units.shortDuration
easing.type: Easing.InOutQuad
}
}
flags: Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus flags: Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
color: "transparent" color: "transparent"
@ -642,16 +706,24 @@ MouseArea {
x: { x: {
if (!targetDelegate) return 0 if (!targetDelegate) return 0
var delegateGlobal = targetDelegate.mapToGlobal(0, 0) var delegateGlobal = targetDelegate.mapToGlobal(0, 0)
return Math.max(0, Math.min(Screen.width - width, delegateGlobal.x + (targetDelegate.width - width) / 2)) var win = targetDelegate.Window.window
var screenLeft = win && win.screen ? win.screen.virtualX : 0
var screenRight = screenLeft + (win && win.screen ? win.screen.width : Screen.width)
var centered = delegateGlobal.x + (targetDelegate.width - width) / 2
return Math.max(screenLeft, Math.min(screenRight - width, centered))
} }
y: { y: {
if (!targetDelegate) return 0 if (!targetDelegate) return 0
var delegateGlobal = targetDelegate.mapToGlobal(0, 0) var delegateGlobal = targetDelegate.mapToGlobal(0, 0)
return Math.max(0, Math.min(Screen.height - height, delegateGlobal.y - height - Kirigami.Units.smallSpacing)) var win = targetDelegate.Window.window
var screenTop = win && win.screen ? win.screen.virtualY : 0
var screenBottom = screenTop + (win && win.screen ? win.screen.height : Screen.height)
var above = delegateGlobal.y - height - Kirigami.Units.smallSpacing
return Math.max(screenTop, Math.min(screenBottom - height, above))
} }
onVisibleChanged: { onShowingChanged: {
if (!visible) { if (!showing && !fadeAnim.running) {
windowIds = [] windowIds = []
targetDelegate = null targetDelegate = null
taskIndex = -1 taskIndex = -1
@ -659,6 +731,18 @@ MouseArea {
} }
} }
Connections {
target: fadeAnim
function onRunningChanged() {
if (!fadeAnim.running && !thumbnailPopup.showing) {
thumbnailPopup.windowIds = []
thumbnailPopup.targetDelegate = null
thumbnailPopup.taskIndex = -1
thumbnailPopup.isGroup = false
}
}
}
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
color: Kirigami.Theme.backgroundColor color: Kirigami.Theme.backgroundColor
@ -829,9 +913,49 @@ MouseArea {
required property int index required property int index
required property var model required property var model
activeFocusOnTab: root.convergenceMode
readonly property bool isLocationBottom: folio.HomeScreenState.favouritesBarLocation === Folio.HomeScreenState.Bottom readonly property bool isLocationBottom: folio.HomeScreenState.favouritesBarLocation === Folio.HomeScreenState.Bottom
readonly property string taskStorageId: root.runningTaskStorageId(taskDelegate.model) readonly property string taskStorageId: root.runningTaskStorageId(taskDelegate.model)
Accessible.role: Accessible.Button
Accessible.name: taskDelegate.model.display || ""
Accessible.onPressAction: taskDelegate.activateTask()
function activateTask() {
var winIds = taskDelegate.model.WinIdList
if (winIds && winIds.length > 1) {
if (thumbnailPopup.opened && thumbnailPopup.taskIndex === taskDelegate.index) {
thumbnailPopup.close()
} else {
thumbnailPopup.targetDelegate = taskDelegate
thumbnailPopup.taskIndex = taskDelegate.index
thumbnailPopup.windowIds = winIds
thumbnailPopup.isGroup = taskDelegate.model.IsGroupParent === true
thumbnailPopup.open()
}
} else {
thumbnailPopup.close()
tasksModel.requestActivate(tasksModel.makeModelIndex(taskDelegate.index))
}
}
Keys.onReturnPressed: taskDelegate.activateTask()
Keys.onEnterPressed: taskDelegate.activateTask()
Keys.onSpacePressed: taskDelegate.activateTask()
Keys.onLeftPressed: {
let prev = taskRepeater.itemAt(taskDelegate.index - 1)
if (prev) { prev.forceActiveFocus(); return }
let lastFav = repeater.itemAt(repeater.count - 1)
if (lastFav) { lastFav.keyboardFocus(); return }
homeButton.forceActiveFocus()
}
Keys.onRightPressed: {
let next = taskRepeater.itemAt(taskDelegate.index + 1)
if (next) { next.forceActiveFocus(); return }
overviewButton.forceActiveFocus()
}
// Position after all favourites // Position after all favourites
property double fromCenterValue: (repeater.count + taskDelegate.index) - (root.totalItemCount / 2) property double fromCenterValue: (repeater.count + taskDelegate.index) - (root.totalItemCount / 2)
Behavior on fromCenterValue { Behavior on fromCenterValue {
@ -854,8 +978,13 @@ MouseArea {
anchors.fill: parent anchors.fill: parent
radius: Kirigami.Units.cornerRadius radius: Kirigami.Units.cornerRadius
color: taskMouseArea.containsPress color: taskMouseArea.containsPress
? Qt.rgba(1.0, 1.0, 1.0, 0.2) ? Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.2)
: (taskMouseArea.containsMouse ? Qt.rgba(1.0, 1.0, 1.0, 0.1) : "transparent") : (taskMouseArea.containsMouse ? Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.1) : "transparent")
}
KeyboardHighlight {
anchors.fill: parent
visible: taskDelegate.activeFocus
} }
// Task icon // Task icon
@ -947,22 +1076,7 @@ MouseArea {
thumbnailShowTimer.stop() thumbnailShowTimer.stop()
taskContextMenu.open(); taskContextMenu.open();
} else { } else {
var winIds = taskDelegate.model.WinIdList taskDelegate.activateTask()
if (winIds && winIds.length > 1) {
// Multiple windows: toggle thumbnail popup
if (thumbnailPopup.opened && thumbnailPopup.taskIndex === taskDelegate.index) {
thumbnailPopup.close()
} else {
thumbnailPopup.targetDelegate = taskDelegate
thumbnailPopup.taskIndex = taskDelegate.index
thumbnailPopup.windowIds = winIds
thumbnailPopup.isGroup = taskDelegate.model.IsGroupParent === true
thumbnailPopup.open()
}
} else {
thumbnailPopup.close()
tasksModel.requestActivate(tasksModel.makeModelIndex(taskDelegate.index));
}
} }
} }
onContainsMouseChanged: { onContainsMouseChanged: {

View file

@ -126,7 +126,7 @@ Folio.DelegateTouchArea {
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
radius: Kirigami.Units.cornerRadius radius: Kirigami.Units.cornerRadius
color: Qt.rgba(1.0, 1.0, 1.0, 0.1) color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.1)
visible: ShellSettings.Settings.convergenceModeEnabled && root.hovered visible: ShellSettings.Settings.convergenceModeEnabled && root.hovered
} }
} }
@ -142,7 +142,7 @@ Folio.DelegateTouchArea {
Layout.rightMargin: -parent.anchors.rightMargin + Kirigami.Units.smallSpacing Layout.rightMargin: -parent.anchors.rightMargin + Kirigami.Units.smallSpacing
text: root.name text: root.name
color: "white" color: ShellSettings.Settings.convergenceModeEnabled ? Kirigami.Theme.textColor : "white"
} }
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,216 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtQuick.Window
import org.kde.kirigami as Kirigami
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell
import org.kde.layershell 1.0 as LayerShell
Window {
id: root
signal openRequested()
// Guard against startup timing where Kirigami units may briefly be 0/NaN.
// LayerShell surfaces must never be committed with zero size.
readonly property real safeGridUnit: ((Kirigami.Units.gridUnit || 0) > 0) ? Kirigami.Units.gridUnit : 16
property string toastMessage: ""
property bool toastError: false
readonly property bool toastActive: toastMessage.length > 0
// Most-recently-played game for quick resume. Populated from recentGames(1)
// and refreshed whenever the recent list changes.
property var quickResumeGame: null
readonly property bool hasQuickResume: quickResumeGame !== null
// Window grows leftward from top-right anchor:
// toast active widest (needs room for message text)
// quick resume medium (game name + controls)
// idle compact (controls only)
width: toastActive ? safeGridUnit * 16 : (hasQuickResume ? safeGridUnit * 14 : safeGridUnit * 4)
height: toastActive ? safeGridUnit * 4 : safeGridUnit * 2
color: "transparent"
flags: Qt.FramelessWindowHint
Behavior on width {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
Behavior on height {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
LayerShell.Window.scope: "gaming-hud"
LayerShell.Window.layer: LayerShell.Window.LayerOverlay
LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorRight
LayerShell.Window.exclusionZone: 0
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
// Driven by the Loader in folio/qml/main.qml set false to fade out
// before the Loader destroys the window.
property bool showing: true
opacity: showing ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad }
}
Timer {
id: toastTimer
onTriggered: root.toastMessage = ""
}
Connections {
target: GamingShell.GameLauncherProvider
function onGameLaunched(name) {
root.toastMessage = i18n("Launching %1", name)
root.toastError = false
toastTimer.interval = 3000
toastTimer.restart()
}
function onGameLaunchFailed(name, error) {
root.toastMessage = error.length > 0 ? error : i18n("Failed to launch %1", name)
root.toastError = true
toastTimer.interval = 5000
toastTimer.restart()
}
function onRecentGamesChanged() {
const recent = GamingShell.GameLauncherProvider.recentGames(1)
root.quickResumeGame = recent.length > 0 ? recent[0] : null
}
}
Component.onCompleted: {
const recent = GamingShell.GameLauncherProvider.recentGames(1)
root.quickResumeGame = recent.length > 0 ? recent[0] : null
}
// ---- HUD pill (always visible, fills window width, grows leftward) ----
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Kirigami.Units.smallSpacing
height: safeGridUnit * 2 - Kirigami.Units.smallSpacing * 2
radius: height / 2
color: Qt.rgba(0, 0, 0, 0.55)
RowLayout {
anchors {
fill: parent
leftMargin: Kirigami.Units.smallSpacing
rightMargin: Kirigami.Units.smallSpacing
}
spacing: 0
// Quick-resume section only visible when a recent game exists
QQC2.ToolButton {
visible: root.hasQuickResume
icon.name: "media-playback-start"
icon.color: "white"
display: QQC2.AbstractButton.IconOnly
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.text: root.quickResumeGame ? i18n("Resume %1", root.quickResumeGame.name) : ""
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
onClicked: {
if (root.quickResumeGame) {
GamingShell.GameLauncherProvider.launchByStorageId(root.quickResumeGame.storageId)
}
}
}
QQC2.Label {
visible: root.hasQuickResume
Layout.fillWidth: true
text: root.quickResumeGame ? root.quickResumeGame.name : ""
color: "white"
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85
elide: Text.ElideRight
maximumLineCount: 1
leftPadding: Kirigami.Units.smallSpacing
}
// Separator between quick-resume and controls
Rectangle {
visible: root.hasQuickResume
width: 1
implicitHeight: Kirigami.Units.gridUnit
color: Qt.rgba(1, 1, 1, 0.25)
Layout.alignment: Qt.AlignVCenter
Layout.leftMargin: Kirigami.Units.smallSpacing
Layout.rightMargin: Kirigami.Units.smallSpacing
}
// Game Center button
QQC2.ToolButton {
icon.name: "input-gaming"
icon.color: "white"
display: QQC2.AbstractButton.IconOnly
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.text: i18n("Game Center")
onClicked: root.openRequested()
}
// Primary gamepad battery
QQC2.Label {
visible: GamingShell.GamepadManager.hasGamepad
&& GamingShell.GamepadManager.primaryGamepad
&& GamingShell.GamepadManager.primaryGamepad.batteryPercent >= 0
text: GamingShell.GamepadManager.primaryGamepad
? GamingShell.GamepadManager.primaryGamepad.batteryPercent + "%"
: ""
color: "white"
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8
rightPadding: Kirigami.Units.smallSpacing
}
}
}
// ---- toast pill (slides in below HUD pill when active) ----
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Kirigami.Units.smallSpacing
height: safeGridUnit * 2 - Kirigami.Units.smallSpacing * 2
radius: height / 2
color: root.toastError ? Qt.rgba(0.75, 0.1, 0.05, 0.9) : Qt.rgba(0, 0, 0, 0.55)
opacity: root.toastActive ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad }
}
RowLayout {
anchors {
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
leftMargin: Kirigami.Units.largeSpacing
rightMargin: Kirigami.Units.largeSpacing
}
spacing: Kirigami.Units.smallSpacing
Kirigami.Icon {
source: root.toastError ? "dialog-warning" : "media-playback-start"
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
Layout.alignment: Qt.AlignVCenter
}
QQC2.Label {
Layout.fillWidth: true
text: root.toastMessage
color: "white"
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85
elide: Text.ElideRight
maximumLineCount: 1
}
}
}
}

View file

@ -0,0 +1,692 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import org.kde.kirigami as Kirigami
import org.kde.plasma.components 3.0 as PC3
import org.kde.plasma.private.mobileshell as MobileShell
import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import org.kde.plasma.private.mobileshell.screenbrightnessplugin as ScreenBrightness
import org.kde.plasma.private.volume
import org.kde.plasma.networkmanagement as PlasmaNM
import org.kde.bluezqt 1.0 as BluezQt
import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.plasma.quicksetting.nightcolor as NightColor
Item {
id: root
anchors.fill: parent
property bool opened: false
readonly property string acceptButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonA)
readonly property string closeButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonB)
function pulsePrimaryGamepad(lowIntensity, highIntensity, durationMs) {
var pad = GamingShell.GamepadManager.primaryGamepad
if (!pad || !pad.hasRumble) {
return
}
pad.rumble(lowIntensity, highIntensity, durationMs)
}
// Focusable controls for gamepad navigation
property var _controls: []
property int _focusIndex: 0
function _buildControlsList() {
var list = []
if (GamingShell.PowerProfileControl.available && performanceSection._availableProfiles.length > 0) list.push(profileRow)
if (screenBrightness.brightnessAvailable) list.push(brightnessSlider)
if (PreferredDevice.sink) list.push(volumeSlider)
list.push(dndSwitch)
list.push(launchHintSwitch)
list.push(nightColorSwitch)
list.push(overlaySwitch)
list.push(wifiSwitch)
list.push(btSwitch)
list.push(airplaneSwitch)
_controls = list
}
function open() {
opened = true
_buildControlsList()
_focusIndex = Math.max(0, Math.min(_focusIndex, _controls.length - 1))
_highlightCurrent()
pulsePrimaryGamepad(7000, 11000, 40)
}
function close() {
opened = false
pulsePrimaryGamepad(5000, 8000, 30)
}
function toggle() {
if (opened) close(); else open()
}
function _highlightCurrent() {
if (_controls.length > 0 && _focusIndex >= 0 && _focusIndex < _controls.length) {
_controls[_focusIndex].forceActiveFocus()
}
}
// Gamepad input handlers called from GameCenterOverlay
function gamepadUp() {
if (_focusIndex > 0) {
_focusIndex--
_highlightCurrent()
}
}
function gamepadDown() {
if (_focusIndex < _controls.length - 1) {
_focusIndex++
_highlightCurrent()
}
}
function gamepadLeft() {
var ctrl = _controls[_focusIndex]
if (typeof ctrl.decrease === "function") {
ctrl.decrease()
if (typeof ctrl.moved === "function") ctrl.moved()
}
}
function gamepadRight() {
var ctrl = _controls[_focusIndex]
if (typeof ctrl.increase === "function") {
ctrl.increase()
if (typeof ctrl.moved === "function") ctrl.moved()
}
}
function gamepadAccept() {
var ctrl = _controls[_focusIndex]
if (ctrl === profileRow) {
ctrl.increase()
pulsePrimaryGamepad(6000, 9000, 35)
return
}
if (ctrl instanceof QQC2.Switch) {
ctrl.toggle()
ctrl.toggled()
pulsePrimaryGamepad(6000, 9000, 35)
}
}
onOpenedChanged: {
if (opened) {
_buildControlsList()
_focusIndex = Math.max(0, Math.min(_focusIndex, _controls.length - 1))
_highlightCurrent()
}
}
// Eat clicks on the dimmed backdrop
MouseArea {
anchors.fill: parent
visible: root.opened
onClicked: root.close()
}
// Dim backdrop
Rectangle {
anchors.fill: parent
color: "black"
opacity: root.opened ? 0.4 : 0
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad }
}
}
// Panel sliding in from the right
Rectangle {
id: panel
width: Math.min(root.width * 0.35, Kirigami.Units.gridUnit * 22)
height: root.height
anchors.top: root.top
anchors.bottom: root.bottom
x: root.opened ? root.width - width : root.width
Behavior on x {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad }
}
Kirigami.Theme.inherit: false
Kirigami.Theme.colorSet: Kirigami.Theme.Window
color: Qt.rgba(Kirigami.Theme.backgroundColor.r,
Kirigami.Theme.backgroundColor.g,
Kirigami.Theme.backgroundColor.b, 0.96)
// Subtle left border
Rectangle {
width: 1
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.left: parent.left
color: Kirigami.Theme.disabledTextColor
opacity: 0.3
}
ScreenBrightness.ScreenBrightnessUtil {
id: screenBrightness
}
PlasmaNM.Handler {
id: nmHandler
}
PlasmaNM.EnabledConnections {
id: enabledConnections
}
Flickable {
anchors.fill: parent
anchors.margins: Kirigami.Units.largeSpacing * 2
contentHeight: settingsColumn.implicitHeight
clip: true
ColumnLayout {
id: settingsColumn
width: parent.width
spacing: Kirigami.Units.largeSpacing * 2
// ---- Header ----
RowLayout {
Layout.fillWidth: true
Kirigami.Heading {
text: i18n("Quick Settings")
level: 2
Layout.fillWidth: true
}
QQC2.ToolButton {
icon.name: "window-close-symbolic"
onClicked: root.close()
}
}
Kirigami.Separator { Layout.fillWidth: true }
// ---- Performance Profile ----
ColumnLayout {
id: performanceSection
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
visible: GamingShell.PowerProfileControl.available
&& _availableProfiles.length > 0
PC3.Label {
text: i18n("Performance")
font.bold: true
}
// Ordered low-to-high so gamepad left=slower, right=faster
readonly property var _profileOrder: ["power-saver", "balanced", "performance"]
readonly property var _availableProfiles: {
var ordered = []
for (var i = 0; i < _profileOrder.length; i++) {
if (GamingShell.PowerProfileControl.profiles.indexOf(_profileOrder[i]) >= 0) {
ordered.push(_profileOrder[i])
}
}
return ordered
}
Item {
id: profileRow
focus: true
Layout.fillWidth: true
Layout.preferredHeight: profileButtons.implicitHeight
function decrease() {
var profiles = parent._availableProfiles
var idx = profiles.indexOf(GamingShell.PowerProfileControl.activeProfile)
if (idx > 0) {
GamingShell.PowerProfileControl.activeProfile = profiles[idx - 1]
}
}
function increase() {
var profiles = parent._availableProfiles
var idx = profiles.indexOf(GamingShell.PowerProfileControl.activeProfile)
if (idx >= 0 && idx < profiles.length - 1) {
GamingShell.PowerProfileControl.activeProfile = profiles[idx + 1]
}
}
Rectangle {
anchors.fill: parent
anchors.margins: -Kirigami.Units.smallSpacing
radius: Kirigami.Units.smallSpacing
color: "transparent"
border.color: Kirigami.Theme.highlightColor
border.width: parent.activeFocus ? 2 : 0
}
RowLayout {
id: profileButtons
anchors.left: parent.left
anchors.right: parent.right
spacing: Kirigami.Units.smallSpacing
Repeater {
model: performanceSection._availableProfiles
QQC2.Button {
Layout.fillWidth: true
text: {
switch (modelData) {
case "performance": return i18n("Performance")
case "balanced": return i18n("Balanced")
case "power-saver": return i18n("Power Saver")
default: return modelData
}
}
icon.name: {
switch (modelData) {
case "performance": return "speedometer"
case "balanced": return "system-suspend-hibernate"
case "power-saver": return "battery-profile-powersave"
default: return ""
}
}
highlighted: GamingShell.PowerProfileControl.activeProfile === modelData
onClicked: GamingShell.PowerProfileControl.activeProfile = modelData
}
}
}
}
}
// ---- Brightness ----
ColumnLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
visible: screenBrightness.brightnessAvailable
PC3.Label {
text: i18n("Brightness")
font.bold: true
}
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
Kirigami.Icon {
implicitWidth: Kirigami.Units.iconSizes.smallMedium
implicitHeight: Kirigami.Units.iconSizes.smallMedium
source: "low-brightness"
}
PC3.Slider {
id: brightnessSlider
Layout.fillWidth: true
from: 1
to: screenBrightness.maxBrightness
stepSize: Math.max(1, Math.round(screenBrightness.maxBrightness / 20))
value: screenBrightness.brightness
onMoved: screenBrightness.brightness = value
Timer {
interval: 0
running: true
repeat: false
onTriggered: brightnessSlider.value = Qt.binding(() => screenBrightness.brightness)
}
// Keep Plasma/Kirigami colors while using a cleaner rounded style.
background: Rectangle {
x: brightnessSlider.leftPadding
y: brightnessSlider.topPadding + brightnessSlider.availableHeight / 2 - height / 2
width: brightnessSlider.availableWidth
height: Kirigami.Units.smallSpacing + 2
radius: height / 2
color: Kirigami.Theme.alternateBackgroundColor
Rectangle {
width: parent.width * brightnessSlider.visualPosition
height: parent.height
radius: parent.radius
color: Kirigami.Theme.highlightColor
}
Rectangle {
anchors.fill: parent
radius: parent.radius
color: "transparent"
border.color: Kirigami.Theme.highlightColor
border.width: brightnessSlider.activeFocus ? 1 : 0
}
}
handle: Rectangle {
x: brightnessSlider.leftPadding + brightnessSlider.visualPosition * (brightnessSlider.availableWidth - width)
y: brightnessSlider.topPadding + brightnessSlider.availableHeight / 2 - height / 2
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
radius: width / 2
color: Kirigami.Theme.backgroundColor
border.color: brightnessSlider.pressed
? Kirigami.Theme.highlightColor
: Kirigami.Theme.disabledTextColor
border.width: brightnessSlider.activeFocus || brightnessSlider.pressed ? 2 : 1
}
}
Kirigami.Icon {
implicitWidth: Kirigami.Units.iconSizes.smallMedium
implicitHeight: Kirigami.Units.iconSizes.smallMedium
source: "high-brightness"
}
}
}
// ---- Volume ----
ColumnLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
visible: PreferredDevice.sink != null
PC3.Label {
text: i18n("Volume")
font.bold: true
}
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
Kirigami.Icon {
implicitWidth: Kirigami.Units.iconSizes.smallMedium
implicitHeight: Kirigami.Units.iconSizes.smallMedium
source: "audio-volume-low"
}
PC3.Slider {
id: volumeSlider
Layout.fillWidth: true
from: PulseAudio.MinimalVolume
to: PulseAudio.NormalVolume
stepSize: PulseAudio.NormalVolume / 20
value: PreferredDevice.sink ? PreferredDevice.sink.volume : 0
onMoved: {
if (PreferredDevice.sink) {
PreferredDevice.sink.volume = value
PreferredDevice.sink.muted = (value === 0)
}
}
// Keep Plasma/Kirigami colors while using a cleaner rounded style.
background: Rectangle {
x: volumeSlider.leftPadding
y: volumeSlider.topPadding + volumeSlider.availableHeight / 2 - height / 2
width: volumeSlider.availableWidth
height: Kirigami.Units.smallSpacing + 2
radius: height / 2
color: Kirigami.Theme.alternateBackgroundColor
Rectangle {
width: parent.width * volumeSlider.visualPosition
height: parent.height
radius: parent.radius
color: Kirigami.Theme.highlightColor
}
Rectangle {
anchors.fill: parent
radius: parent.radius
color: "transparent"
border.color: Kirigami.Theme.highlightColor
border.width: volumeSlider.activeFocus ? 1 : 0
}
}
handle: Rectangle {
x: volumeSlider.leftPadding + volumeSlider.visualPosition * (volumeSlider.availableWidth - width)
y: volumeSlider.topPadding + volumeSlider.availableHeight / 2 - height / 2
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
radius: width / 2
color: Kirigami.Theme.backgroundColor
border.color: volumeSlider.pressed
? Kirigami.Theme.highlightColor
: Kirigami.Theme.disabledTextColor
border.width: volumeSlider.activeFocus || volumeSlider.pressed ? 2 : 1
}
}
Kirigami.Icon {
implicitWidth: Kirigami.Units.iconSizes.smallMedium
implicitHeight: Kirigami.Units.iconSizes.smallMedium
source: "audio-volume-high"
}
}
}
Kirigami.Separator { Layout.fillWidth: true }
// ---- Gaming Tweaks ----
PC3.Label {
text: i18n("Gaming")
font.bold: true
}
GridLayout {
Layout.fillWidth: true
columns: 2
rowSpacing: Kirigami.Units.smallSpacing
columnSpacing: Kirigami.Units.largeSpacing
QQC2.Switch {
id: dndSwitch
text: i18n("Do Not Disturb")
checked: MobileShellState.ShellDBusClient.doNotDisturb
onToggled: MobileShellState.ShellDBusClient.doNotDisturb = checked
}
QQC2.Switch {
id: launchHintSwitch
text: i18n("Launch Hint")
checked: ShellSettings.Settings.gamingDismissHintEnabled
onToggled: ShellSettings.Settings.gamingDismissHintEnabled = checked
}
QQC2.Switch {
id: nightColorSwitch
text: i18n("Night Color")
checked: NightColor.NightColorUtil.enabled
onToggled: NightColor.NightColorUtil.enabled = checked
}
QQC2.Switch {
id: overlaySwitch
text: i18n("Perf Overlay")
checked: GamingShell.GameLauncherProvider.overlayEnabled
enabled: GamingShell.GameLauncherProvider.mangohudAvailable
opacity: enabled ? 1.0 : 0.5
onToggled: GamingShell.GameLauncherProvider.overlayEnabled = checked
QQC2.ToolTip.visible: !GamingShell.GameLauncherProvider.mangohudAvailable && hovered
QQC2.ToolTip.text: i18n("MangoHud is not installed")
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
// FPS cap spans both columns, driven by MangoHud fps_limit
QQC2.ButtonGroup { id: fpsCap; exclusive: true }
RowLayout {
Layout.columnSpan: 2
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
enabled: GamingShell.GameLauncherProvider.mangohudAvailable
opacity: enabled ? 1.0 : 0.5
PC3.Label { text: i18n("FPS Cap") }
Item { Layout.fillWidth: true }
Repeater {
model: [
{ label: i18nc("@action:button FPS cap off", "Off"), fps: 0 },
{ label: "30", fps: 30 },
{ label: "40", fps: 40 },
{ label: "60", fps: 60 }
]
delegate: QQC2.Button {
required property var modelData
text: modelData.label
flat: true
checkable: true
checked: GamingShell.GameLauncherProvider.fpsLimit === modelData.fps
QQC2.ButtonGroup.group: fpsCap
onClicked: GamingShell.GameLauncherProvider.fpsLimit = modelData.fps
}
}
}
// GameMode status (auto-managed, read-only indicator)
RowLayout {
spacing: Kirigami.Units.smallSpacing
visible: GamingShell.GameModeControl.available
Kirigami.Icon {
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
source: "games-achievements"
}
PC3.Label {
text: GamingShell.GameModeControl.active
? i18n("GameMode requested")
: i18n("GameMode not requested")
opacity: 0.7
}
}
}
Kirigami.Separator { Layout.fillWidth: true }
// ---- Connectivity toggles ----
PC3.Label {
text: i18n("Connectivity")
font.bold: true
}
GridLayout {
Layout.fillWidth: true
columns: 2
rowSpacing: Kirigami.Units.smallSpacing
columnSpacing: Kirigami.Units.largeSpacing
// WiFi toggle
QQC2.Switch {
id: wifiSwitch
text: i18n("Wi-Fi")
checked: enabledConnections.wirelessEnabled
onToggled: nmHandler.enableWireless(checked)
}
// Bluetooth toggle
QQC2.Switch {
id: btSwitch
text: i18n("Bluetooth")
checked: !BluezQt.Manager.bluetoothBlocked
onToggled: BluezQt.Manager.bluetoothBlocked = !checked
}
// Airplane mode
QQC2.Switch {
id: airplaneSwitch
text: i18n("Airplane Mode")
checked: PlasmaNM.Configuration.airplaneModeEnabled
onToggled: {
nmHandler.enableAirplaneMode(!PlasmaNM.Configuration.airplaneModeEnabled)
PlasmaNM.Configuration.airplaneModeEnabled = !PlasmaNM.Configuration.airplaneModeEnabled
}
}
}
Kirigami.Separator { Layout.fillWidth: true }
// ---- Battery info ----
ColumnLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
visible: MobileShell.BatteryInfo.isVisible
PC3.Label {
text: i18n("Battery")
font.bold: true
}
RowLayout {
spacing: Kirigami.Units.smallSpacing
MobileShell.BatteryIndicator {
textPixelSize: Kirigami.Units.gridUnit * 0.7
}
PC3.Label {
text: MobileShell.BatteryInfo.pluggedIn ? i18n("Charging") : ""
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85
opacity: 0.7
}
}
}
// ---- Controller info ----
ColumnLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
visible: GamingShell.GamepadManager.hasGamepad
PC3.Label {
text: i18n("Controllers")
font.bold: true
}
Repeater {
model: GamingShell.GamepadManager
RowLayout {
spacing: Kirigami.Units.smallSpacing
required property string name
required property int battery
required property string type
Kirigami.Icon {
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
source: "input-gaming"
}
PC3.Label {
text: name
}
PC3.Label {
text: battery >= 0 ? battery + "%" : i18n("Wired")
opacity: 0.7
}
}
}
}
Kirigami.Separator { Layout.fillWidth: true }
// ---- Gamepad legend ----
PC3.Label {
Layout.fillWidth: true
text: i18n("↕: Navigate ↔: Adjust %1: Toggle %2: Close",
acceptButtonLabel, closeButtonLabel)
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8
opacity: 0.5
horizontalAlignment: Text.AlignHCenter
}
Item { Layout.fillHeight: true }
}
}
}
}

View file

@ -0,0 +1,208 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import org.kde.kirigami as Kirigami
import org.kde.plasma.components 3.0 as PC3
import org.kde.taskmanager as TaskManager
import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio
Item {
id: root
implicitHeight: taskList.count > 0 ? column.implicitHeight : 0
readonly property bool hasTasks: taskList.count > 0
readonly property int taskCount: taskList.count
signal taskActivated()
signal moveDownRequested()
function focusFirstTask() {
if (!hasTasks) {
return;
}
taskList.currentIndex = Math.max(0, taskList.currentIndex)
taskList.positionViewAtIndex(taskList.currentIndex, ListView.Visible)
taskList.forceActiveFocus()
}
function activateCurrent() {
if (taskList.currentItem) {
taskList.currentItem.activate()
}
}
function closeCurrent() {
if (taskList.currentItem) {
var idx = taskList.currentIndex
tasks.requestClose(tasks.makeModelIndex(idx))
}
}
TaskManager.VirtualDesktopInfo { id: vdInfo }
TaskManager.ActivityInfo { id: actInfo }
TaskManager.TasksModel {
id: tasks
filterByVirtualDesktop: true
filterByActivity: true
filterNotMaximized: false
filterByScreen: true
filterHidden: false
virtualDesktop: vdInfo.currentDesktop
activity: actInfo.currentActivity
groupMode: TaskManager.TasksModel.GroupApplications
}
Behavior on implicitHeight {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad }
}
ColumnLayout {
id: column
anchors.left: parent.left
anchors.right: parent.right
spacing: Kirigami.Units.smallSpacing
visible: taskList.count > 0
Kirigami.Heading {
level: 2
text: i18n("Running")
}
ListView {
id: taskList
Layout.fillWidth: true
implicitHeight: contentHeight
model: tasks
orientation: ListView.Horizontal
spacing: Kirigami.Units.largeSpacing
clip: true
keyNavigationEnabled: true
currentIndex: 0
onActiveFocusChanged: {
if (activeFocus && count > 0 && currentIndex < 0) {
currentIndex = 0
}
}
Keys.onLeftPressed: {
if (count <= 0) {
return
}
currentIndex = Math.max(0, currentIndex - 1)
positionViewAtIndex(currentIndex, ListView.Contain)
}
Keys.onRightPressed: {
if (count <= 0) {
return
}
currentIndex = Math.min(count - 1, currentIndex + 1)
positionViewAtIndex(currentIndex, ListView.Contain)
}
Keys.onDownPressed: root.moveDownRequested()
Keys.onReturnPressed: currentItem && currentItem.activate()
Keys.onEnterPressed: currentItem && currentItem.activate()
delegate: QQC2.ItemDelegate {
id: taskItem
required property var decoration
required property var winIdList
width: Kirigami.Units.gridUnit * 8
height: Kirigami.Units.gridUnit * 5
readonly property var modelIndex: tasks.makeModelIndex(index)
readonly property bool isCurrent: ListView.isCurrentItem && taskList.activeFocus
readonly property string titleText: typeof model !== "undefined" && model.display ? model.display : ""
function activate() {
tasks.requestActivate(taskItem.modelIndex)
root.taskActivated()
}
onClicked: {
taskList.currentIndex = index
activate()
}
Keys.onReturnPressed: activate()
Keys.onEnterPressed: activate()
Rectangle {
anchors.fill: parent
radius: Kirigami.Units.cornerRadius
color: taskItem.isCurrent
? Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g,
Kirigami.Theme.highlightColor.b, 0.25)
: resumeArea.containsPress
? Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g,
Kirigami.Theme.highlightColor.b, 0.3)
: resumeArea.containsMouse
? Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
Kirigami.Theme.textColor.b, 0.1)
: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
Kirigami.Theme.textColor.b, 0.06)
Behavior on color { ColorAnimation { duration: Kirigami.Units.shortDuration } }
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
Kirigami.Icon {
Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.large
implicitHeight: Kirigami.Units.iconSizes.large
source: taskItem.decoration
}
PC3.Label {
Layout.fillWidth: true
text: taskItem.titleText
maximumLineCount: 1
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85
}
}
// Close button (top-right corner)
QQC2.ToolButton {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Kirigami.Units.smallSpacing / 2
width: Kirigami.Units.iconSizes.small
height: width
icon.name: "window-close-symbolic"
icon.width: Kirigami.Units.iconSizes.small
icon.height: Kirigami.Units.iconSizes.small
display: QQC2.AbstractButton.IconOnly
onClicked: tasks.requestClose(taskItem.modelIndex)
}
MouseArea {
id: resumeArea
anchors.fill: parent
hoverEnabled: true
z: -1
// Leave room for the close button
onClicked: {
taskList.currentIndex = index
taskItem.activate()
}
}
}
}
}
}

View file

@ -16,6 +16,7 @@ import org.kde.plasma.private.mobileshell as MobileShell
import org.kde.plasma.private.mobileshell.state as MobileShellState import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.plasma.private.mobileshell.windowplugin as WindowPlugin import org.kde.plasma.private.mobileshell.windowplugin as WindowPlugin
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell
import org.kde.layershell 1.0 as LayerShell import org.kde.layershell 1.0 as LayerShell
import org.kde.plasma.private.sessions 2.0 import org.kde.plasma.private.sessions 2.0
@ -25,13 +26,84 @@ import org.kde.kirigamiaddons.components as KirigamiAddonsComponents
import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio
import "./gaming"
import "./private" import "./private"
ContainmentItem { ContainmentItem {
id: root id: root
property Folio.HomeScreen folio: root.plasmoid property Folio.HomeScreen folio: root.plasmoid
// Tracks whether the Game Center grid is visible within gaming mode.
// If gaming mode is already enabled at startup, open it immediately so
// the user is never left without controls.
property bool gameCenterOpen: ShellSettings.Settings.gamingModeEnabled
property bool showGameCenterHint: false
// State saved when gaming mode activates, restored when it deactivates
property string _savedPowerProfile: ""
property bool _savedDnd: false
property bool _gamingSessionActive: false
function _applyGamingModeState(enabled) {
root.gameCenterOpen = enabled
GamingShell.GamepadManager.active = enabled
if (enabled === root._gamingSessionActive) {
return
}
if (enabled) {
// Save current state and apply gaming optimizations
root._savedDnd = MobileShellState.ShellDBusClient.doNotDisturb
MobileShellState.ShellDBusClient.doNotDisturb = true
if (GamingShell.PowerProfileControl.available) {
root._savedPowerProfile = GamingShell.PowerProfileControl.activeProfile
GamingShell.PowerProfileControl.activeProfile = "performance"
}
GamingShell.GameModeControl.requestStart()
root._gamingSessionActive = true
} else {
// Restore previous state
MobileShellState.ShellDBusClient.doNotDisturb = root._savedDnd
if (GamingShell.PowerProfileControl.available && root._savedPowerProfile.length > 0) {
GamingShell.PowerProfileControl.activeProfile = root._savedPowerProfile
}
GamingShell.GameModeControl.requestEnd()
root._gamingSessionActive = false
}
}
Timer {
id: gameCenterHintTimer
interval: 2600
onTriggered: root.showGameCenterHint = false
}
Connections {
target: ShellSettings.Settings
function onGamingModeEnabledChanged() {
root._applyGamingModeState(ShellSettings.Settings.gamingModeEnabled)
}
}
// Gamepad Guide button toggles Game Center overlay
Connections {
target: GamingShell.GamepadManager
enabled: ShellSettings.Settings.gamingModeEnabled
function onButtonPressed(button, gamepadIndex) {
if (button === GamingShell.GamepadManager.ButtonGuide) {
root.gameCenterOpen = !root.gameCenterOpen
}
}
}
Component.onCompleted: { Component.onCompleted: {
root._applyGamingModeState(ShellSettings.Settings.gamingModeEnabled)
folio.FolioSettings.load(); folio.FolioSettings.load();
folio.FavouritesModel.load(); folio.FavouritesModel.load();
folio.PageListModel.load(); folio.PageListModel.load();
@ -71,6 +143,18 @@ ContainmentItem {
screenGeometry: Plasmoid.containment.screenGeometry screenGeometry: Plasmoid.containment.screenGeometry
} }
// In gaming mode, reopen Game Center when the last window goes away
// so the user is never stranded on a bare wallpaper.
Connections {
target: windowMaximizedTracker
enabled: ShellSettings.Settings.gamingModeEnabled
function onShowingWindowChanged() {
if (!windowMaximizedTracker.showingWindow && !root.gameCenterOpen) {
root.gameCenterOpen = true
}
}
}
// Close app drawer when a new window appears // Close app drawer when a new window appears
Connections { Connections {
target: WindowPlugin.WindowUtil target: WindowPlugin.WindowUtil
@ -89,6 +173,12 @@ ContainmentItem {
MobileShellState.ShellDBusClient.closeActionDrawer(); MobileShellState.ShellDBusClient.closeActionDrawer();
} }
if (ShellSettings.Settings.gamingModeEnabled) {
// In gaming mode Home/Menu should reopen the Game Center overlay.
root.gameCenterOpen = true;
return;
}
if (ShellSettings.Settings.convergenceModeEnabled) { if (ShellSettings.Settings.convergenceModeEnabled) {
// Convergence: toggle the app drawer as a layer-shell overlay // Convergence: toggle the app drawer as a layer-shell overlay
// without disturbing open windows. // without disturbing open windows.
@ -186,7 +276,7 @@ ContainmentItem {
// task panel containment; this window only provides the visible dock. // task panel containment; this window only provides the visible dock.
Window { Window {
id: dockOverlay id: dockOverlay
visible: ShellSettings.Settings.convergenceModeEnabled visible: ShellSettings.Settings.convergenceModeEnabled && !ShellSettings.Settings.gamingModeEnabled
color: "transparent" color: "transparent"
width: Screen.width width: Screen.width
height: Kirigami.Units.gridUnit * 3 height: Kirigami.Units.gridUnit * 3
@ -263,7 +353,9 @@ ContainmentItem {
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.5) Kirigami.Theme.inherit: false
Kirigami.Theme.colorSet: Kirigami.Theme.Window
color: Kirigami.Theme.backgroundColor
transform: Translate { y: dockOverlay.dockOffset } transform: Translate { y: dockOverlay.dockOffset }
} }
@ -274,6 +366,11 @@ ContainmentItem {
maskManager: root.maskManager maskManager: root.maskManager
homeScreen: folioHomeScreen homeScreen: folioHomeScreen
transform: Translate { y: dockOverlay.dockOffset } transform: Translate { y: dockOverlay.dockOffset }
// Dock is an opaque panel use Window colorset so all content
// (labels, hover highlights, icon tints) follows the system theme
// instead of the containment's Complementary wallpaper context.
Kirigami.Theme.inherit: false
Kirigami.Theme.colorSet: Kirigami.Theme.Window
} }
} }
@ -284,6 +381,7 @@ ContainmentItem {
Window { Window {
id: drawerOverlay id: drawerOverlay
visible: ShellSettings.Settings.convergenceModeEnabled visible: ShellSettings.Settings.convergenceModeEnabled
&& !ShellSettings.Settings.gamingModeEnabled
&& folio.HomeScreenState.appDrawerOpenProgress > 0 && folio.HomeScreenState.appDrawerOpenProgress > 0
color: "transparent" color: "transparent"
width: Screen.width width: Screen.width
@ -643,6 +741,74 @@ ContainmentItem {
} }
} }
// Game Center overlay full-screen grid of games shown when gaming mode
// is active. Sits at LayerTop so it covers running application windows
// without going above system notifications.
GameCenterOverlay {
id: gameCenterOverlay
folio: root.folio
visible: ShellSettings.Settings.gamingModeEnabled && root.gameCenterOpen
onGameStarted: root.gameCenterOpen = false
onDismissRequested: {
root.gameCenterOpen = false
if (ShellSettings.Settings.gamingDismissHintEnabled) {
root.showGameCenterHint = true
gameCenterHintTimer.restart()
}
}
}
// Small persistent button at the top-right corner of the screen that lets
// the user return to the Game Center after launching a game.
// Keep the Loader active for the full duration of gaming mode so the
// opacity Behavior in GamingHUD can animate both fade-in and fade-out.
//
// Hide the HUD while a game window covers the screen. A mapped LayerShell
// surface prevents KWin from using DRM direct scanout for the fullscreen
// game window. Setting showing=false triggers the opacity fade-out and then
// sets visible=false, which unmaps the Wayland surface and lets KWin bypass
// the compositor render loop entirely for the game frame.
Loader {
active: ShellSettings.Settings.gamingModeEnabled
sourceComponent: GamingHUD {
visible: showing
showing: !root.gameCenterOpen && !windowMaximizedTracker.showingWindow
onOpenRequested: root.gameCenterOpen = true
}
}
Rectangle {
id: gameCenterHint
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: Kirigami.Units.gridUnit * 2
visible: root.showGameCenterHint && ShellSettings.Settings.gamingDismissHintEnabled
opacity: visible ? 1 : 0
z: 2000
radius: Kirigami.Units.cornerRadius
color: Qt.rgba(0, 0, 0, 0.65)
border.width: 1
border.color: Qt.rgba(1, 1, 1, 0.2)
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad }
}
implicitWidth: hintText.implicitWidth + Kirigami.Units.gridUnit * 2
implicitHeight: hintText.implicitHeight + Kirigami.Units.largeSpacing
PlasmaComponents.Label {
id: hintText
anchors.centerIn: parent
text: i18n("Gaming mode is still on. Use Home or the gamepad icon to reopen Game Center.")
color: "white"
wrapMode: Text.WordWrap
width: Math.min(root.width * 0.8, Kirigami.Units.gridUnit * 30)
horizontalAlignment: Text.AlignHCenter
}
}
MobileShell.HomeScreen { MobileShell.HomeScreen {
id: homeScreen id: homeScreen
anchors.fill: parent anchors.fill: parent

View file

@ -83,6 +83,12 @@ Item {
return true; return true;
} }
// In convergence mode the status bar behaves like a desktop panel:
// always opaque so it matches the dock and doesn't let the wallpaper bleed through.
if (ShellSettings.Settings.convergenceModeEnabled) {
return false;
}
return !containmentItem.showingApp && !containmentItem.fullscreen; return !containmentItem.showingApp && !containmentItem.fullscreen;
} }
forcedComplementary: { forcedComplementary: {
@ -131,6 +137,7 @@ Item {
context: Qt.ApplicationShortcut context: Qt.ApplicationShortcut
onActivated: { onActivated: {
if (drawer.actionDrawer.intendedToBeVisible) { if (drawer.actionDrawer.intendedToBeVisible) {
drawer.actionDrawer.intendedToBeVisible = false;
drawer.actionDrawer.close(); drawer.actionDrawer.close();
} else { } else {
drawer.actionDrawer.openToPinnedMode = false; drawer.actionDrawer.openToPinnedMode = false;

View file

@ -34,11 +34,17 @@ ContainmentItem {
// Whether the startup feedback is showing // Whether the startup feedback is showing
readonly property bool showingStartupFeedback: MobileShellState.ShellDBusObject.startupFeedbackModel.activeWindowIsStartupFeedback readonly property bool showingStartupFeedback: MobileShellState.ShellDBusObject.startupFeedbackModel.activeWindowIsStartupFeedback
readonly property bool gamingMode: ShellSettings.Settings.gamingModeEnabled
// Whether an app is maximized and showing (does not include startup feedback) // Whether an app is maximized and showing (does not include startup feedback)
readonly property bool showingApp: windowMaximizedTracker.showingWindow && !showingStartupFeedback readonly property bool showingApp: windowMaximizedTracker.showingWindow && !showingStartupFeedback
// Whether the currently showing app is in "fullscreen" // Whether the currently showing app is in "fullscreen"
readonly property bool fullscreen: { readonly property bool fullscreen: {
if (gamingMode) {
return true;
}
// In convergence mode the status bar is always visible, like a desktop panel. // In convergence mode the status bar is always visible, like a desktop panel.
if (ShellSettings.Settings.convergenceModeEnabled) { if (ShellSettings.Settings.convergenceModeEnabled) {
return false; return false;
@ -69,7 +75,7 @@ ContainmentItem {
} }
} }
readonly property real panelHeight: MobileShell.Constants.topPanelHeight readonly property real panelHeight: gamingMode ? 0 : MobileShell.Constants.topPanelHeight
onPanelHeightChanged: setWindowProperties() onPanelHeightChanged: setWindowProperties()
function setWindowProperties() { function setWindowProperties() {
@ -123,6 +129,11 @@ ContainmentItem {
function onConvergenceModeEnabledChanged() { function onConvergenceModeEnabledChanged() {
root.setWindowProperties(); root.setWindowProperties();
} }
function onGamingModeEnabledChanged() {
root.setWindowProperties();
MobileShellState.ShellDBusClient.panelState = ShellSettings.Settings.gamingModeEnabled ? "hidden" : (fullscreen ? "hidden" : "default");
}
} }
Component.onCompleted: { Component.onCompleted: {
@ -136,22 +147,23 @@ ContainmentItem {
// MaximizeArea by the panel height. // MaximizeArea by the panel height.
Window { Window {
id: topBarSpaceReserver id: topBarSpaceReserver
visible: ShellSettings.Settings.convergenceModeEnabled visible: ShellSettings.Settings.convergenceModeEnabled && !ShellSettings.Settings.gamingModeEnabled
color: "transparent" color: "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput
height: root.panelHeight height: Math.max(1, root.panelHeight)
width: 1 width: 1
LayerShell.Window.scope: "topbar-space" LayerShell.Window.scope: "topbar-space"
LayerShell.Window.layer: LayerShell.Window.LayerBottom LayerShell.Window.layer: LayerShell.Window.LayerBottom
LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight
LayerShell.Window.exclusionZone: root.panelHeight LayerShell.Window.exclusionZone: Math.max(1, root.panelHeight)
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
} }
// Visual panel component // Visual panel component
StatusPanel { StatusPanel {
id: statusPanel id: statusPanel
visible: !ShellSettings.Settings.gamingModeEnabled
anchors.fill: parent anchors.fill: parent
containmentItem: root containmentItem: root
} }

View file

@ -88,7 +88,7 @@ MobileShell.NavigationPanel {
id: homeAction id: homeAction
enabled: true enabled: true
iconSource: "start-here-kde" iconSource: "start-here-shift"
onTriggered: { onTriggered: {
MobileShellState.ShellDBusClient.openHomeScreen(); MobileShellState.ShellDBusClient.openHomeScreen();

View file

@ -38,12 +38,14 @@ ContainmentItem {
readonly property bool inLandscape: MobileShell.Constants.navigationPanelOnSide(Screen.width, Screen.height) readonly property bool inLandscape: MobileShell.Constants.navigationPanelOnSide(Screen.width, Screen.height)
readonly property real navigationPanelHeight: MobileShell.Constants.navigationPanelThickness readonly property bool gamingMode: ShellSettings.Settings.gamingModeEnabled
readonly property real navigationPanelHeight: gamingMode ? 0 : MobileShell.Constants.navigationPanelThickness
onNavigationPanelHeightChanged: setWindowProperties() onNavigationPanelHeightChanged: setWindowProperties()
readonly property real intendedWindowThickness: navigationPanelHeight readonly property real intendedWindowThickness: navigationPanelHeight
readonly property real intendedWindowLength: inLandscape ? Screen.height : Screen.width readonly property real intendedWindowLength: inLandscape ? Screen.height : Screen.width
readonly property real intendedWindowOffset: inLandscape ? MobileShell.Constants.topPanelHeight : 0; // offset for top panel readonly property real intendedWindowOffset: (inLandscape && !gamingMode) ? MobileShell.Constants.topPanelHeight : 0; // offset for top panel
readonly property int intendedWindowLocation: inLandscape ? PlasmaCore.Types.RightEdge : PlasmaCore.Types.BottomEdge readonly property int intendedWindowLocation: inLandscape ? PlasmaCore.Types.RightEdge : PlasmaCore.Types.BottomEdge
onIntendedWindowLengthChanged: maximizeTimer.restart() // ensure it always takes up the full length of the screen onIntendedWindowLengthChanged: maximizeTimer.restart() // ensure it always takes up the full length of the screen
@ -136,6 +138,11 @@ ContainmentItem {
function onConvergenceModeEnabledChanged() { function onConvergenceModeEnabledChanged() {
root.setWindowProperties(); root.setWindowProperties();
} }
function onGamingModeEnabledChanged() {
root.setWindowProperties();
navigationPanel.offset = ShellSettings.Settings.gamingModeEnabled ? MobileShell.Constants.navigationPanelThickness : 0;
}
} }
Component.onCompleted: setWindowProperties(); Component.onCompleted: setWindowProperties();
@ -153,18 +160,19 @@ ContainmentItem {
Window { Window {
id: dockSpaceReserver id: dockSpaceReserver
visible: ShellSettings.Settings.convergenceModeEnabled visible: ShellSettings.Settings.convergenceModeEnabled
&& !ShellSettings.Settings.gamingModeEnabled
&& !(ShellSettings.Settings.autoHidePanelsEnabled && !(ShellSettings.Settings.autoHidePanelsEnabled
&& windowMaximizedTracker.showingWindow) && windowMaximizedTracker.showingWindow)
color: "transparent" color: "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput
// height is set by layer-shell anchoring; provide a fallback. // height is set by layer-shell anchoring; provide a fallback.
height: Kirigami.Units.gridUnit * 3 height: Math.max(1, MobileShell.Constants.navigationPanelThickness)
width: 1 // layer-shell stretches it via AnchorLeft|AnchorRight width: 1 // layer-shell stretches it via AnchorLeft|AnchorRight
LayerShell.Window.scope: "dock-space" LayerShell.Window.scope: "dock-space"
LayerShell.Window.layer: LayerShell.Window.LayerBottom LayerShell.Window.layer: LayerShell.Window.LayerBottom
LayerShell.Window.anchors: LayerShell.Window.AnchorBottom | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight LayerShell.Window.anchors: LayerShell.Window.AnchorBottom | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight
LayerShell.Window.exclusionZone: Kirigami.Units.gridUnit * 3 LayerShell.Window.exclusionZone: Math.max(1, MobileShell.Constants.navigationPanelThickness)
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
} }
@ -177,7 +185,9 @@ ContainmentItem {
return (windowMaximizedTracker.showingWindow || isCurrentWindowFullscreen) && !showingStartupFeedback return (windowMaximizedTracker.showingWindow || isCurrentWindowFullscreen) && !showingStartupFeedback
} }
readonly property alias isCurrentWindowFullscreen: windowMaximizedTracker.isCurrentWindowFullscreen readonly property alias isCurrentWindowFullscreen: windowMaximizedTracker.isCurrentWindowFullscreen
readonly property bool fullscreen: isCurrentWindowFullscreen || (ShellSettings.Settings.autoHidePanelsEnabled && opaqueBar) readonly property bool fullscreen: ShellSettings.Settings.gamingModeEnabled
|| isCurrentWindowFullscreen
|| (ShellSettings.Settings.autoHidePanelsEnabled && opaqueBar)
WindowPlugin.WindowMaximizedTracker { WindowPlugin.WindowMaximizedTracker {
id: windowMaximizedTracker id: windowMaximizedTracker
@ -205,6 +215,7 @@ ContainmentItem {
Item { Item {
id: navigationPanel id: navigationPanel
visible: !ShellSettings.Settings.gamingModeEnabled
anchors.fill: parent anchors.fill: parent
property real offset: 0 property real offset: 0

10
icons/CMakeLists.txt Normal file
View file

@ -0,0 +1,10 @@
# SPDX-FileCopyrightText: 2026 Marco Allegretti
# SPDX-License-Identifier: GPL-2.0-or-later
include(ECMInstallIcons)
ecm_install_icons(
ICONS sc-places-start-here-shift.svg
DESTINATION ${KDE_INSTALL_ICONDIR}
THEME hicolor
)

View file

@ -0,0 +1,10 @@
<svg viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
<!-- top-left circle -->
<circle cx="16" cy="16" r="16" fill="#FBFBFB"/>
<circle cx="16" cy="16" r="5.4" fill="#31363B"/>
<!-- bottom-left circle -->
<circle cx="16" cy="80" r="16" fill="#FBFBFB"/>
<circle cx="16" cy="80" r="7.5" fill="#31363B"/>
<!-- right circle -->
<circle cx="80" cy="48" r="16" fill="#FBFBFB"/>
</svg>

After

Width:  |  Height:  |  Size: 401 B

View file

@ -17,6 +17,45 @@ import org.kde.plasma.private.mobileshell.waydroidintegrationplugin as AIP
KCM.SimpleKCM { KCM.SimpleKCM {
id: root id: root
function packagePatterns(value: string): var {
if (value === "") {
return []
}
return value.split(",")
.map(pattern => pattern.trim())
.filter(pattern => pattern !== "")
}
function wildcardRegExp(pattern: string): RegExp {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
return new RegExp("^" + escaped.replace(/\*/g, ".*") + "$")
}
function hasExactPackage(value: string, packageName: string): bool {
return packagePatterns(value).includes(packageName)
}
function hasWildcardPackage(value: string, packageName: string): bool {
return packagePatterns(value)
.filter(pattern => pattern.includes("*"))
.some(pattern => wildcardRegExp(pattern).test(packageName))
}
function hasEffectivePackage(value: string, packageName: string): bool {
return hasExactPackage(value, packageName) || hasWildcardPackage(value, packageName)
}
function updateExactPackage(value: string, packageName: string, enabled: bool): string {
const updatedPatterns = packagePatterns(value).filter(pattern => pattern !== packageName)
if (enabled) {
updatedPatterns.push(packageName)
}
return updatedPatterns.join(",")
}
topPadding: Kirigami.Units.largeSpacing topPadding: Kirigami.Units.largeSpacing
bottomPadding: Kirigami.Units.largeSpacing bottomPadding: Kirigami.Units.largeSpacing
leftPadding: 0 leftPadding: 0
@ -74,6 +113,7 @@ KCM.SimpleKCM {
} }
ColumnLayout { ColumnLayout {
visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized
spacing: Kirigami.Units.largeSpacing spacing: Kirigami.Units.largeSpacing
Kirigami.InlineMessage { Kirigami.InlineMessage {
@ -85,6 +125,11 @@ KCM.SimpleKCM {
showCloseButton: true showCloseButton: true
} }
Kirigami.PlaceholderMessage {
Layout.fillWidth: true
explanation: i18n("This page manages the launchers exported by Waydroid. Enable Show in Game Shell for Android apps you want listed in Game Center's Waydroid tab. Touch and Wi-Fi toggles add or remove exact package names from Waydroid's documented compatibility property lists. Wildcard rules remain in the main Waydroid properties page.")
}
FormCard.FormCard { FormCard.FormCard {
Repeater { Repeater {
model: AIP.WaydroidDBusClient.applicationListModel model: AIP.WaydroidDBusClient.applicationListModel
@ -95,23 +140,105 @@ KCM.SimpleKCM {
width: ListView.view.width width: ListView.view.width
background: null background: null
contentItem: RowLayout { contentItem: ColumnLayout {
QQC2.Label { spacing: Kirigami.Units.smallSpacing
RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
text: model.name
elide: Text.ElideRight QQC2.Label {
Layout.fillWidth: true
text: model.name
elide: Text.ElideRight
}
QQC2.ToolButton {
display: QQC2.AbstractButton.IconOnly
text: i18nc("@action:button", "Launch the application")
icon.name: "media-playback-start"
onClicked: AIP.WaydroidDBusClient.launchApplication(model.id)
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.text: text
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton {
display: QQC2.AbstractButton.IconOnly
text: i18nc("@action:button", "Delete the application")
icon.name: "usermenu-delete"
onClicked: AIP.WaydroidDBusClient.deleteApplication(model.id)
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.text: text
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
} }
QQC2.ToolButton { RowLayout {
display: QQC2.AbstractButton.IconOnly Layout.fillWidth: true
text: i18nc("@action:button", "Delete the application")
icon.name: "usermenu-delete"
onClicked: AIP.WaydroidDBusClient.deleteApplication(model.id) QQC2.CheckBox {
text: i18n("Show in Game Shell")
checked: AIP.WaydroidDBusClient.gameShellPackages.indexOf(model.id) !== -1
QQC2.ToolTip.visible: hovered onClicked: AIP.WaydroidDBusClient.setGameShellEnabledForPackage(model.id, checked)
QQC2.ToolTip.text: text }
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
Item {
Layout.fillWidth: true
}
}
QQC2.Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
color: Kirigami.Theme.disabledTextColor
text: i18n("Adds this app to the Game Shell allowlist so it appears in Game Center under Waydroid.")
}
RowLayout {
Layout.fillWidth: true
QQC2.CheckBox {
id: fakeTouchToggle
text: i18n("Touch")
checked: root.hasEffectivePackage(AIP.WaydroidDBusClient.fakeTouch, model.id)
enabled: !root.hasWildcardPackage(AIP.WaydroidDBusClient.fakeTouch, model.id)
onClicked: {
AIP.WaydroidDBusClient.fakeTouch = root.updateExactPackage(AIP.WaydroidDBusClient.fakeTouch, model.id, checked)
}
QQC2.ToolTip.visible: hovered && !enabled
QQC2.ToolTip.text: i18n("Managed by a wildcard pattern in Waydroid properties")
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.CheckBox {
id: fakeWifiToggle
text: i18n("Wi-Fi")
checked: root.hasEffectivePackage(AIP.WaydroidDBusClient.fakeWifi, model.id)
enabled: !root.hasWildcardPackage(AIP.WaydroidDBusClient.fakeWifi, model.id)
onClicked: {
AIP.WaydroidDBusClient.fakeWifi = root.updateExactPackage(AIP.WaydroidDBusClient.fakeWifi, model.id, checked)
}
QQC2.ToolTip.visible: hovered && !enabled
QQC2.ToolTip.text: i18n("Managed by a wildcard pattern in Waydroid properties")
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
}
QQC2.Label {
Layout.fillWidth: true
visible: !fakeTouchToggle.enabled || !fakeWifiToggle.enabled
wrapMode: Text.WordWrap
color: Kirigami.Theme.disabledTextColor
text: i18n("One or more compatibility settings for this app come from a wildcard rule. Edit the global Waydroid property to change that rule.")
} }
} }
} }
@ -121,4 +248,22 @@ KCM.SimpleKCM {
} }
} }
} }
ColumnLayout {
visible: AIP.WaydroidDBusClient.status !== AIP.WaydroidDBusClient.Initialized
anchors.centerIn: parent
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
text: i18n("Waydroid is unavailable")
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
}
PC3.Button {
text: i18n("Check again")
Layout.alignment: Qt.AlignHCenter
onClicked: AIP.WaydroidDBusClient.refreshSupportsInfo()
}
}
} }

View file

@ -15,6 +15,13 @@ import org.kde.plasma.private.mobileshell.waydroidintegrationplugin as AIP
ColumnLayout { ColumnLayout {
id: root id: root
visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized
&& AIP.WaydroidDBusClient.sessionStatus === AIP.WaydroidDBusClient.SessionRunning
function packagePatternSummary(value: string): string {
return value === "" ? i18n("Not set") : value
}
FormCard.FormHeader { FormCard.FormHeader {
title: i18n("General information") title: i18n("General information")
} }
@ -66,6 +73,64 @@ ColumnLayout {
onAccepted: AIP.WaydroidDBusClient.resetWaydroid() onAccepted: AIP.WaydroidDBusClient.resetWaydroid()
} }
Kirigami.PromptDialog {
id: fakeTouchDialog
title: i18n("Touch input override")
standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel
onOpened: {
fakeTouchField.text = AIP.WaydroidDBusClient.fakeTouch
fakeTouchField.forceActiveFocus()
}
onAccepted: AIP.WaydroidDBusClient.fakeTouch = fakeTouchField.text.trim()
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
text: i18n("Comma-separated package names for apps where mouse input should be interpreted as touch. Supports * wildcards. Leave empty to clear the override.")
}
QQC2.TextField {
id: fakeTouchField
Layout.fillWidth: true
placeholderText: "com.rovio.*"
}
}
}
Kirigami.PromptDialog {
id: fakeWifiDialog
title: i18n("Wi-Fi override")
standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel
onOpened: {
fakeWifiField.text = AIP.WaydroidDBusClient.fakeWifi
fakeWifiField.forceActiveFocus()
}
onAccepted: AIP.WaydroidDBusClient.fakeWifi = fakeWifiField.text.trim()
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
text: i18n("Comma-separated package names for apps that should always appear to be on Wi-Fi. Supports * wildcards. Leave empty to clear the override.")
}
QQC2.TextField {
id: fakeWifiField
Layout.fillWidth: true
placeholderText: "com.gameloft.*"
}
}
}
} }
// Some information such as IP address can take time to be set by Waydroid // Some information such as IP address can take time to be set by Waydroid
@ -140,5 +205,29 @@ ColumnLayout {
infoMessage.visible = true infoMessage.visible = true
} }
} }
FormCard.FormDelegateSeparator { above: uevent; below: fakeTouch }
FormCard.FormTextDelegate {
id: fakeTouch
text: i18n("Touch input override")
description: root.packagePatternSummary(AIP.WaydroidDBusClient.fakeTouch)
trailing: PC3.Button {
text: i18n("Edit")
onClicked: fakeTouchDialog.open()
}
}
FormCard.FormDelegateSeparator { above: fakeTouch; below: fakeWifi }
FormCard.FormTextDelegate {
id: fakeWifi
text: i18n("Wi-Fi override")
description: root.packagePatternSummary(AIP.WaydroidDBusClient.fakeWifi)
trailing: PC3.Button {
text: i18n("Edit")
onClicked: fakeWifiDialog.open()
}
}
} }
} }

View file

@ -29,7 +29,9 @@ KCM.SimpleKCM {
} }
WaydroidLoader { WaydroidLoader {
visible: AIP.WaydroidDBusClient.androidId === "" visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized
&& AIP.WaydroidDBusClient.androidId === ""
&& !inlineMessage.visible
text: i18n("Fetching your Android ID.\nIt can take a few seconds.") text: i18n("Fetching your Android ID.\nIt can take a few seconds.")
} }
@ -53,7 +55,8 @@ KCM.SimpleKCM {
} }
ColumnLayout { ColumnLayout {
visible: AIP.WaydroidDBusClient.androidId !== "" visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized
&& AIP.WaydroidDBusClient.androidId !== ""
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.left: parent anchors.left: parent
anchors.leftMargin: Kirigami.Units.largeSpacing anchors.leftMargin: Kirigami.Units.largeSpacing
@ -76,4 +79,22 @@ KCM.SimpleKCM {
} }
} }
} }
ColumnLayout {
visible: AIP.WaydroidDBusClient.status !== AIP.WaydroidDBusClient.Initialized
anchors.centerIn: parent
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
text: i18n("Waydroid is unavailable")
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
}
QQC2.Button {
text: i18n("Check again")
Layout.alignment: Qt.AlignHCenter
onClicked: AIP.WaydroidDBusClient.refreshSupportsInfo()
}
}
} }

View file

@ -29,14 +29,14 @@ KCM.SimpleKCM {
spacing: Kirigami.Units.largeSpacing spacing: Kirigami.Units.largeSpacing
QQC2.Label { QQC2.Label {
text: i18n("Waydroid is not installed") text: i18n("Waydroid is unavailable")
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
} }
PC3.Button { PC3.Button {
text: i18n("Check installation") text: i18n("Check again")
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
onClicked: AIP.WaydroidDBusClient.refreshSupportsInfo() onClicked: AIP.WaydroidDBusClient.refreshSupportsInfo()
} }

View file

@ -11,9 +11,9 @@ Loader {
property var currentWindow property var currentWindow
// Window that needs geometry clamping after un-maximize in convergence // Windows awaiting geometry clamping after un-maximize in convergence
// mode. Set in onMaximizedChanged and consumed by the timer below. // mode. Using an array so concurrent un-maximizes are not lost.
property var pendingConstrainWindow: null property var pendingConstrainWindows: []
// After a window is un-maximized in convergence mode, the dockSpaceReserver // After a window is un-maximized in convergence mode, the dockSpaceReserver
// LayerShell surface needs one Wayland roundtrip to (re)commit its exclusive // LayerShell surface needs one Wayland roundtrip to (re)commit its exclusive
@ -24,27 +24,30 @@ Loader {
id: constrainAfterRestoreTimer id: constrainAfterRestoreTimer
interval: 200 interval: 200
onTriggered: { onTriggered: {
const window = root.pendingConstrainWindow const windows = root.pendingConstrainWindows.slice()
root.pendingConstrainWindow = null root.pendingConstrainWindows = []
if (!window || window.deleted || !window.normalWindow) return for (const window of windows) {
if (!ShellSettings.Settings.convergenceModeEnabled) return if (!window || window.deleted || !window.normalWindow) continue
if (!ShellSettings.Settings.convergenceModeEnabled) continue
if (ShellSettings.Settings.gamingModeEnabled) continue
const output = window.output const output = window.output
const desktop = window.desktops[0] const desktop = window.desktops[0]
if (!output) return if (!output) continue
if (!desktop) return if (!desktop) continue
const maxRect = KWinComponents.Workspace.clientArea( const maxRect = KWinComponents.Workspace.clientArea(
KWinComponents.Workspace.MaximizeArea, output, desktop) KWinComponents.Workspace.MaximizeArea, output, desktop)
const geo = window.frameGeometry const geo = window.frameGeometry
const maxBottom = maxRect.y + maxRect.height const maxBottom = maxRect.y + maxRect.height
if (geo.y + geo.height > maxBottom) { if (geo.y + geo.height > maxBottom) {
// Clip the bottom edge to MaximizeArea; preserve top position // Clip the bottom edge to MaximizeArea; preserve top position
// and width. Ensure height is at least 100px to avoid // and width. Ensure height is at least 100px to avoid
// pathological cases where the window starts above maxRect. // pathological cases where the window starts above maxRect.
const newH = Math.max(100, maxBottom - geo.y) const newH = Math.max(100, maxBottom - geo.y)
window.frameGeometry = Qt.rect(geo.x, geo.y, geo.width, newH) window.frameGeometry = Qt.rect(geo.x, geo.y, geo.width, newH)
}
} }
} }
} }
@ -60,6 +63,12 @@ Loader {
return; return;
} }
if (ShellSettings.Settings.gamingModeEnabled) {
window.noBorder = true;
window.setMaximize(true, true);
return;
}
if (ShellSettings.Settings.convergenceModeEnabled) { if (ShellSettings.Settings.convergenceModeEnabled) {
window.noBorder = false; window.noBorder = false;
} else { } else {
@ -91,6 +100,9 @@ Loader {
target: currentWindow target: currentWindow
function onFullScreenChanged() { function onFullScreenChanged() {
if (!currentWindow) {
return;
}
currentWindow.interactiveMoveResizeFinished.connect((currentWindow) => { currentWindow.interactiveMoveResizeFinished.connect((currentWindow) => {
root.run(currentWindow); root.run(currentWindow);
}); });
@ -98,6 +110,9 @@ Loader {
} }
function onMaximizedChanged() { function onMaximizedChanged() {
if (!currentWindow) {
return;
}
if (!currentWindow.maximizable) { if (!currentWindow.maximizable) {
return; return;
} }
@ -110,7 +125,7 @@ Loader {
// zone is re-committed over a Wayland roundtrip. // zone is re-committed over a Wayland roundtrip.
if (ShellSettings.Settings.convergenceModeEnabled if (ShellSettings.Settings.convergenceModeEnabled
&& ShellSettings.Settings.autoHidePanelsEnabled) { && ShellSettings.Settings.autoHidePanelsEnabled) {
root.pendingConstrainWindow = currentWindow root.pendingConstrainWindows.push(currentWindow)
constrainAfterRestoreTimer.restart() constrainAfterRestoreTimer.restart()
} }
} }
@ -128,12 +143,25 @@ Loader {
} }
} }
} }
function onGamingModeEnabledChanged() {
const windows = KWinComponents.Workspace.windows;
for (let i = 0; i < windows.length; i++) {
if (windows[i].normalWindow) {
root.run(windows[i]);
}
}
}
} }
Connections { Connections {
target: KWinComponents.Workspace target: KWinComponents.Workspace
function onWindowAdded(window) { function onWindowAdded(window) {
if (!window) {
return;
}
if (window.normalWindow) { if (window.normalWindow) {
window.interactiveMoveResizeFinished.connect((window) => { window.interactiveMoveResizeFinished.connect((window) => {
root.run(window); root.run(window);
@ -143,6 +171,9 @@ Loader {
} }
function onWindowActivated(window) { function onWindowActivated(window) {
if (!window) {
return;
}
if (window.normalWindow) { if (window.normalWindow) {
currentWindow = window; currentWindow = window;
window.interactiveMoveResizeFinished.connect((window) => { window.interactiveMoveResizeFinished.connect((window) => {

View file

@ -8,11 +8,11 @@
widgetStyle=Breeze widgetStyle=Breeze
[kdeglobals][General] [kdeglobals][General]
ColorScheme=BreezeLight ColorScheme=ShiftDark
Name=Breeze Name=Shift Dark
[kdeglobals][Icons] [kdeglobals][Icons]
Theme=breeze Theme=breeze-dark
[plasmarc][Theme] [plasmarc][Theme]
name=default name=default
@ -26,6 +26,7 @@ cursorTheme=breeze_cursors
[kwinrc][org.kde.kdecoration2] [kwinrc][org.kde.kdecoration2]
library=org.kde.breeze library=org.kde.breeze
theme=Shift Dark
[KSplash] [KSplash]
Theme=org.kde.Breeze Theme=org.kde.Breeze

View file

@ -8,6 +8,8 @@ plasma_install_package(bluetooth org.kde.plasma.quicksetting.bluetooth quicksett
plasma_install_package(caffeine org.kde.plasma.quicksetting.caffeine quicksettings) plasma_install_package(caffeine org.kde.plasma.quicksetting.caffeine quicksettings)
plasma_install_package(docked org.kde.plasma.quicksetting.docked quicksettings) plasma_install_package(docked org.kde.plasma.quicksetting.docked quicksettings)
plasma_install_package(donotdisturb org.kde.plasma.quicksetting.donotdisturb quicksettings) plasma_install_package(donotdisturb org.kde.plasma.quicksetting.donotdisturb quicksettings)
plasma_install_package(gaming org.kde.plasma.quicksetting.gaming quicksettings)
plasma_install_package(gaminghint org.kde.plasma.quicksetting.gaminghint quicksettings)
plasma_install_package(autohidepanels org.kde.plasma.quicksetting.autohidepanels quicksettings) plasma_install_package(autohidepanels org.kde.plasma.quicksetting.autohidepanels quicksettings)
plasma_install_package(keyboardtoggle org.kde.plasma.quicksetting.keyboardtoggle quicksettings) plasma_install_package(keyboardtoggle org.kde.plasma.quicksetting.keyboardtoggle quicksettings)
plasma_install_package(kscreenosd org.kde.plasma.quicksetting.kscreenosd quicksettings) plasma_install_package(kscreenosd org.kde.plasma.quicksetting.kscreenosd quicksettings)

View file

@ -0,0 +1,16 @@
#! /usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Marco Allegretti
# SPDX-License-Identifier: EUPL-1.2
set -e
: "${XGETTEXT:?XGETTEXT is not set}"
: "${podir:?podir is not set}"
mapfile -t files < <(find . \( -name '*.js' -o -name '*.qml' -o -name '*.cpp' \) -print)
if [[ ${#files[@]} -eq 0 ]]; then
echo "Messages.sh: no input files found" >&2
exit 1
fi
"$XGETTEXT" "${files[@]}" -o "$podir/plasma_org.kde.plasma.quicksetting.gaming.pot"

View file

@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
import QtQuick 2.15
import org.kde.kirigami as Kirigami
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import org.kde.plasma.private.mobileshell.quicksettingsplugin as QS
QS.QuickSetting {
id: root
text: i18n("Gaming Mode")
icon: "input-gamepad"
status: enabled ? i18n("Active") : i18n("Inactive")
enabled: ShellSettings.Settings.gamingModeEnabled
function requestDisable() {
confirmDisableDialog.active = true;
}
function toggle() {
if (ShellSettings.Settings.gamingModeEnabled) {
requestDisable();
return;
}
ShellSettings.Settings.gamingModeEnabled = true;
}
Loader {
id: confirmDisableDialog
active: false
onLoaded: item.open()
sourceComponent: Kirigami.PromptDialog {
id: theConfirmDialog
title: i18n("Leave gaming mode?")
subtitle: i18n("Your games will keep running in the background.")
standardButtons: Kirigami.Dialog.NoButton
customFooterActions: [
Kirigami.Action {
text: i18n("Keep Playing")
onTriggered: theConfirmDialog.close()
},
Kirigami.Action {
text: i18n("Leave")
onTriggered: {
ShellSettings.Settings.gamingModeEnabled = false
theConfirmDialog.close()
}
}
]
onClosed: confirmDisableDialog.active = false
}
}
}

View file

@ -0,0 +1,18 @@
{
"KPackageStructure": "KPackage/GenericQML",
"KPlugin": {
"Authors": [
{
"Email": "mightymarco4@gmail.com",
"Name": "Marco Allegretti"
}
],
"Description": "Quick setting to toggle gaming mode for Plasma Mobile",
"Icon": "input-gaming",
"Id": "org.kde.plasma.quicksetting.gaming",
"License": "EUPL-1.2",
"Name": "Gaming Mode",
"Version": "0.1",
"Website": ""
}
}

View file

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Marco Allegretti
# SPDX-License-Identifier: EUPL-1.2
set -e
: "${XGETTEXT:?XGETTEXT is not set}"
: "${podir:?podir is not set}"
shopt -s nullglob
files=(*.json contents/ui/*.qml)
if [[ ${#files[@]} -eq 0 ]]; then
echo "Messages.sh: no input files found" >&2
exit 1
fi
"$XGETTEXT" "${files[@]}" -o "$podir/plasma_mobile_qt.pot"

View file

@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
import QtQuick 2.15
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import org.kde.plasma.private.mobileshell.quicksettingsplugin as QS
QS.QuickSetting {
text: i18n("Launch Hint")
icon: "dialog-information"
status: ShellSettings.Settings.gamingDismissHintEnabled ? i18n("On") : i18n("Off")
enabled: true
available: ShellSettings.Settings.gamingModeEnabled
function toggle() {
ShellSettings.Settings.gamingDismissHintEnabled = !ShellSettings.Settings.gamingDismissHintEnabled;
}
}

View file

@ -0,0 +1,18 @@
{
"KPackageStructure": "KPackage/GenericQML",
"KPlugin": {
"Authors": [
{
"Email": "mightymarco4@gmail.com",
"Name": "Marco Allegretti"
}
],
"Description": "Quick setting to toggle the Game Center dismiss hint banner",
"Icon": "dialog-information",
"Id": "org.kde.plasma.quicksetting.gaminghint",
"License": "EUPL-1.2",
"Name": "Game Center Hint",
"Version": "0.1",
"Website": ""
}
}

View file

@ -1,2 +1,2 @@
SPDX-FileCopyrightText: 2026 Marco Allegretti. SPDX-FileCopyrightText: 2026 Marco Allegretti
SPDX-License-Identifier: EUPL-1.2 SPDX-License-Identifier: EUPL-1.2