Compare commits
20
Commits
dd9b43bd27
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
852a5fe82e | ||
|
|
ba7945a1d6 | ||
|
|
e4ea19cd09 | ||
|
|
75a74a4f01 | ||
|
|
7741fbcb5c | ||
|
|
4d70014242 | ||
|
|
4321209e92 | ||
|
|
08e67845a5 | ||
|
|
e96a22eb9e | ||
|
|
2f59d3c115 | ||
|
|
8c07c10050 | ||
|
|
ab869119cc | ||
|
|
b0c95b9e78 | ||
|
|
a7d204f241 | ||
|
|
9db047b8ad | ||
|
|
13d06e3ca8 | ||
|
|
9348605044 | ||
|
|
befed8ff96 | ||
|
|
5504d3cfb8 | ||
|
|
2dadead399 |
@@ -6,15 +6,80 @@ A Linux desktop environment built around a custom compositor, shell, and service
|
|||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `core/nebula-core` | Core runtime and platform APIs |
|
| `core/nebula-core` | Core runtime and platform APIs (growing) |
|
||||||
| `compositor/` | Compositor configuration (Sway) |
|
| `core/applications` | Installed `.desktop` discovery and launching |
|
||||||
| `shells/` | Desktop and bigscreen shell implementations |
|
| `core/windows` | Window and workspace management (`WindowService`) |
|
||||||
| `packages/` | Shared UI libraries, icons, API, and design tokens |
|
| `compositor/` | Compositor configuration (Sway is temporary) |
|
||||||
|
| `shells/` | Desktop and future Bigscreen shells |
|
||||||
|
| `packages/` | Shared UI (`Nebula.UI`), icons, API, and design tokens |
|
||||||
| `services/` | System services (audio, network, bluetooth, power, accounts) |
|
| `services/` | System services (audio, network, bluetooth, power, accounts) |
|
||||||
| `session/` | Session manager and desktop entry |
|
| `session/` | Session manager and desktop entry |
|
||||||
| `packaging/` | Distribution packaging (Ubuntu) |
|
| `packaging/` | Distribution packaging (Ubuntu) |
|
||||||
| `branding/` | Boot assets, wallpapers, and sounds |
|
| `branding/` | Boot assets, wallpapers, and sounds |
|
||||||
|
| `scripts/` | Development helpers (including the VMware shell wrapper) |
|
||||||
|
|
||||||
## Getting started
|
## Shells
|
||||||
|
|
||||||
Documentation and build instructions will be added as components land.
|
**Nebula Desktop 0.2** is a C++20 Qt Quick/QML Wayland shell using LayerShellQt. It is floating-first: ordinary applications open as independently movable windows. See `shells/desktop/`.
|
||||||
|
|
||||||
|
**Nebula Bigscreen** is planned as a separate Qt Quick/QML shell with a controller-first interaction model, sharing `Nebula.UI`, `Nebula.Applications`, and `Nebula.Windows`.
|
||||||
|
|
||||||
|
Standalone Nebula applications may still use React/Tauri where that is a better fit. The operating-system shell does not.
|
||||||
|
|
||||||
|
An archived React desktop prototype lives in `shells/desktop/prototype-react/` as a design reference only.
|
||||||
|
|
||||||
|
## Current stack
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ubuntu
|
||||||
|
↓
|
||||||
|
Wayland
|
||||||
|
↓
|
||||||
|
Sway temporary compositor / window manager
|
||||||
|
↓
|
||||||
|
Nebula Shell Qt Quick/QML via LayerShellQt
|
||||||
|
```
|
||||||
|
|
||||||
|
Window management is abstracted:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nebula QML
|
||||||
|
↓
|
||||||
|
WindowService
|
||||||
|
↓
|
||||||
|
Sway IPC backend now
|
||||||
|
Nebula compositor later
|
||||||
|
↓
|
||||||
|
Sway
|
||||||
|
/ \
|
||||||
|
Wayland app XWayland app
|
||||||
|
```
|
||||||
|
|
||||||
|
Applications and games render on their own Wayland/XWayland paths. They are not composited inside QML.
|
||||||
|
|
||||||
|
Sway currently provides temporary window decorations (title bar and border) so windows can be identified, dragged, and resized. Custom Nebula window chrome is later work.
|
||||||
|
|
||||||
|
## Getting started (development VM)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd shells/desktop/shell
|
||||||
|
rm -rf build
|
||||||
|
cmake -S . -B build
|
||||||
|
cmake --build build -j$(nproc)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sh compositor/sway/dev/run-sway-vm.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
or:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sway -c compositor/sway/nebula-sway.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
The VMware wrapper sets `QT_QUICK_BACKEND=software`. Production `nebula-shell` does not. Details: `shells/desktop/README.md` and `compositor/README.md`.
|
||||||
|
|
||||||
|
The Qt shell must be compiled on Linux (the Ubuntu NebulaOS VM). It will not build on Windows.
|
||||||
|
|||||||
+105
-1
@@ -1,3 +1,107 @@
|
|||||||
# Compositor
|
# Compositor
|
||||||
|
|
||||||
NebulaOS uses Sway as its Wayland compositor. Configuration lives in `sway/nebula-sway.conf`.
|
Sway is the **temporary** Wayland compositor while Nebula Desktop is developed. Configuration lives in `sway/nebula-sway.conf`.
|
||||||
|
|
||||||
|
Sway currently provides:
|
||||||
|
|
||||||
|
- Wayland compositor
|
||||||
|
- floating-first application window management
|
||||||
|
- temporary title bars and borders on normal application windows
|
||||||
|
- XWayland
|
||||||
|
- input and the pointer cursor
|
||||||
|
- output handling
|
||||||
|
|
||||||
|
Nebula provides the visible shell (Qt Quick/QML via LayerShellQt) and logical window management through `WindowService` (`core/windows`). The Sway config must not enable a Sway bar.
|
||||||
|
|
||||||
|
A custom Nebula compositor is a later milestone. Do not treat this Sway setup as the final architecture. QML must not call `swaymsg`; the Sway IPC backend is the only Sway-specific code, and it is isolated so it can be replaced.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ubuntu/Linux
|
||||||
|
↓
|
||||||
|
Wayland
|
||||||
|
↓
|
||||||
|
Sway now
|
||||||
|
Nebula compositor later
|
||||||
|
↓
|
||||||
|
Nebula Shell
|
||||||
|
Qt Quick/QML
|
||||||
|
```
|
||||||
|
|
||||||
|
## Floating-first session
|
||||||
|
|
||||||
|
Ordinary application windows open as floating windows, sized to about **70% of the current workspace** (the usable output area below the 40px Nebula top bar) and centred. Sway workspace geometry already excludes the top-bar exclusive zone, so placement stays on-screen at any resolution.
|
||||||
|
|
||||||
|
Rules match only normal application views:
|
||||||
|
|
||||||
|
- Wayland: `shell="xdg_shell"` (`app_id`)
|
||||||
|
- XWayland: `shell="xwayland"` (`class`)
|
||||||
|
|
||||||
|
Layer-shell surfaces (Nebula desktop, top bar, launcher, overlays) are a different shell and are not matched. They stay on BACKGROUND / TOP / OVERLAY.
|
||||||
|
|
||||||
|
Sway draws a restrained title bar and border on those application windows so they can be identified, dragged, and resized with the mouse. This is **temporary**. Custom Nebula decorations, CSD, and wrapping Wayland clients inside QML are out of scope.
|
||||||
|
|
||||||
|
## Development session
|
||||||
|
|
||||||
|
From the NebulaOS repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sway -c compositor/sway/nebula-sway.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended on the Ubuntu VMware VM (repo-root `cd`, cursor environment, software cursors):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sh compositor/sway/dev/run-sway-vm.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Sway then autostarts Nebula Desktop through `scripts/run-nebula-vm.sh`.
|
||||||
|
|
||||||
|
Skip shell autostart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEBULA_SHELL_AUTOSTART=0 sh compositor/sway/dev/run-sway-vm.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output / resolution
|
||||||
|
|
||||||
|
The development config uses scale `1` and does not pin a resolution. Sway uses the output's preferred mode, which follows the current display size (including VMware Autofit Guest). Window sizes use workspace-relative percentages, not hard-coded pixel dimensions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swaymsg -t get_outputs
|
||||||
|
swaymsg output <name> scale 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cursor
|
||||||
|
|
||||||
|
```text
|
||||||
|
seat * xcursor_theme Adwaita 24
|
||||||
|
XCURSOR_THEME=Adwaita
|
||||||
|
XCURSOR_SIZE=24
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `adwaita-icon-theme`. A Nebula-branded cursor theme is a later task.
|
||||||
|
|
||||||
|
`WLR_NO_HARDWARE_CURSORS=1` is VMware-specific and is set by `run-sway-vm.sh`, not by production session files.
|
||||||
|
|
||||||
|
### Emergency / development shortcuts
|
||||||
|
|
||||||
|
Normal launching, focusing, and closing is done from the Nebula GUI. These remain compositor fallbacks:
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| Super+Enter | open Foot |
|
||||||
|
| Super+Left Mouse | move the floating window under the pointer |
|
||||||
|
| Super+Right Mouse | resize the floating window under the pointer |
|
||||||
|
| Super+Left | snap to left half of the usable area |
|
||||||
|
| Super+Right | snap to right half of the usable area |
|
||||||
|
| Super+Up | maximise to the usable area (below the top bar) |
|
||||||
|
| Super+Down | restore to the default ~70% centred size |
|
||||||
|
| Super+Shift+Q | close the focused window |
|
||||||
|
| Super+Shift+E | exit Sway |
|
||||||
|
| Super+Shift+C | reload Sway config |
|
||||||
|
|
||||||
|
Title-bar drag and border resize should be the normal mouse path. Super+mouse is an emergency convenience.
|
||||||
|
|
||||||
|
### Rendering
|
||||||
|
|
||||||
|
`scripts/run-nebula-vm.sh` sets `QT_QUICK_BACKEND=software` for the VMware guest GPU. Production `nebula-shell` must not set that variable.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Convenience wrapper; the canonical VMware nebula-shell launcher is
|
||||||
|
# scripts/run-nebula-vm.sh
|
||||||
|
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
exec /bin/sh "${script_dir}/../../../scripts/run-nebula-vm.sh" "$@"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Recommended VMware development session entry point.
|
||||||
|
#
|
||||||
|
# Sets compositor-side development environment (cursor theme, software
|
||||||
|
# cursors if needed) and starts Sway with the Nebula development config.
|
||||||
|
# Sway then autostarts nebula-shell via scripts/run-nebula-vm.sh.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./compositor/sway/dev/run-sway-vm.sh
|
||||||
|
#
|
||||||
|
# Equivalent:
|
||||||
|
# export XCURSOR_THEME=Adwaita XCURSOR_SIZE=24
|
||||||
|
# sway -c compositor/sway/nebula-sway.conf
|
||||||
|
#
|
||||||
|
# Skip shell autostart:
|
||||||
|
# NEBULA_SHELL_AUTOSTART=0 ./compositor/sway/dev/run-sway-vm.sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
repo_root=$(CDPATH= cd -- "${script_dir}/../../.." && pwd)
|
||||||
|
config="${repo_root}/compositor/sway/nebula-sway.conf"
|
||||||
|
|
||||||
|
if command -v realpath >/dev/null 2>&1; then
|
||||||
|
repo_root=$(realpath "${repo_root}")
|
||||||
|
config=$(realpath "${config}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-NebulaOS}"
|
||||||
|
export XDG_SESSION_DESKTOP="${XDG_SESSION_DESKTOP:-nebula}"
|
||||||
|
export XCURSOR_THEME="${XCURSOR_THEME:-Adwaita}"
|
||||||
|
export XCURSOR_SIZE="${XCURSOR_SIZE:-24}"
|
||||||
|
|
||||||
|
# VMware often needs software cursors. This is development-specific.
|
||||||
|
export WLR_NO_HARDWARE_CURSORS="${WLR_NO_HARDWARE_CURSORS:-1}"
|
||||||
|
|
||||||
|
if [ ! -f "${config}" ]; then
|
||||||
|
echo "nebula: missing Sway config at ${config}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -q '^[[:space:]]*title_format\>' "${config}"; then
|
||||||
|
echo "nebula: refusing to start; ${config} still contains title_format" >&2
|
||||||
|
echo "nebula: this Sway build does not support that command." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "nebula: repo ${repo_root}"
|
||||||
|
echo "nebula: config ${config}"
|
||||||
|
echo "nebula: sway $(command -v sway 2>/dev/null || echo missing)"
|
||||||
|
|
||||||
|
if [ -n "${SWAYSOCK:-}" ]; then
|
||||||
|
echo "nebula: SWAYSOCK is set to ${SWAYSOCK}" >&2
|
||||||
|
echo "nebula: you are already inside a compositor session." >&2
|
||||||
|
echo "nebula: log out of that session or switch to a TTY (Ctrl+Alt+F3)," >&2
|
||||||
|
echo "nebula: then start Sway from ~/Nebula-OS so this config is used." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${repo_root}"
|
||||||
|
echo "nebula: starting Sway with ${config}"
|
||||||
|
exec sway -c "${config}" "$@"
|
||||||
@@ -1,17 +1,127 @@
|
|||||||
# NebulaOS Sway configuration
|
|
||||||
|
|
||||||
include /etc/sway/config.d/*
|
|
||||||
|
|
||||||
set $mod Mod4
|
set $mod Mod4
|
||||||
|
set $term foot
|
||||||
|
|
||||||
input type:keyboard {
|
# NebulaOS temporary development compositor configuration
|
||||||
xkb_layout us
|
#
|
||||||
}
|
# Sway is invisible infrastructure. Nebula Desktop is the visible shell.
|
||||||
|
# Do NOT add a bar block; Nebula provides its own top bar.
|
||||||
|
#
|
||||||
|
# Nebula Desktop 0.2 is floating-first: ordinary application windows open
|
||||||
|
# as independently movable/resizable floating windows, not tiled to fill
|
||||||
|
# the workspace. Sway remains the temporary compositor/window manager.
|
||||||
|
# Custom Nebula window decoration is later work.
|
||||||
|
|
||||||
output * bg #1a1a2e solid_color
|
# -----------------------------------------------------------------------------
|
||||||
|
# Output / resolution
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Development default: 1:1 scale, fill the current display.
|
||||||
|
# No mode is pinned, so Sway uses the output's preferred mode (typically the
|
||||||
|
# native or current size). VMware Autofit Guest can then follow the host window.
|
||||||
|
#
|
||||||
|
# Inspect modes: swaymsg -t get_outputs
|
||||||
|
# Force another mode (example): swaymsg output <name> mode 1600x900
|
||||||
|
output * scale 1
|
||||||
|
output * bg #000000 solid_color
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Cursor
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# The compositor owns the pointer cursor, not QML.
|
||||||
|
# A branded Nebula cursor theme is a later task.
|
||||||
|
seat * xcursor_theme Adwaita 24
|
||||||
|
# If the cursor is invisible in VMware, start Sway with:
|
||||||
|
# WLR_NO_HARDWARE_CURSORS=1 sway -c compositor/sway/nebula-sway.conf
|
||||||
|
# compositor/sway/dev/run-sway-vm.sh sets that automatically.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Focus / pointer
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Do not warp the pointer when focusing windows; this is a mouse-first desktop.
|
||||||
|
mouse_warping none
|
||||||
|
focus_follows_mouse yes
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Temporary window decorations
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Sway draws a restrained title bar and border on normal application windows
|
||||||
|
# so they can be identified, dragged, and resized with the mouse.
|
||||||
|
#
|
||||||
|
# Nebula overlays traffic-light controls on the focused window title bar.
|
||||||
|
# Menu-style chrome (application name, future File/Edit) stays in the top bar.
|
||||||
|
#
|
||||||
|
# This is temporary infrastructure until Nebula owns server-side decorations.
|
||||||
|
# Layer-shell surfaces (desktop, top bar, launcher, window chrome) are not
|
||||||
|
# xdg_shell/XWayland views and are not decorated by these settings.
|
||||||
|
font pango:Ubuntu 10
|
||||||
|
titlebar_padding 14 10
|
||||||
|
titlebar_border_thickness 1
|
||||||
|
default_border normal 1
|
||||||
|
default_floating_border normal 1
|
||||||
|
hide_edge_borders none
|
||||||
|
|
||||||
|
# class border backgr. text indicator child_border
|
||||||
|
client.focused #3d4d62 #1a222e #e8ecf2 #7ea0c4 #3d4d62
|
||||||
|
client.focused_inactive #2a3342 #141a24 #8e99ab #2a3342 #2a3342
|
||||||
|
client.unfocused #1a2029 #10151c #6b7587 #1a2029 #1a2029
|
||||||
|
client.urgent #c98888 #1a222e #e8ecf2 #c98888 #c98888
|
||||||
|
client.placeholder #1a2029 #0b0f16 #6b7587 #1a2029 #1a2029
|
||||||
|
client.background #0b0f16
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Floating-first application windows
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Match only ordinary application views:
|
||||||
|
# Wayland → shell="xdg_shell" (app_id)
|
||||||
|
# XWayland → shell="xwayland" (class)
|
||||||
|
#
|
||||||
|
# Layer-shell clients (Nebula desktop, top bar, launcher, overlays) are a
|
||||||
|
# different shell and are not matched. They stay on BACKGROUND / TOP / OVERLAY.
|
||||||
|
#
|
||||||
|
# Size is a percentage of the *workspace* (ppt). Sway workspace geometry
|
||||||
|
# already excludes the 40px Nebula top-bar exclusive zone, so 70% × centred
|
||||||
|
# stays in the usable work area on any resolution. Do not hard-code pixels.
|
||||||
|
|
||||||
|
for_window [shell="xdg_shell"] floating enable, border normal, resize set width 70 ppt height 70 ppt, move position center
|
||||||
|
for_window [shell="xwayland"] floating enable, border normal, resize set width 70 ppt height 70 ppt, move position center
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Mouse move and resize
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Title bar: drag. Window edges: resize.
|
||||||
|
# Development fallback (does not replace title-bar interaction):
|
||||||
|
# Super + Left Mouse → move
|
||||||
|
# Super + Right Mouse → resize
|
||||||
|
floating_modifier $mod normal
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Development snap shortcuts
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Preparation for later Snap Layouts. Keyboard is not the only way to arrange
|
||||||
|
# windows; the mouse still moves and resizes floating windows directly.
|
||||||
|
# Percentages are workspace-relative (usable area below the top bar).
|
||||||
|
bindsym $mod+Left resize set width 50 ppt height 100 ppt, move position 0 ppt 0 ppt
|
||||||
|
bindsym $mod+Right resize set width 50 ppt height 100 ppt, move position 50 ppt 0 ppt
|
||||||
|
bindsym $mod+Up resize set width 100 ppt height 100 ppt, move position 0 ppt 0 ppt
|
||||||
|
bindsym $mod+Down resize set width 70 ppt height 70 ppt, move position center
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Nebula shell autostart
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Starts nebula-shell through the VMware development wrapper, which sets
|
||||||
|
# QT_QUICK_BACKEND=software. Production sessions must start nebula-shell
|
||||||
|
# without that variable.
|
||||||
|
#
|
||||||
|
# Disable autostart for debugging:
|
||||||
|
# NEBULA_SHELL_AUTOSTART=0 sway -c compositor/sway/nebula-sway.conf
|
||||||
|
#
|
||||||
|
# Run from the repository root so this relative path resolves, or use
|
||||||
|
# compositor/sway/dev/run-sway-vm.sh which does that for you.
|
||||||
|
exec /bin/sh scripts/run-nebula-vm.sh
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Emergency development shortcuts (not the normal UX)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
bindsym $mod+Return exec $term
|
bindsym $mod+Return exec $term
|
||||||
bindsym $mod+Shift+q kill
|
bindsym $mod+Shift+q kill
|
||||||
bindsym $mod+d exec $menu
|
bindsym $mod+Shift+e exec swaymsg exit
|
||||||
|
bindsym $mod+Shift+c reload
|
||||||
exec_always nebula-session
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#include "ApplicationFilterModel.hpp"
|
||||||
|
#include "ApplicationModel.hpp"
|
||||||
|
|
||||||
|
#include <QtGlobal>
|
||||||
|
|
||||||
|
ApplicationFilterModel::ApplicationFilterModel(QObject *parent)
|
||||||
|
: QSortFilterProxyModel(parent)
|
||||||
|
{
|
||||||
|
setDynamicSortFilter(true);
|
||||||
|
setFilterCaseSensitivity(Qt::CaseInsensitive);
|
||||||
|
|
||||||
|
connect(this, &QAbstractItemModel::modelReset, this, &ApplicationFilterModel::countChanged);
|
||||||
|
connect(this, &QAbstractItemModel::rowsInserted, this, &ApplicationFilterModel::countChanged);
|
||||||
|
connect(this, &QAbstractItemModel::rowsRemoved, this, &ApplicationFilterModel::countChanged);
|
||||||
|
connect(this, &QAbstractItemModel::layoutChanged, this, &ApplicationFilterModel::countChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ApplicationFilterModel::filter() const
|
||||||
|
{
|
||||||
|
return m_filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApplicationFilterModel::setFilter(const QString &filter)
|
||||||
|
{
|
||||||
|
const QString trimmed = filter.trimmed();
|
||||||
|
if (m_filter == trimmed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_filter = trimmed;
|
||||||
|
#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0)
|
||||||
|
beginFilterChange();
|
||||||
|
endFilterChange();
|
||||||
|
#else
|
||||||
|
invalidateFilter();
|
||||||
|
#endif
|
||||||
|
emit filterChanged();
|
||||||
|
emit countChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
int ApplicationFilterModel::count() const
|
||||||
|
{
|
||||||
|
return rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ApplicationFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
|
||||||
|
{
|
||||||
|
if (m_filter.isEmpty())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent);
|
||||||
|
if (!index.isValid())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const auto matches = [this, &index](int role) {
|
||||||
|
return index.data(role).toString().contains(m_filter, Qt::CaseInsensitive);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (matches(ApplicationModel::NameRole)
|
||||||
|
|| matches(ApplicationModel::GenericNameRole)
|
||||||
|
|| matches(ApplicationModel::CommentRole)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList categories = index.data(ApplicationModel::CategoriesRole).toStringList();
|
||||||
|
for (const QString &category : categories) {
|
||||||
|
if (category.contains(m_filter, Qt::CaseInsensitive))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QSortFilterProxyModel>
|
||||||
|
#include <QtQml/qqmlregistration.h>
|
||||||
|
|
||||||
|
class ApplicationFilterModel : public QSortFilterProxyModel
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_UNCREATABLE("Obtained from ApplicationService")
|
||||||
|
Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged)
|
||||||
|
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ApplicationFilterModel(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString filter() const;
|
||||||
|
void setFilter(const QString &filter);
|
||||||
|
int count() const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void filterChanged();
|
||||||
|
void countChanged();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_filter;
|
||||||
|
};
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#include "ApplicationModel.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
ApplicationModel::ApplicationModel(QObject *parent)
|
||||||
|
: QAbstractListModel(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
int ApplicationModel::rowCount(const QModelIndex &parent) const
|
||||||
|
{
|
||||||
|
if (parent.isValid())
|
||||||
|
return 0;
|
||||||
|
return m_entries.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
int ApplicationModel::count() const
|
||||||
|
{
|
||||||
|
return m_entries.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant ApplicationModel::data(const QModelIndex &index, int role) const
|
||||||
|
{
|
||||||
|
if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const DesktopEntry &entry = m_entries.at(index.row());
|
||||||
|
switch (role) {
|
||||||
|
case DesktopIdRole:
|
||||||
|
case Qt::UserRole:
|
||||||
|
return entry.desktopId;
|
||||||
|
case NameRole:
|
||||||
|
case Qt::DisplayRole:
|
||||||
|
return entry.name;
|
||||||
|
case GenericNameRole:
|
||||||
|
return entry.genericName;
|
||||||
|
case CommentRole:
|
||||||
|
return entry.comment;
|
||||||
|
case IconNameRole:
|
||||||
|
return entry.icon;
|
||||||
|
case CategoriesRole:
|
||||||
|
return entry.categories;
|
||||||
|
case TerminalRole:
|
||||||
|
return entry.terminal;
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<int, QByteArray> ApplicationModel::roleNames() const
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
{DesktopIdRole, QByteArrayLiteral("desktopId")},
|
||||||
|
{NameRole, QByteArrayLiteral("name")},
|
||||||
|
{GenericNameRole, QByteArrayLiteral("genericName")},
|
||||||
|
{CommentRole, QByteArrayLiteral("comment")},
|
||||||
|
{IconNameRole, QByteArrayLiteral("iconName")},
|
||||||
|
{CategoriesRole, QByteArrayLiteral("categories")},
|
||||||
|
{TerminalRole, QByteArrayLiteral("terminal")},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApplicationModel::setEntries(QVector<DesktopEntry> entries)
|
||||||
|
{
|
||||||
|
std::sort(entries.begin(), entries.end(), [](const DesktopEntry &left, const DesktopEntry &right) {
|
||||||
|
const int order = QString::localeAwareCompare(left.name, right.name);
|
||||||
|
if (order != 0)
|
||||||
|
return order < 0;
|
||||||
|
return left.desktopId < right.desktopId;
|
||||||
|
});
|
||||||
|
|
||||||
|
beginResetModel();
|
||||||
|
m_entries = std::move(entries);
|
||||||
|
endResetModel();
|
||||||
|
emit countChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
const DesktopEntry *ApplicationModel::entryAt(int row) const
|
||||||
|
{
|
||||||
|
if (row < 0 || row >= m_entries.size())
|
||||||
|
return nullptr;
|
||||||
|
return &m_entries.at(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DesktopEntry *ApplicationModel::entryById(const QString &desktopId) const
|
||||||
|
{
|
||||||
|
for (const DesktopEntry &entry : m_entries) {
|
||||||
|
if (entry.desktopId == desktopId)
|
||||||
|
return &entry;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DesktopEntry *ApplicationModel::entryMatchingWindow(const QString &appId, const QString &windowClass) const
|
||||||
|
{
|
||||||
|
auto stem = [](QString value) {
|
||||||
|
if (value.endsWith(QLatin1String(".desktop"), Qt::CaseInsensitive))
|
||||||
|
value.chop(8);
|
||||||
|
return value.toLower();
|
||||||
|
};
|
||||||
|
|
||||||
|
const QString app = stem(appId);
|
||||||
|
const QString cls = stem(windowClass);
|
||||||
|
const QString appLast = app.contains(QLatin1Char('.')) ? app.section(QLatin1Char('.'), -1) : app;
|
||||||
|
const QString classLast = cls.contains(QLatin1Char('.')) ? cls.section(QLatin1Char('.'), -1) : cls;
|
||||||
|
|
||||||
|
const DesktopEntry *wmClassMatch = nullptr;
|
||||||
|
const DesktopEntry *lastComponentMatch = nullptr;
|
||||||
|
|
||||||
|
for (const DesktopEntry &entry : m_entries) {
|
||||||
|
const QString id = stem(entry.desktopId);
|
||||||
|
if (!app.isEmpty() && id == app)
|
||||||
|
return &entry;
|
||||||
|
if (!cls.isEmpty() && id == cls)
|
||||||
|
return &entry;
|
||||||
|
|
||||||
|
if (!entry.startupWmClass.isEmpty()) {
|
||||||
|
const QString wm = stem(entry.startupWmClass);
|
||||||
|
if ((!app.isEmpty() && wm == app) || (!cls.isEmpty() && wm == cls)
|
||||||
|
|| (!appLast.isEmpty() && wm == appLast) || (!classLast.isEmpty() && wm == classLast)) {
|
||||||
|
if (!wmClassMatch)
|
||||||
|
wmClassMatch = &entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastComponentMatch) {
|
||||||
|
const QString idLast = id.contains(QLatin1Char('.')) ? id.section(QLatin1Char('.'), -1) : id;
|
||||||
|
if (appLast.size() >= 3 && (id == appLast || idLast == appLast))
|
||||||
|
lastComponentMatch = &entry;
|
||||||
|
else if (classLast.size() >= 3 && (id == classLast || idLast == classLast))
|
||||||
|
lastComponentMatch = &entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wmClassMatch)
|
||||||
|
return wmClassMatch;
|
||||||
|
return lastComponentMatch;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "DesktopEntry.hpp"
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QVector>
|
||||||
|
#include <QtQml/qqmlregistration.h>
|
||||||
|
|
||||||
|
class ApplicationModel : public QAbstractListModel
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_UNCREATABLE("Obtained from ApplicationService")
|
||||||
|
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum Roles {
|
||||||
|
DesktopIdRole = Qt::UserRole + 1,
|
||||||
|
NameRole,
|
||||||
|
GenericNameRole,
|
||||||
|
CommentRole,
|
||||||
|
IconNameRole,
|
||||||
|
CategoriesRole,
|
||||||
|
TerminalRole
|
||||||
|
};
|
||||||
|
Q_ENUM(Roles)
|
||||||
|
|
||||||
|
explicit ApplicationModel(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
|
QHash<int, QByteArray> roleNames() const override;
|
||||||
|
|
||||||
|
int count() const;
|
||||||
|
void setEntries(QVector<DesktopEntry> entries);
|
||||||
|
const DesktopEntry *entryAt(int row) const;
|
||||||
|
const DesktopEntry *entryById(const QString &desktopId) const;
|
||||||
|
const DesktopEntry *entryMatchingWindow(const QString &appId, const QString &windowClass) const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void countChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QVector<DesktopEntry> m_entries;
|
||||||
|
};
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#include "ApplicationService.hpp"
|
||||||
|
|
||||||
|
#include "DesktopEntry.hpp"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QDirIterator>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QVector>
|
||||||
|
#include <QtGlobal>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
ApplicationService *s_instance = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplicationService::ApplicationService(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_model(new ApplicationModel(this))
|
||||||
|
, m_filtered(new ApplicationFilterModel(this))
|
||||||
|
{
|
||||||
|
if (!s_instance)
|
||||||
|
s_instance = this;
|
||||||
|
|
||||||
|
m_filtered->setSourceModel(m_model);
|
||||||
|
m_filtered->setSortRole(ApplicationModel::NameRole);
|
||||||
|
m_filtered->sort(0, Qt::AscendingOrder);
|
||||||
|
|
||||||
|
connect(m_model, &ApplicationModel::countChanged, this, &ApplicationService::countChanged);
|
||||||
|
connect(m_filtered, &ApplicationFilterModel::filterChanged, this, &ApplicationService::filterChanged);
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplicationService::~ApplicationService()
|
||||||
|
{
|
||||||
|
if (s_instance == this)
|
||||||
|
s_instance = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplicationService *ApplicationService::instance()
|
||||||
|
{
|
||||||
|
return s_instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplicationService *ApplicationService::create(QQmlEngine *engine, QJSEngine *)
|
||||||
|
{
|
||||||
|
auto *service = new ApplicationService(engine);
|
||||||
|
QQmlEngine::setObjectOwnership(service, QQmlEngine::CppOwnership);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplicationModel *ApplicationService::model() const
|
||||||
|
{
|
||||||
|
return m_model;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplicationFilterModel *ApplicationService::filteredModel() const
|
||||||
|
{
|
||||||
|
return m_filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ApplicationService::filter() const
|
||||||
|
{
|
||||||
|
return m_filtered->filter();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApplicationService::setFilter(const QString &filter)
|
||||||
|
{
|
||||||
|
m_filtered->setFilter(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
int ApplicationService::count() const
|
||||||
|
{
|
||||||
|
return m_model->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ApplicationService::launch(const QString &desktopId) const
|
||||||
|
{
|
||||||
|
const DesktopEntry *entry = m_model->entryById(desktopId);
|
||||||
|
if (!entry) {
|
||||||
|
qWarning("nebula: unknown application %s", qPrintable(desktopId));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return entry->launch();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ApplicationService::displayNameForWindow(const QString &appId, const QString &windowClass) const
|
||||||
|
{
|
||||||
|
const DesktopEntry *entry = m_model->entryMatchingWindow(appId, windowClass);
|
||||||
|
if (entry && !entry->name.isEmpty())
|
||||||
|
return entry->name;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ApplicationService::iconNameForWindow(const QString &appId, const QString &windowClass) const
|
||||||
|
{
|
||||||
|
const DesktopEntry *entry = m_model->entryMatchingWindow(appId, windowClass);
|
||||||
|
if (entry)
|
||||||
|
return entry->icon;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApplicationService::refresh()
|
||||||
|
{
|
||||||
|
QVector<DesktopEntry> entries;
|
||||||
|
QSet<QString> seenIds;
|
||||||
|
|
||||||
|
const QStringList directories = applicationDirectories();
|
||||||
|
qInfo("nebula: scanning %d application director%s",
|
||||||
|
int(directories.size()),
|
||||||
|
directories.size() == 1 ? "y" : "ies");
|
||||||
|
|
||||||
|
for (const QString &directory : directories) {
|
||||||
|
QDir dir(directory);
|
||||||
|
if (!dir.exists())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
QDirIterator iterator(directory, {QStringLiteral("*.desktop")}, QDir::Files, QDirIterator::Subdirectories);
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
const QString filePath = iterator.next();
|
||||||
|
QString relative = QDir(directory).relativeFilePath(filePath);
|
||||||
|
relative.replace(QLatin1Char('/'), QLatin1Char('-'));
|
||||||
|
const QString desktopId = relative;
|
||||||
|
|
||||||
|
if (seenIds.contains(desktopId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
seenIds.insert(desktopId);
|
||||||
|
DesktopEntry entry = parseDesktopEntryFile(filePath, desktopId);
|
||||||
|
if (entry.name.isEmpty())
|
||||||
|
entry.name = QFileInfo(filePath).completeBaseName();
|
||||||
|
if (!entry.isLauncherVisible())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
entries.append(std::move(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qInfo("nebula: discovered %d launcher application%s",
|
||||||
|
int(entries.size()),
|
||||||
|
entries.size() == 1 ? "" : "s");
|
||||||
|
m_model->setEntries(std::move(entries));
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "ApplicationFilterModel.hpp"
|
||||||
|
#include "ApplicationModel.hpp"
|
||||||
|
|
||||||
|
#include <QJSEngine>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QQmlEngine>
|
||||||
|
#include <QtQml/qqmlregistration.h>
|
||||||
|
|
||||||
|
class ApplicationService : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_SINGLETON
|
||||||
|
Q_PROPERTY(ApplicationModel *model READ model CONSTANT)
|
||||||
|
Q_PROPERTY(ApplicationFilterModel *filteredModel READ filteredModel CONSTANT)
|
||||||
|
Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged)
|
||||||
|
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ApplicationService(QObject *parent = nullptr);
|
||||||
|
~ApplicationService() override;
|
||||||
|
|
||||||
|
static ApplicationService *create(QQmlEngine *engine, QJSEngine *scriptEngine);
|
||||||
|
static ApplicationService *instance();
|
||||||
|
|
||||||
|
ApplicationModel *model() const;
|
||||||
|
ApplicationFilterModel *filteredModel() const;
|
||||||
|
|
||||||
|
QString filter() const;
|
||||||
|
void setFilter(const QString &filter);
|
||||||
|
int count() const;
|
||||||
|
|
||||||
|
Q_INVOKABLE bool launch(const QString &desktopId) const;
|
||||||
|
Q_INVOKABLE QString displayNameForWindow(const QString &appId, const QString &windowClass) const;
|
||||||
|
Q_INVOKABLE QString iconNameForWindow(const QString &appId, const QString &windowClass) const;
|
||||||
|
Q_INVOKABLE void refresh();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void filterChanged();
|
||||||
|
void countChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
ApplicationModel *m_model = nullptr;
|
||||||
|
ApplicationFilterModel *m_filtered = nullptr;
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
if(NOT TARGET Qt6::Qml)
|
||||||
|
project(NebulaApplications LANGUAGES CXX)
|
||||||
|
find_package(Qt6 6.4 REQUIRED COMPONENTS Core Gui Qml)
|
||||||
|
qt_standard_project_setup(REQUIRES 6.4)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
qt_add_library(NebulaApplications STATIC)
|
||||||
|
|
||||||
|
qt_add_qml_module(NebulaApplications
|
||||||
|
URI Nebula.Applications
|
||||||
|
VERSION 1.0
|
||||||
|
RESOURCE_PREFIX /qt/qml
|
||||||
|
SOURCES
|
||||||
|
DesktopEntry.hpp
|
||||||
|
DesktopEntry.cpp
|
||||||
|
ApplicationModel.hpp
|
||||||
|
ApplicationModel.cpp
|
||||||
|
ApplicationFilterModel.hpp
|
||||||
|
ApplicationFilterModel.cpp
|
||||||
|
ApplicationService.hpp
|
||||||
|
ApplicationService.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(NebulaApplications
|
||||||
|
PUBLIC
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}"
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(NebulaApplications
|
||||||
|
PUBLIC
|
||||||
|
Qt6::Core
|
||||||
|
Qt6::Gui
|
||||||
|
Qt6::Qml
|
||||||
|
)
|
||||||
|
|
||||||
|
set_target_properties(NebulaApplications PROPERTIES
|
||||||
|
AUTOMOC ON
|
||||||
|
)
|
||||||
|
|
||||||
|
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||||
|
target_compile_options(NebulaApplications PRIVATE -Wall -Wextra)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
#include "DesktopEntry.hpp"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QLocale>
|
||||||
|
#include <QMap>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QtGlobal>
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QString unquote(QString value)
|
||||||
|
{
|
||||||
|
value = value.trimmed();
|
||||||
|
if (value.size() >= 2) {
|
||||||
|
const QChar first = value.front();
|
||||||
|
const QChar last = value.back();
|
||||||
|
if ((first == QLatin1Char('"') && last == QLatin1Char('"'))
|
||||||
|
|| (first == QLatin1Char('\'') && last == QLatin1Char('\''))) {
|
||||||
|
value = value.mid(1, value.size() - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parseBool(const QString &value)
|
||||||
|
{
|
||||||
|
const QString lowered = value.trimmed().toLower();
|
||||||
|
return lowered == QLatin1String("true") || lowered == QLatin1String("1");
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocalizedValue
|
||||||
|
{
|
||||||
|
QString fallback;
|
||||||
|
QMap<QString, QString> locales;
|
||||||
|
};
|
||||||
|
|
||||||
|
void storeLocalized(LocalizedValue *target, const QString &key, const QString &prefix, const QString &value)
|
||||||
|
{
|
||||||
|
if (key == prefix) {
|
||||||
|
target->fallback = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString start = prefix + QLatin1Char('[');
|
||||||
|
if (!key.startsWith(start) || !key.endsWith(QLatin1Char(']')))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const QString locale = key.mid(start.size(), key.size() - start.size() - 1);
|
||||||
|
if (!locale.isEmpty())
|
||||||
|
target->locales.insert(locale, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString resolveLocalized(const LocalizedValue &value, const QLocale &locale)
|
||||||
|
{
|
||||||
|
const QString name = locale.name(); // e.g. en_US
|
||||||
|
const QString language = name.section(QLatin1Char('_'), 0, 0);
|
||||||
|
|
||||||
|
auto match = [&value](const QString &tag) -> QString {
|
||||||
|
if (tag.isEmpty())
|
||||||
|
return {};
|
||||||
|
return value.locales.value(tag);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (QString found = match(name); !found.isEmpty())
|
||||||
|
return found;
|
||||||
|
|
||||||
|
if (QString found = match(language); !found.isEmpty())
|
||||||
|
return found;
|
||||||
|
|
||||||
|
const int underscore = name.indexOf(QLatin1Char('_'));
|
||||||
|
if (underscore > 0) {
|
||||||
|
if (QString found = match(name.left(underscore)); !found.isEmpty())
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString findTerminalEmulator()
|
||||||
|
{
|
||||||
|
const QStringList candidates = {
|
||||||
|
QStringLiteral("foot"),
|
||||||
|
QStringLiteral("x-terminal-emulator"),
|
||||||
|
QStringLiteral("kitty"),
|
||||||
|
QStringLiteral("alacritty"),
|
||||||
|
QStringLiteral("kgx"),
|
||||||
|
QStringLiteral("gnome-terminal"),
|
||||||
|
QStringLiteral("konsole"),
|
||||||
|
QStringLiteral("xterm"),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const QString &name : candidates) {
|
||||||
|
const QString path = QStandardPaths::findExecutable(name);
|
||||||
|
if (!path.isEmpty())
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool executableAvailable(const QString &command)
|
||||||
|
{
|
||||||
|
if (command.isEmpty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const QFileInfo info(command);
|
||||||
|
if (info.isAbsolute())
|
||||||
|
return info.isExecutable();
|
||||||
|
|
||||||
|
return !QStandardPaths::findExecutable(command).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QStringList splitDesktopList(const QString &value)
|
||||||
|
{
|
||||||
|
const QStringList raw = value.split(QLatin1Char(';'), Qt::SkipEmptyParts);
|
||||||
|
QStringList cleaned;
|
||||||
|
cleaned.reserve(raw.size());
|
||||||
|
for (const QString &item : raw) {
|
||||||
|
const QString trimmed = item.trimmed();
|
||||||
|
if (!trimmed.isEmpty())
|
||||||
|
cleaned.append(trimmed);
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList currentDesktopIdentifiers()
|
||||||
|
{
|
||||||
|
const QByteArray raw = qgetenv("XDG_CURRENT_DESKTOP");
|
||||||
|
if (raw.isEmpty())
|
||||||
|
return {QStringLiteral("NebulaOS")};
|
||||||
|
|
||||||
|
QStringList ids;
|
||||||
|
for (const QByteArray &part : raw.split(':')) {
|
||||||
|
const QString id = QString::fromLocal8Bit(part).trimmed();
|
||||||
|
if (!id.isEmpty())
|
||||||
|
ids.append(id);
|
||||||
|
}
|
||||||
|
if (ids.isEmpty())
|
||||||
|
ids.append(QStringLiteral("NebulaOS"));
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList applicationDirectories()
|
||||||
|
{
|
||||||
|
QStringList dirs;
|
||||||
|
|
||||||
|
QString dataHome = QString::fromLocal8Bit(qgetenv("XDG_DATA_HOME"));
|
||||||
|
if (dataHome.isEmpty())
|
||||||
|
dataHome = QDir::homePath() + QStringLiteral("/.local/share");
|
||||||
|
dirs.append(QDir::cleanPath(dataHome + QStringLiteral("/applications")));
|
||||||
|
|
||||||
|
QString dataDirs = QString::fromLocal8Bit(qgetenv("XDG_DATA_DIRS"));
|
||||||
|
if (dataDirs.isEmpty())
|
||||||
|
dataDirs = QStringLiteral("/usr/local/share:/usr/share");
|
||||||
|
|
||||||
|
for (const QString &root : dataDirs.split(QLatin1Char(':'), Qt::SkipEmptyParts)) {
|
||||||
|
const QString trimmed = root.trimmed();
|
||||||
|
if (trimmed.isEmpty())
|
||||||
|
continue;
|
||||||
|
dirs.append(QDir::cleanPath(trimmed + QStringLiteral("/applications")));
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs.removeDuplicates();
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString resolveIconName(const QString &icon)
|
||||||
|
{
|
||||||
|
if (icon.isEmpty())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const QFileInfo info(icon);
|
||||||
|
if (info.isAbsolute())
|
||||||
|
return icon;
|
||||||
|
|
||||||
|
QString name = icon;
|
||||||
|
if (name.endsWith(QLatin1String(".png"), Qt::CaseInsensitive)
|
||||||
|
|| name.endsWith(QLatin1String(".svg"), Qt::CaseInsensitive)
|
||||||
|
|| name.endsWith(QLatin1String(".xpm"), Qt::CaseInsensitive)) {
|
||||||
|
name = QFileInfo(name).completeBaseName();
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList expandExec(const QString &exec, const DesktopEntry &entry)
|
||||||
|
{
|
||||||
|
QString expanded;
|
||||||
|
expanded.reserve(exec.size());
|
||||||
|
|
||||||
|
for (int i = 0; i < exec.size(); ++i) {
|
||||||
|
const QChar ch = exec.at(i);
|
||||||
|
if (ch != QLatin1Char('%') || i + 1 >= exec.size()) {
|
||||||
|
expanded.append(ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QChar code = exec.at(++i);
|
||||||
|
auto trimTrailingSpace = [&expanded]() {
|
||||||
|
if (expanded.endsWith(QLatin1Char(' ')))
|
||||||
|
expanded.chop(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (code.toLatin1()) {
|
||||||
|
case '%':
|
||||||
|
expanded.append(QLatin1Char('%'));
|
||||||
|
break;
|
||||||
|
case 'f':
|
||||||
|
case 'F':
|
||||||
|
case 'u':
|
||||||
|
case 'U':
|
||||||
|
case 'd':
|
||||||
|
case 'D':
|
||||||
|
case 'n':
|
||||||
|
case 'N':
|
||||||
|
case 'v':
|
||||||
|
case 'm':
|
||||||
|
// File/URL/deprecated codes are omitted when launching without arguments.
|
||||||
|
trimTrailingSpace();
|
||||||
|
break;
|
||||||
|
case 'c':
|
||||||
|
expanded.append(QLatin1Char('"'));
|
||||||
|
expanded.append(entry.name);
|
||||||
|
expanded.append(QLatin1Char('"'));
|
||||||
|
break;
|
||||||
|
case 'k':
|
||||||
|
expanded.append(QLatin1Char('"'));
|
||||||
|
expanded.append(entry.filePath);
|
||||||
|
expanded.append(QLatin1Char('"'));
|
||||||
|
break;
|
||||||
|
case 'i':
|
||||||
|
if (!entry.icon.isEmpty()) {
|
||||||
|
expanded.append(QStringLiteral("--icon \""));
|
||||||
|
expanded.append(entry.icon);
|
||||||
|
expanded.append(QLatin1Char('"'));
|
||||||
|
} else {
|
||||||
|
trimTrailingSpace();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
trimTrailingSpace();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList parts = QProcess::splitCommand(expanded.trimmed());
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DesktopEntry::isLauncherVisible() const
|
||||||
|
{
|
||||||
|
if (hidden || noDisplay)
|
||||||
|
return false;
|
||||||
|
if (exec.trimmed().isEmpty())
|
||||||
|
return false;
|
||||||
|
if (!tryExec.trimmed().isEmpty() && !executableAvailable(tryExec.trimmed()))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DesktopEntry::launch() const
|
||||||
|
{
|
||||||
|
QStringList args = expandExec(exec, *this);
|
||||||
|
if (args.isEmpty()) {
|
||||||
|
qWarning("nebula: cannot launch %s: empty Exec", qPrintable(desktopId));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString program = args.takeFirst();
|
||||||
|
if (program.isEmpty()) {
|
||||||
|
qWarning("nebula: cannot launch %s: missing program", qPrintable(desktopId));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terminal) {
|
||||||
|
const QString term = findTerminalEmulator();
|
||||||
|
if (term.isEmpty()) {
|
||||||
|
qWarning("nebula: cannot launch %s: Terminal=true but no terminal emulator was found",
|
||||||
|
qPrintable(desktopId));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QStringList wrapped;
|
||||||
|
wrapped.append(QStringLiteral("-e"));
|
||||||
|
wrapped.append(program);
|
||||||
|
wrapped.append(args);
|
||||||
|
args = std::move(wrapped);
|
||||||
|
program = term;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString cwd = workingDirectory.isEmpty() ? QDir::homePath() : workingDirectory;
|
||||||
|
qint64 pid = 0;
|
||||||
|
if (!QProcess::startDetached(program, args, cwd, &pid)) {
|
||||||
|
qWarning("nebula: failed to launch %s (%s)", qPrintable(desktopId), qPrintable(program));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
qInfo("nebula: launched %s (%s) pid %lld", qPrintable(desktopId), qPrintable(program), static_cast<long long>(pid));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
DesktopEntry parseDesktopEntryFile(const QString &filePath, const QString &desktopId)
|
||||||
|
{
|
||||||
|
DesktopEntry entry;
|
||||||
|
entry.filePath = filePath;
|
||||||
|
entry.desktopId = desktopId;
|
||||||
|
|
||||||
|
QFile file(filePath);
|
||||||
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||||
|
return entry;
|
||||||
|
|
||||||
|
LocalizedValue names;
|
||||||
|
LocalizedValue genericNames;
|
||||||
|
LocalizedValue comments;
|
||||||
|
QString type = QStringLiteral("Application");
|
||||||
|
QStringList onlyShowIn;
|
||||||
|
QStringList notShowIn;
|
||||||
|
bool inDesktopEntry = false;
|
||||||
|
|
||||||
|
while (!file.atEnd()) {
|
||||||
|
QString line = QString::fromUtf8(file.readLine());
|
||||||
|
if (!line.isEmpty() && line.back() == QLatin1Char('\n'))
|
||||||
|
line.chop(1);
|
||||||
|
if (!line.isEmpty() && line.back() == QLatin1Char('\r'))
|
||||||
|
line.chop(1);
|
||||||
|
|
||||||
|
const QString trimmed = line.trimmed();
|
||||||
|
if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char('#')))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (trimmed.startsWith(QLatin1Char('[')) && trimmed.endsWith(QLatin1Char(']'))) {
|
||||||
|
inDesktopEntry = (trimmed == QLatin1String("[Desktop Entry]"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!inDesktopEntry)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const int equals = trimmed.indexOf(QLatin1Char('='));
|
||||||
|
if (equals <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const QString key = trimmed.left(equals).trimmed();
|
||||||
|
const QString value = unquote(trimmed.mid(equals + 1));
|
||||||
|
|
||||||
|
if (key == QLatin1String("Type"))
|
||||||
|
type = value;
|
||||||
|
else if (key == QLatin1String("Exec"))
|
||||||
|
entry.exec = value;
|
||||||
|
else if (key == QLatin1String("TryExec"))
|
||||||
|
entry.tryExec = value;
|
||||||
|
else if (key == QLatin1String("StartupWMClass"))
|
||||||
|
entry.startupWmClass = value;
|
||||||
|
else if (key == QLatin1String("Icon"))
|
||||||
|
entry.icon = value;
|
||||||
|
else if (key == QLatin1String("Path"))
|
||||||
|
entry.workingDirectory = value;
|
||||||
|
else if (key == QLatin1String("Categories"))
|
||||||
|
entry.categories = splitDesktopList(value);
|
||||||
|
else if (key == QLatin1String("Terminal"))
|
||||||
|
entry.terminal = parseBool(value);
|
||||||
|
else if (key == QLatin1String("Hidden"))
|
||||||
|
entry.hidden = parseBool(value);
|
||||||
|
else if (key == QLatin1String("NoDisplay"))
|
||||||
|
entry.noDisplay = parseBool(value);
|
||||||
|
else if (key == QLatin1String("OnlyShowIn"))
|
||||||
|
onlyShowIn = splitDesktopList(value);
|
||||||
|
else if (key == QLatin1String("NotShowIn"))
|
||||||
|
notShowIn = splitDesktopList(value);
|
||||||
|
else {
|
||||||
|
storeLocalized(&names, key, QStringLiteral("Name"), value);
|
||||||
|
storeLocalized(&genericNames, key, QStringLiteral("GenericName"), value);
|
||||||
|
storeLocalized(&comments, key, QStringLiteral("Comment"), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const QLocale locale = QLocale::system();
|
||||||
|
entry.name = resolveLocalized(names, locale);
|
||||||
|
entry.genericName = resolveLocalized(genericNames, locale);
|
||||||
|
entry.comment = resolveLocalized(comments, locale);
|
||||||
|
entry.icon = resolveIconName(entry.icon);
|
||||||
|
|
||||||
|
if (entry.desktopId.isEmpty())
|
||||||
|
entry.desktopId = QFileInfo(filePath).fileName();
|
||||||
|
|
||||||
|
if (type != QLatin1String("Application")) {
|
||||||
|
entry.hidden = true;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList desktops = currentDesktopIdentifiers();
|
||||||
|
if (!onlyShowIn.isEmpty()) {
|
||||||
|
bool matched = false;
|
||||||
|
for (const QString &desktop : desktops) {
|
||||||
|
if (onlyShowIn.contains(desktop)) {
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched)
|
||||||
|
entry.noDisplay = true;
|
||||||
|
}
|
||||||
|
for (const QString &desktop : desktops) {
|
||||||
|
if (notShowIn.contains(desktop)) {
|
||||||
|
entry.noDisplay = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
struct DesktopEntry
|
||||||
|
{
|
||||||
|
QString desktopId;
|
||||||
|
QString filePath;
|
||||||
|
QString name;
|
||||||
|
QString genericName;
|
||||||
|
QString comment;
|
||||||
|
QString icon;
|
||||||
|
QString exec;
|
||||||
|
QString tryExec;
|
||||||
|
QString startupWmClass;
|
||||||
|
QString workingDirectory;
|
||||||
|
QStringList categories;
|
||||||
|
bool terminal = false;
|
||||||
|
bool hidden = false;
|
||||||
|
bool noDisplay = false;
|
||||||
|
|
||||||
|
bool isLauncherVisible() const;
|
||||||
|
bool launch() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
DesktopEntry parseDesktopEntryFile(const QString &filePath, const QString &desktopId);
|
||||||
|
QStringList applicationDirectories();
|
||||||
|
QStringList currentDesktopIdentifiers();
|
||||||
|
QStringList splitDesktopList(const QString &value);
|
||||||
|
QStringList expandExec(const QString &exec, const DesktopEntry &entry);
|
||||||
|
QString resolveIconName(const QString &icon);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Nebula Applications
|
||||||
|
|
||||||
|
Shared C++ service for discovering and launching installed Linux applications from `.desktop` entries.
|
||||||
|
|
||||||
|
Desktop and the future Bigscreen shell should both use this library. It has no Desktop-specific QML. Window naming helpers (`displayNameForWindow`, `iconNameForWindow`) are intended for `WindowService`.
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
- Scans XDG application directories (`XDG_DATA_HOME`, `XDG_DATA_DIRS`, with the usual `~/.local/share` and `/usr/share` defaults).
|
||||||
|
- Hides entries with `Hidden=true` or `NoDisplay=true`, and respects `OnlyShowIn` / `NotShowIn` / `TryExec`.
|
||||||
|
- Exposes a `QAbstractListModel` to QML (`desktopId`, `name`, `genericName`, `comment`, `iconName`, `categories`, `terminal`).
|
||||||
|
- Parses `StartupWMClass` and can map a compositor `app_id` / window class to a friendly `Name` (`displayNameForWindow`, `iconNameForWindow`) for `WindowService`.
|
||||||
|
- Launches with `QProcess::startDetached` and `QProcess::splitCommand`. Does not use a shell.
|
||||||
|
- Strips or substitutes common Exec field codes when launching without files/URLs.
|
||||||
|
|
||||||
|
QML module URI: `Nebula.Applications`.
|
||||||
|
|
||||||
|
See `shells/desktop/README.md` for launcher behaviour and session integration.
|
||||||
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
if(NOT TARGET Qt6::Qml)
|
||||||
|
project(NebulaWindows LANGUAGES CXX)
|
||||||
|
find_package(Qt6 6.4 REQUIRED COMPONENTS Core Gui Qml Network)
|
||||||
|
qt_standard_project_setup(REQUIRES 6.4)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT TARGET Qt6::Network)
|
||||||
|
find_package(Qt6 6.4 REQUIRED COMPONENTS Network)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT TARGET NebulaApplications)
|
||||||
|
add_subdirectory(
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/../applications"
|
||||||
|
"${CMAKE_CURRENT_BINARY_DIR}/nebula-applications"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
qt_add_library(NebulaWindows STATIC)
|
||||||
|
|
||||||
|
qt_add_qml_module(NebulaWindows
|
||||||
|
URI Nebula.Windows
|
||||||
|
VERSION 1.0
|
||||||
|
RESOURCE_PREFIX /qt/qml
|
||||||
|
SOURCES
|
||||||
|
WindowTypes.hpp
|
||||||
|
WindowBackend.hpp
|
||||||
|
WindowModel.hpp
|
||||||
|
WindowModel.cpp
|
||||||
|
WorkspaceModel.hpp
|
||||||
|
WorkspaceModel.cpp
|
||||||
|
WindowService.hpp
|
||||||
|
WindowService.cpp
|
||||||
|
backends/sway/SwayIpcClient.hpp
|
||||||
|
backends/sway/SwayIpcClient.cpp
|
||||||
|
backends/sway/SwayWindowBackend.hpp
|
||||||
|
backends/sway/SwayWindowBackend.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(NebulaWindows
|
||||||
|
PUBLIC
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}"
|
||||||
|
PRIVATE
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/backends/sway"
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(NebulaWindows
|
||||||
|
PUBLIC
|
||||||
|
Qt6::Core
|
||||||
|
Qt6::Gui
|
||||||
|
Qt6::Qml
|
||||||
|
Qt6::Network
|
||||||
|
NebulaApplications
|
||||||
|
)
|
||||||
|
|
||||||
|
set_target_properties(NebulaWindows PROPERTIES
|
||||||
|
AUTOMOC ON
|
||||||
|
)
|
||||||
|
|
||||||
|
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||||
|
target_compile_options(NebulaWindows PRIVATE -Wall -Wextra)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Nebula Windows
|
||||||
|
|
||||||
|
Shared C++ window-management service. Desktop, the future Bigscreen shell, and a future Nebula compositor backend should all talk to this library rather than to a compositor directly.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nebula QML
|
||||||
|
↓
|
||||||
|
WindowService
|
||||||
|
↓
|
||||||
|
generic WindowBackend
|
||||||
|
↓
|
||||||
|
Sway IPC now
|
||||||
|
Nebula compositor later
|
||||||
|
```
|
||||||
|
|
||||||
|
QML must not run `swaymsg`. Sway-specific protocol, socket handling, and tree parsing stay in `backends/sway/`.
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
- Connects to the compositor IPC socket from `SWAYSOCK`. The path is never hard-coded.
|
||||||
|
- If IPC is missing or fails, the shell keeps running and WindowService stays empty. A development warning is logged.
|
||||||
|
- Subscribes to `window`, `workspace`, and `output` events instead of polling.
|
||||||
|
- Exposes `WindowModel` (`QAbstractListModel`) and `WorkspaceModel`.
|
||||||
|
- Tracks the focused *normal application* window. Layer-shell chrome (desktop, top bar, launcher) is not treated as the active app.
|
||||||
|
- Resolves a user-facing name via `ApplicationService` when possible, then a small identifier humanizer (`footclient` → Foot, `org.mozilla.firefox` → Firefox).
|
||||||
|
|
||||||
|
QML module URI: `Nebula.Windows`.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
`focusWindow`, `closeWindow`, `maximizeWindow`, `restoreWindow`, `setWindowFloating`, plus `moveWindow` / `resizeWindow` / `snapWindow` / `switchWorkspace`.
|
||||||
|
|
||||||
|
`closeWindow` asks the compositor to close the client. It does not kill the process.
|
||||||
|
|
||||||
|
Maximise fills the current workspace usable area (below the Nebula exclusive zone) using compositor-relative sizing. Restore returns the previous floating geometry when WindowService saved it.
|
||||||
|
|
||||||
|
Minimise is implemented for the Sway backend using scratchpad semantics, but the UI must not mention scratchpad. The user-facing idea is only hide / show.
|
||||||
|
|
||||||
|
Snap edges (`SnapLeft`, `SnapRight`, `SnapMaximize`) are prepared for a later Snap Layouts UI.
|
||||||
|
|
||||||
|
See `shells/desktop/README.md` and `compositor/README.md` for session behaviour.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "WindowTypes.hpp"
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
class WindowBackend : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum SnapEdge {
|
||||||
|
SnapLeft = 0,
|
||||||
|
SnapRight,
|
||||||
|
SnapMaximize
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit WindowBackend(QObject *parent = nullptr)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
~WindowBackend() override = default;
|
||||||
|
|
||||||
|
virtual bool isConnected() const = 0;
|
||||||
|
virtual QVector<WindowInfo> windows() const = 0;
|
||||||
|
virtual QVector<WorkspaceInfo> workspaces() const = 0;
|
||||||
|
virtual qint64 focusedWindowId() const = 0;
|
||||||
|
|
||||||
|
virtual bool focusWindow(qint64 windowId) = 0;
|
||||||
|
virtual bool closeWindow(qint64 windowId) = 0;
|
||||||
|
virtual bool maximizeWindow(qint64 windowId) = 0;
|
||||||
|
virtual bool restoreWindow(qint64 windowId) = 0;
|
||||||
|
virtual bool minimizeWindow(qint64 windowId) = 0;
|
||||||
|
virtual bool setWindowFloating(qint64 windowId, bool floating) = 0;
|
||||||
|
virtual bool moveWindow(qint64 windowId, int x, int y) = 0;
|
||||||
|
virtual bool resizeWindow(qint64 windowId, int width, int height) = 0;
|
||||||
|
virtual bool snapWindow(qint64 windowId, SnapEdge edge) = 0;
|
||||||
|
virtual bool switchWorkspace(const QString &workspaceId) = 0;
|
||||||
|
virtual bool updateFocusedGeometry() { return false; }
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void connectedChanged();
|
||||||
|
void windowsChanged();
|
||||||
|
void workspacesChanged();
|
||||||
|
void focusChanged();
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#include "WindowModel.hpp"
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
WindowModel::WindowModel(QObject *parent)
|
||||||
|
: QAbstractListModel(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowModel::rowCount(const QModelIndex &parent) const
|
||||||
|
{
|
||||||
|
if (parent.isValid())
|
||||||
|
return 0;
|
||||||
|
return m_windows.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowModel::count() const
|
||||||
|
{
|
||||||
|
return m_windows.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant WindowModel::data(const QModelIndex &index, int role) const
|
||||||
|
{
|
||||||
|
if (!index.isValid() || index.row() < 0 || index.row() >= m_windows.size())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const WindowInfo &window = m_windows.at(index.row());
|
||||||
|
switch (role) {
|
||||||
|
case WindowIdRole:
|
||||||
|
return QString::number(window.id);
|
||||||
|
case TitleRole:
|
||||||
|
return window.title;
|
||||||
|
case AppIdRole:
|
||||||
|
return window.appId;
|
||||||
|
case WindowClassRole:
|
||||||
|
return window.windowClass;
|
||||||
|
case WorkspaceRole:
|
||||||
|
return window.workspace;
|
||||||
|
case FocusedRole:
|
||||||
|
return window.focused;
|
||||||
|
case FloatingRole:
|
||||||
|
return window.floating;
|
||||||
|
case FullscreenRole:
|
||||||
|
return window.fullscreen;
|
||||||
|
case XRole:
|
||||||
|
return window.x;
|
||||||
|
case YRole:
|
||||||
|
return window.y;
|
||||||
|
case WidthRole:
|
||||||
|
return window.width;
|
||||||
|
case HeightRole:
|
||||||
|
return window.height;
|
||||||
|
case DisplayNameRole:
|
||||||
|
case Qt::DisplayRole:
|
||||||
|
return window.displayName.isEmpty() ? window.title : window.displayName;
|
||||||
|
case IconNameRole:
|
||||||
|
return window.iconName;
|
||||||
|
case MinimizedRole:
|
||||||
|
return window.minimized;
|
||||||
|
case MaximizedRole:
|
||||||
|
return window.maximized;
|
||||||
|
case OutputRole:
|
||||||
|
return window.output;
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<int, QByteArray> WindowModel::roleNames() const
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
{WindowIdRole, QByteArrayLiteral("windowId")},
|
||||||
|
{TitleRole, QByteArrayLiteral("title")},
|
||||||
|
{AppIdRole, QByteArrayLiteral("appId")},
|
||||||
|
{WindowClassRole, QByteArrayLiteral("windowClass")},
|
||||||
|
{WorkspaceRole, QByteArrayLiteral("workspace")},
|
||||||
|
{FocusedRole, QByteArrayLiteral("focused")},
|
||||||
|
{FloatingRole, QByteArrayLiteral("floating")},
|
||||||
|
{FullscreenRole, QByteArrayLiteral("fullscreen")},
|
||||||
|
{XRole, QByteArrayLiteral("x")},
|
||||||
|
{YRole, QByteArrayLiteral("y")},
|
||||||
|
{WidthRole, QByteArrayLiteral("width")},
|
||||||
|
{HeightRole, QByteArrayLiteral("height")},
|
||||||
|
{DisplayNameRole, QByteArrayLiteral("displayName")},
|
||||||
|
{IconNameRole, QByteArrayLiteral("iconName")},
|
||||||
|
{MinimizedRole, QByteArrayLiteral("minimized")},
|
||||||
|
{MaximizedRole, QByteArrayLiteral("maximized")},
|
||||||
|
{OutputRole, QByteArrayLiteral("output")},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void WindowModel::setWindows(QVector<WindowInfo> windows)
|
||||||
|
{
|
||||||
|
beginResetModel();
|
||||||
|
m_windows = std::move(windows);
|
||||||
|
endResetModel();
|
||||||
|
emit countChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
const WindowInfo *WindowModel::windowAt(int row) const
|
||||||
|
{
|
||||||
|
if (row < 0 || row >= m_windows.size())
|
||||||
|
return nullptr;
|
||||||
|
return &m_windows.at(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const WindowInfo *WindowModel::windowById(qint64 windowId) const
|
||||||
|
{
|
||||||
|
for (const WindowInfo &window : m_windows) {
|
||||||
|
if (window.id == windowId)
|
||||||
|
return &window;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "WindowTypes.hpp"
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QVector>
|
||||||
|
#include <QtQml/qqmlregistration.h>
|
||||||
|
|
||||||
|
class WindowModel : public QAbstractListModel
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_UNCREATABLE("Obtained from WindowService")
|
||||||
|
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum Roles {
|
||||||
|
WindowIdRole = Qt::UserRole + 1,
|
||||||
|
TitleRole,
|
||||||
|
AppIdRole,
|
||||||
|
WindowClassRole,
|
||||||
|
WorkspaceRole,
|
||||||
|
FocusedRole,
|
||||||
|
FloatingRole,
|
||||||
|
FullscreenRole,
|
||||||
|
XRole,
|
||||||
|
YRole,
|
||||||
|
WidthRole,
|
||||||
|
HeightRole,
|
||||||
|
DisplayNameRole,
|
||||||
|
IconNameRole,
|
||||||
|
MinimizedRole,
|
||||||
|
MaximizedRole,
|
||||||
|
OutputRole
|
||||||
|
};
|
||||||
|
Q_ENUM(Roles)
|
||||||
|
|
||||||
|
explicit WindowModel(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
|
QHash<int, QByteArray> roleNames() const override;
|
||||||
|
|
||||||
|
int count() const;
|
||||||
|
void setWindows(QVector<WindowInfo> windows);
|
||||||
|
const WindowInfo *windowAt(int row) const;
|
||||||
|
const WindowInfo *windowById(qint64 windowId) const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void countChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QVector<WindowInfo> m_windows;
|
||||||
|
};
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
#include "WindowService.hpp"
|
||||||
|
|
||||||
|
#include "ApplicationService.hpp"
|
||||||
|
#include "SwayWindowBackend.hpp"
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool looksTechnicalTitle(const QString &title)
|
||||||
|
{
|
||||||
|
if (title.isEmpty())
|
||||||
|
return true;
|
||||||
|
if (title.contains(QLatin1Char('@')) || title.contains(QLatin1Char('/')) || title.contains(QLatin1Char('\\')))
|
||||||
|
return true;
|
||||||
|
if (title.contains(QLatin1String("://")))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
WindowService::WindowService(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_backend(new SwayWindowBackend(this))
|
||||||
|
, m_model(new WindowModel(this))
|
||||||
|
, m_workspaces(new WorkspaceModel(this))
|
||||||
|
, m_geometryTimer(new QTimer(this))
|
||||||
|
{
|
||||||
|
connect(m_backend, &WindowBackend::connectedChanged, this, &WindowService::connectedChanged);
|
||||||
|
connect(m_backend, &WindowBackend::connectedChanged, this, &WindowService::syncFromBackend);
|
||||||
|
connect(m_backend, &WindowBackend::windowsChanged, this, &WindowService::syncFromBackend);
|
||||||
|
|
||||||
|
m_geometryTimer->setInterval(33);
|
||||||
|
connect(m_geometryTimer, &QTimer::timeout, this, &WindowService::syncFocusedGeometry);
|
||||||
|
|
||||||
|
QTimer::singleShot(0, this, [this]() {
|
||||||
|
bindApplicationService();
|
||||||
|
if (m_applications)
|
||||||
|
syncFromBackend();
|
||||||
|
});
|
||||||
|
syncFromBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowService *WindowService::create(QQmlEngine *engine, QJSEngine *)
|
||||||
|
{
|
||||||
|
auto *service = new WindowService(engine);
|
||||||
|
QQmlEngine::setObjectOwnership(service, QQmlEngine::CppOwnership);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::connected() const
|
||||||
|
{
|
||||||
|
return m_backend && m_backend->isConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowModel *WindowService::model() const
|
||||||
|
{
|
||||||
|
return m_model;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkspaceModel *WindowService::workspaceModel() const
|
||||||
|
{
|
||||||
|
return m_workspaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowService::windowCount() const
|
||||||
|
{
|
||||||
|
return m_model->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::hasFocusedWindow() const
|
||||||
|
{
|
||||||
|
return !m_focusedWindowId.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString WindowService::focusedWindowId() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString WindowService::focusedWindowTitle() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString WindowService::focusedApplicationId() const
|
||||||
|
{
|
||||||
|
return m_focusedApplicationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString WindowService::focusedApplicationName() const
|
||||||
|
{
|
||||||
|
return m_focusedApplicationName;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::focusedWindowMaximized() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowMaximized;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::focusedWindowFullscreen() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowFullscreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowService::focusedWindowX() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowX;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowService::focusedWindowY() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowY;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowService::focusedWindowWidth() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowService::focusedWindowHeight() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowService::focusedWindowDecoX() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowDecoX;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowService::focusedWindowDecoY() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowDecoY;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowService::focusedWindowDecoWidth() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowDecoWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowService::focusedWindowDecoHeight() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowDecoHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowService::focusedWindowTitleBarY() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowTitleBarY;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowService::focusedWindowTitleBarHeight() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowTitleBarHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowService::focusedWorkspaceX() const
|
||||||
|
{
|
||||||
|
return m_focusedWorkspaceX;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WindowService::focusedWorkspaceY() const
|
||||||
|
{
|
||||||
|
return m_focusedWorkspaceY;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::focusWindow(const QString &windowId)
|
||||||
|
{
|
||||||
|
return m_backend->focusWindow(parseWindowId(windowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::closeWindow(const QString &windowId)
|
||||||
|
{
|
||||||
|
return m_backend->closeWindow(parseWindowId(windowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::maximizeWindow(const QString &windowId)
|
||||||
|
{
|
||||||
|
return m_backend->maximizeWindow(parseWindowId(windowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::restoreWindow(const QString &windowId)
|
||||||
|
{
|
||||||
|
return m_backend->restoreWindow(parseWindowId(windowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::minimizeWindow(const QString &windowId)
|
||||||
|
{
|
||||||
|
return m_backend->minimizeWindow(parseWindowId(windowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::minimizeFocusedWindow()
|
||||||
|
{
|
||||||
|
if (m_focusedWindowId.isEmpty())
|
||||||
|
return false;
|
||||||
|
return minimizeWindow(m_focusedWindowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::toggleMaximizeFocusedWindow()
|
||||||
|
{
|
||||||
|
if (m_focusedWindowId.isEmpty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const bool restore = m_focusedWindowMaximized;
|
||||||
|
const bool ok = restore ? restoreWindow(m_focusedWindowId)
|
||||||
|
: maximizeWindow(m_focusedWindowId);
|
||||||
|
if (!ok)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (m_focusedWindowMaximized == restore) {
|
||||||
|
m_focusedWindowMaximized = !restore;
|
||||||
|
emit focusChanged();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::closeFocusedWindow()
|
||||||
|
{
|
||||||
|
if (m_focusedWindowId.isEmpty())
|
||||||
|
return false;
|
||||||
|
return closeWindow(m_focusedWindowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::setWindowFloating(const QString &windowId, bool floating)
|
||||||
|
{
|
||||||
|
return m_backend->setWindowFloating(parseWindowId(windowId), floating);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::moveWindow(const QString &windowId, int x, int y)
|
||||||
|
{
|
||||||
|
return m_backend->moveWindow(parseWindowId(windowId), x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::resizeWindow(const QString &windowId, int width, int height)
|
||||||
|
{
|
||||||
|
return m_backend->resizeWindow(parseWindowId(windowId), width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::snapWindow(const QString &windowId, SnapEdge edge)
|
||||||
|
{
|
||||||
|
return m_backend->snapWindow(parseWindowId(windowId), static_cast<WindowBackend::SnapEdge>(edge));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WindowService::switchWorkspace(const QString &workspaceId)
|
||||||
|
{
|
||||||
|
return m_backend->switchWorkspace(workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void WindowService::syncFromBackend()
|
||||||
|
{
|
||||||
|
bindApplicationService();
|
||||||
|
|
||||||
|
QVector<WindowInfo> windows = m_backend->windows();
|
||||||
|
resolveNames(windows);
|
||||||
|
const int previousCount = m_model->count();
|
||||||
|
m_model->setWindows(windows);
|
||||||
|
m_workspaces->setWorkspaces(m_backend->workspaces());
|
||||||
|
if (m_model->count() != previousCount)
|
||||||
|
emit windowCountChanged();
|
||||||
|
|
||||||
|
const qint64 focusedId = m_backend->focusedWindowId();
|
||||||
|
QString windowId;
|
||||||
|
QString title;
|
||||||
|
QString appId;
|
||||||
|
QString appName;
|
||||||
|
bool maximized = false;
|
||||||
|
const WindowInfo *focusedInfo = nullptr;
|
||||||
|
|
||||||
|
if (const WindowInfo *focused = m_model->windowById(focusedId)) {
|
||||||
|
if (!focused->minimized)
|
||||||
|
focusedInfo = focused;
|
||||||
|
}
|
||||||
|
if (!focusedInfo && !m_focusedWindowId.isEmpty()) {
|
||||||
|
const WindowInfo *previous = m_model->windowById(parseWindowId(m_focusedWindowId));
|
||||||
|
if (previous && !previous->minimized)
|
||||||
|
focusedInfo = previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusedInfo) {
|
||||||
|
windowId = QString::number(focusedInfo->id);
|
||||||
|
title = focusedInfo->title;
|
||||||
|
appId = focusedInfo->appId.isEmpty() ? focusedInfo->windowClass : focusedInfo->appId;
|
||||||
|
appName = focusedInfo->displayName;
|
||||||
|
maximized = focusedInfo->maximized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_focusedWindowId != windowId
|
||||||
|
|| m_focusedWindowTitle != title
|
||||||
|
|| m_focusedApplicationId != appId
|
||||||
|
|| m_focusedApplicationName != appName
|
||||||
|
|| m_focusedWindowMaximized != maximized) {
|
||||||
|
m_focusedWindowId = windowId;
|
||||||
|
m_focusedWindowTitle = title;
|
||||||
|
m_focusedApplicationId = appId;
|
||||||
|
m_focusedApplicationName = appName;
|
||||||
|
m_focusedWindowMaximized = maximized;
|
||||||
|
emit focusChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
publishFocusedGeometry(focusedInfo);
|
||||||
|
|
||||||
|
if (hasFocusedWindow()) {
|
||||||
|
if (!m_geometryTimer->isActive())
|
||||||
|
m_geometryTimer->start();
|
||||||
|
} else if (m_geometryTimer->isActive()) {
|
||||||
|
m_geometryTimer->stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WindowService::syncFocusedGeometry()
|
||||||
|
{
|
||||||
|
if (m_focusedWindowId.isEmpty()) {
|
||||||
|
m_geometryTimer->stop();
|
||||||
|
publishFocusedGeometry(nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_backend->updateFocusedGeometry();
|
||||||
|
|
||||||
|
const QVector<WindowInfo> windows = m_backend->windows();
|
||||||
|
const qint64 id = parseWindowId(m_focusedWindowId);
|
||||||
|
const WindowInfo *focused = nullptr;
|
||||||
|
for (const WindowInfo &window : windows) {
|
||||||
|
if (window.id == id) {
|
||||||
|
focused = &window;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publishFocusedGeometry(focused);
|
||||||
|
}
|
||||||
|
|
||||||
|
void WindowService::publishFocusedGeometry(const WindowInfo *focused)
|
||||||
|
{
|
||||||
|
int x = 0;
|
||||||
|
int y = 0;
|
||||||
|
int width = 0;
|
||||||
|
int height = 0;
|
||||||
|
int decoX = 0;
|
||||||
|
int decoY = 0;
|
||||||
|
int decoWidth = 0;
|
||||||
|
int decoHeight = 0;
|
||||||
|
int titleBarY = 0;
|
||||||
|
int titleBarHeight = 0;
|
||||||
|
int workspaceX = 0;
|
||||||
|
int workspaceY = 0;
|
||||||
|
bool fullscreen = false;
|
||||||
|
|
||||||
|
if (focused && !focused->minimized) {
|
||||||
|
x = focused->x;
|
||||||
|
y = focused->y;
|
||||||
|
width = focused->width;
|
||||||
|
height = focused->height;
|
||||||
|
decoX = focused->decoX;
|
||||||
|
decoY = focused->decoY;
|
||||||
|
decoWidth = focused->decoWidth;
|
||||||
|
decoHeight = focused->decoHeight;
|
||||||
|
fullscreen = focused->fullscreen;
|
||||||
|
|
||||||
|
titleBarHeight = focused->decoHeight;
|
||||||
|
if (titleBarHeight <= 0)
|
||||||
|
titleBarHeight = focused->contentY;
|
||||||
|
if (titleBarHeight < 16 || titleBarHeight > 48)
|
||||||
|
titleBarHeight = 32;
|
||||||
|
|
||||||
|
// Frame rect from the floating container starts at the title bar.
|
||||||
|
titleBarY = focused->y + focused->decoY;
|
||||||
|
|
||||||
|
const WorkspaceInfo *workspace = m_workspaces->workspaceByName(focused->workspace);
|
||||||
|
if (!workspace)
|
||||||
|
workspace = m_workspaces->focusedWorkspace();
|
||||||
|
if (workspace) {
|
||||||
|
workspaceX = workspace->x;
|
||||||
|
workspaceY = workspace->y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_focusedWindowFullscreen != fullscreen) {
|
||||||
|
m_focusedWindowFullscreen = fullscreen;
|
||||||
|
emit focusChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_focusedWindowX != x
|
||||||
|
|| m_focusedWindowY != y
|
||||||
|
|| m_focusedWindowWidth != width
|
||||||
|
|| m_focusedWindowHeight != height
|
||||||
|
|| m_focusedWindowDecoX != decoX
|
||||||
|
|| m_focusedWindowDecoY != decoY
|
||||||
|
|| m_focusedWindowDecoWidth != decoWidth
|
||||||
|
|| m_focusedWindowDecoHeight != decoHeight
|
||||||
|
|| m_focusedWindowTitleBarY != titleBarY
|
||||||
|
|| m_focusedWindowTitleBarHeight != titleBarHeight
|
||||||
|
|| m_focusedWorkspaceX != workspaceX
|
||||||
|
|| m_focusedWorkspaceY != workspaceY) {
|
||||||
|
m_focusedWindowX = x;
|
||||||
|
m_focusedWindowY = y;
|
||||||
|
m_focusedWindowWidth = width;
|
||||||
|
m_focusedWindowHeight = height;
|
||||||
|
m_focusedWindowDecoX = decoX;
|
||||||
|
m_focusedWindowDecoY = decoY;
|
||||||
|
m_focusedWindowDecoWidth = decoWidth;
|
||||||
|
m_focusedWindowDecoHeight = decoHeight;
|
||||||
|
m_focusedWindowTitleBarY = titleBarY;
|
||||||
|
m_focusedWindowTitleBarHeight = titleBarHeight;
|
||||||
|
m_focusedWorkspaceX = workspaceX;
|
||||||
|
m_focusedWorkspaceY = workspaceY;
|
||||||
|
emit focusedWindowGeometryChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WindowService::resolveNames(QVector<WindowInfo> &windows) const
|
||||||
|
{
|
||||||
|
for (WindowInfo &window : windows) {
|
||||||
|
QString name;
|
||||||
|
if (m_applications)
|
||||||
|
name = m_applications->displayNameForWindow(window.appId, window.windowClass);
|
||||||
|
if (name.isEmpty())
|
||||||
|
name = humanizeIdentifier(window.appId, window.windowClass);
|
||||||
|
if (name.isEmpty() && !looksTechnicalTitle(window.title))
|
||||||
|
name = window.title;
|
||||||
|
window.displayName = name;
|
||||||
|
|
||||||
|
if (m_applications)
|
||||||
|
window.iconName = m_applications->iconNameForWindow(window.appId, window.windowClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WindowService::bindApplicationService()
|
||||||
|
{
|
||||||
|
if (m_applications)
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_applications = ApplicationService::instance();
|
||||||
|
if (!m_applications)
|
||||||
|
return;
|
||||||
|
|
||||||
|
connect(m_applications, &ApplicationService::countChanged, this, &WindowService::syncFromBackend);
|
||||||
|
}
|
||||||
|
|
||||||
|
qint64 WindowService::parseWindowId(const QString &windowId)
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
const qint64 id = windowId.trimmed().toLongLong(&ok);
|
||||||
|
return ok ? id : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString WindowService::humanizeIdentifier(const QString &appId, const QString &windowClass)
|
||||||
|
{
|
||||||
|
static const QHash<QString, QString> aliases = {
|
||||||
|
{QStringLiteral("foot"), QStringLiteral("Foot")},
|
||||||
|
{QStringLiteral("footclient"), QStringLiteral("Foot")},
|
||||||
|
{QStringLiteral("footserver"), QStringLiteral("Foot")},
|
||||||
|
{QStringLiteral("org.mozilla.firefox"), QStringLiteral("Firefox")},
|
||||||
|
{QStringLiteral("firefox"), QStringLiteral("Firefox")},
|
||||||
|
{QStringLiteral("firefox-esr"), QStringLiteral("Firefox")},
|
||||||
|
};
|
||||||
|
|
||||||
|
const QString raw = appId.isEmpty() ? windowClass : appId;
|
||||||
|
const QString lower = raw.toLower();
|
||||||
|
if (aliases.contains(lower))
|
||||||
|
return aliases.value(lower);
|
||||||
|
|
||||||
|
return titleCaseIdentifier(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString WindowService::titleCaseIdentifier(QString id)
|
||||||
|
{
|
||||||
|
if (id.endsWith(QLatin1String(".desktop"), Qt::CaseInsensitive))
|
||||||
|
id.chop(8);
|
||||||
|
if (id.contains(QLatin1Char('.')))
|
||||||
|
id = id.section(QLatin1Char('.'), -1);
|
||||||
|
if (id.isEmpty())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
id.replace(QLatin1Char('-'), QLatin1Char(' '));
|
||||||
|
id.replace(QLatin1Char('_'), QLatin1Char(' '));
|
||||||
|
|
||||||
|
const QStringList parts = id.split(QLatin1Char(' '), Qt::SkipEmptyParts);
|
||||||
|
QStringList titled;
|
||||||
|
titled.reserve(parts.size());
|
||||||
|
for (QString part : parts) {
|
||||||
|
if (part.size() == 1)
|
||||||
|
part = part.toUpper();
|
||||||
|
else if (!part.isEmpty())
|
||||||
|
part = part.left(1).toUpper() + part.mid(1);
|
||||||
|
titled.append(part);
|
||||||
|
}
|
||||||
|
return titled.join(QLatin1Char(' '));
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "WindowBackend.hpp"
|
||||||
|
#include "WindowModel.hpp"
|
||||||
|
#include "WorkspaceModel.hpp"
|
||||||
|
|
||||||
|
#include <QJSEngine>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QQmlEngine>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QVector>
|
||||||
|
#include <QtQml/qqmlregistration.h>
|
||||||
|
|
||||||
|
class ApplicationService;
|
||||||
|
|
||||||
|
class WindowService : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_SINGLETON
|
||||||
|
Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged)
|
||||||
|
Q_PROPERTY(WindowModel *model READ model CONSTANT)
|
||||||
|
Q_PROPERTY(WorkspaceModel *workspaceModel READ workspaceModel CONSTANT)
|
||||||
|
Q_PROPERTY(int windowCount READ windowCount NOTIFY windowCountChanged)
|
||||||
|
Q_PROPERTY(bool hasFocusedWindow READ hasFocusedWindow NOTIFY focusChanged)
|
||||||
|
Q_PROPERTY(QString focusedWindowId READ focusedWindowId NOTIFY focusChanged)
|
||||||
|
Q_PROPERTY(QString focusedWindowTitle READ focusedWindowTitle NOTIFY focusChanged)
|
||||||
|
Q_PROPERTY(QString focusedApplicationId READ focusedApplicationId NOTIFY focusChanged)
|
||||||
|
Q_PROPERTY(QString focusedApplicationName READ focusedApplicationName NOTIFY focusChanged)
|
||||||
|
Q_PROPERTY(bool focusedWindowMaximized READ focusedWindowMaximized NOTIFY focusChanged)
|
||||||
|
Q_PROPERTY(bool focusedWindowFullscreen READ focusedWindowFullscreen NOTIFY focusChanged)
|
||||||
|
Q_PROPERTY(int focusedWindowX READ focusedWindowX NOTIFY focusedWindowGeometryChanged)
|
||||||
|
Q_PROPERTY(int focusedWindowY READ focusedWindowY NOTIFY focusedWindowGeometryChanged)
|
||||||
|
Q_PROPERTY(int focusedWindowWidth READ focusedWindowWidth NOTIFY focusedWindowGeometryChanged)
|
||||||
|
Q_PROPERTY(int focusedWindowHeight READ focusedWindowHeight NOTIFY focusedWindowGeometryChanged)
|
||||||
|
Q_PROPERTY(int focusedWindowDecoX READ focusedWindowDecoX NOTIFY focusedWindowGeometryChanged)
|
||||||
|
Q_PROPERTY(int focusedWindowDecoY READ focusedWindowDecoY NOTIFY focusedWindowGeometryChanged)
|
||||||
|
Q_PROPERTY(int focusedWindowDecoWidth READ focusedWindowDecoWidth NOTIFY focusedWindowGeometryChanged)
|
||||||
|
Q_PROPERTY(int focusedWindowDecoHeight READ focusedWindowDecoHeight NOTIFY focusedWindowGeometryChanged)
|
||||||
|
Q_PROPERTY(int focusedWindowTitleBarY READ focusedWindowTitleBarY NOTIFY focusedWindowGeometryChanged)
|
||||||
|
Q_PROPERTY(int focusedWindowTitleBarHeight READ focusedWindowTitleBarHeight NOTIFY focusedWindowGeometryChanged)
|
||||||
|
Q_PROPERTY(int focusedWorkspaceX READ focusedWorkspaceX NOTIFY focusedWindowGeometryChanged)
|
||||||
|
Q_PROPERTY(int focusedWorkspaceY READ focusedWorkspaceY NOTIFY focusedWindowGeometryChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum SnapEdge {
|
||||||
|
SnapLeft = WindowBackend::SnapLeft,
|
||||||
|
SnapRight = WindowBackend::SnapRight,
|
||||||
|
SnapMaximize = WindowBackend::SnapMaximize
|
||||||
|
};
|
||||||
|
Q_ENUM(SnapEdge)
|
||||||
|
|
||||||
|
explicit WindowService(QObject *parent = nullptr);
|
||||||
|
static WindowService *create(QQmlEngine *engine, QJSEngine *scriptEngine);
|
||||||
|
|
||||||
|
bool connected() const;
|
||||||
|
WindowModel *model() const;
|
||||||
|
WorkspaceModel *workspaceModel() const;
|
||||||
|
int windowCount() const;
|
||||||
|
|
||||||
|
bool hasFocusedWindow() const;
|
||||||
|
QString focusedWindowId() const;
|
||||||
|
QString focusedWindowTitle() const;
|
||||||
|
QString focusedApplicationId() const;
|
||||||
|
QString focusedApplicationName() const;
|
||||||
|
bool focusedWindowMaximized() const;
|
||||||
|
bool focusedWindowFullscreen() const;
|
||||||
|
int focusedWindowX() const;
|
||||||
|
int focusedWindowY() const;
|
||||||
|
int focusedWindowWidth() const;
|
||||||
|
int focusedWindowHeight() const;
|
||||||
|
int focusedWindowDecoX() const;
|
||||||
|
int focusedWindowDecoY() const;
|
||||||
|
int focusedWindowDecoWidth() const;
|
||||||
|
int focusedWindowDecoHeight() const;
|
||||||
|
int focusedWindowTitleBarY() const;
|
||||||
|
int focusedWindowTitleBarHeight() const;
|
||||||
|
int focusedWorkspaceX() const;
|
||||||
|
int focusedWorkspaceY() const;
|
||||||
|
|
||||||
|
Q_INVOKABLE bool focusWindow(const QString &windowId);
|
||||||
|
Q_INVOKABLE bool closeWindow(const QString &windowId);
|
||||||
|
Q_INVOKABLE bool maximizeWindow(const QString &windowId);
|
||||||
|
Q_INVOKABLE bool restoreWindow(const QString &windowId);
|
||||||
|
Q_INVOKABLE bool minimizeWindow(const QString &windowId);
|
||||||
|
Q_INVOKABLE bool minimizeFocusedWindow();
|
||||||
|
Q_INVOKABLE bool toggleMaximizeFocusedWindow();
|
||||||
|
Q_INVOKABLE bool closeFocusedWindow();
|
||||||
|
Q_INVOKABLE bool setWindowFloating(const QString &windowId, bool floating);
|
||||||
|
Q_INVOKABLE bool moveWindow(const QString &windowId, int x, int y);
|
||||||
|
Q_INVOKABLE bool resizeWindow(const QString &windowId, int width, int height);
|
||||||
|
Q_INVOKABLE bool snapWindow(const QString &windowId, SnapEdge edge);
|
||||||
|
Q_INVOKABLE bool switchWorkspace(const QString &workspaceId);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void connectedChanged();
|
||||||
|
void windowCountChanged();
|
||||||
|
void focusChanged();
|
||||||
|
void focusedWindowGeometryChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void syncFromBackend();
|
||||||
|
void syncFocusedGeometry();
|
||||||
|
void publishFocusedGeometry(const WindowInfo *focused);
|
||||||
|
void resolveNames(QVector<WindowInfo> &windows) const;
|
||||||
|
void bindApplicationService();
|
||||||
|
static qint64 parseWindowId(const QString &windowId);
|
||||||
|
static QString humanizeIdentifier(const QString &appId, const QString &windowClass);
|
||||||
|
static QString titleCaseIdentifier(QString id);
|
||||||
|
|
||||||
|
WindowBackend *m_backend = nullptr;
|
||||||
|
WindowModel *m_model = nullptr;
|
||||||
|
WorkspaceModel *m_workspaces = nullptr;
|
||||||
|
ApplicationService *m_applications = nullptr;
|
||||||
|
QString m_focusedWindowId;
|
||||||
|
QString m_focusedWindowTitle;
|
||||||
|
QString m_focusedApplicationId;
|
||||||
|
QString m_focusedApplicationName;
|
||||||
|
bool m_focusedWindowMaximized = false;
|
||||||
|
bool m_focusedWindowFullscreen = false;
|
||||||
|
int m_focusedWindowX = 0;
|
||||||
|
int m_focusedWindowY = 0;
|
||||||
|
int m_focusedWindowWidth = 0;
|
||||||
|
int m_focusedWindowHeight = 0;
|
||||||
|
int m_focusedWindowDecoX = 0;
|
||||||
|
int m_focusedWindowDecoY = 0;
|
||||||
|
int m_focusedWindowDecoWidth = 0;
|
||||||
|
int m_focusedWindowDecoHeight = 0;
|
||||||
|
int m_focusedWindowTitleBarY = 0;
|
||||||
|
int m_focusedWindowTitleBarHeight = 0;
|
||||||
|
int m_focusedWorkspaceX = 0;
|
||||||
|
int m_focusedWorkspaceY = 0;
|
||||||
|
QTimer *m_geometryTimer = nullptr;
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QtGlobal>
|
||||||
|
|
||||||
|
struct WindowInfo
|
||||||
|
{
|
||||||
|
qint64 id = 0;
|
||||||
|
QString title;
|
||||||
|
QString appId;
|
||||||
|
QString windowClass;
|
||||||
|
QString workspace;
|
||||||
|
QString output;
|
||||||
|
QString displayName;
|
||||||
|
QString iconName;
|
||||||
|
bool focused = false;
|
||||||
|
bool floating = false;
|
||||||
|
bool fullscreen = false;
|
||||||
|
bool minimized = false;
|
||||||
|
bool maximized = false;
|
||||||
|
int x = 0;
|
||||||
|
int y = 0;
|
||||||
|
int width = 0;
|
||||||
|
int height = 0;
|
||||||
|
int decoX = 0;
|
||||||
|
int decoY = 0;
|
||||||
|
int decoWidth = 0;
|
||||||
|
int decoHeight = 0;
|
||||||
|
int contentX = 0;
|
||||||
|
int contentY = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct WorkspaceInfo
|
||||||
|
{
|
||||||
|
QString id;
|
||||||
|
QString name;
|
||||||
|
int number = -1;
|
||||||
|
bool focused = false;
|
||||||
|
bool visible = false;
|
||||||
|
QString output;
|
||||||
|
int windowCount = 0;
|
||||||
|
int x = 0;
|
||||||
|
int y = 0;
|
||||||
|
int width = 0;
|
||||||
|
int height = 0;
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#include "WorkspaceModel.hpp"
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
WorkspaceModel::WorkspaceModel(QObject *parent)
|
||||||
|
: QAbstractListModel(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
int WorkspaceModel::rowCount(const QModelIndex &parent) const
|
||||||
|
{
|
||||||
|
if (parent.isValid())
|
||||||
|
return 0;
|
||||||
|
return m_workspaces.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
int WorkspaceModel::count() const
|
||||||
|
{
|
||||||
|
return m_workspaces.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant WorkspaceModel::data(const QModelIndex &index, int role) const
|
||||||
|
{
|
||||||
|
if (!index.isValid() || index.row() < 0 || index.row() >= m_workspaces.size())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const WorkspaceInfo &workspace = m_workspaces.at(index.row());
|
||||||
|
switch (role) {
|
||||||
|
case WorkspaceIdRole:
|
||||||
|
return workspace.id;
|
||||||
|
case NameRole:
|
||||||
|
case Qt::DisplayRole:
|
||||||
|
return workspace.name;
|
||||||
|
case NumberRole:
|
||||||
|
return workspace.number;
|
||||||
|
case FocusedRole:
|
||||||
|
return workspace.focused;
|
||||||
|
case VisibleRole:
|
||||||
|
return workspace.visible;
|
||||||
|
case OutputRole:
|
||||||
|
return workspace.output;
|
||||||
|
case WindowCountRole:
|
||||||
|
return workspace.windowCount;
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<int, QByteArray> WorkspaceModel::roleNames() const
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
{WorkspaceIdRole, QByteArrayLiteral("workspaceId")},
|
||||||
|
{NameRole, QByteArrayLiteral("name")},
|
||||||
|
{NumberRole, QByteArrayLiteral("number")},
|
||||||
|
{FocusedRole, QByteArrayLiteral("focused")},
|
||||||
|
{VisibleRole, QByteArrayLiteral("visible")},
|
||||||
|
{OutputRole, QByteArrayLiteral("output")},
|
||||||
|
{WindowCountRole, QByteArrayLiteral("windowCount")},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void WorkspaceModel::setWorkspaces(QVector<WorkspaceInfo> workspaces)
|
||||||
|
{
|
||||||
|
beginResetModel();
|
||||||
|
m_workspaces = std::move(workspaces);
|
||||||
|
endResetModel();
|
||||||
|
emit countChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceInfo *WorkspaceModel::workspaceAt(int row) const
|
||||||
|
{
|
||||||
|
if (row < 0 || row >= m_workspaces.size())
|
||||||
|
return nullptr;
|
||||||
|
return &m_workspaces.at(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceInfo *WorkspaceModel::focusedWorkspace() const
|
||||||
|
{
|
||||||
|
for (const WorkspaceInfo &workspace : m_workspaces) {
|
||||||
|
if (workspace.focused)
|
||||||
|
return &workspace;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceInfo *WorkspaceModel::workspaceByName(const QString &name) const
|
||||||
|
{
|
||||||
|
if (name.isEmpty())
|
||||||
|
return nullptr;
|
||||||
|
for (const WorkspaceInfo &workspace : m_workspaces) {
|
||||||
|
if (workspace.name == name)
|
||||||
|
return &workspace;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "WindowTypes.hpp"
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QVector>
|
||||||
|
#include <QtQml/qqmlregistration.h>
|
||||||
|
|
||||||
|
class WorkspaceModel : public QAbstractListModel
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_UNCREATABLE("Obtained from WindowService")
|
||||||
|
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum Roles {
|
||||||
|
WorkspaceIdRole = Qt::UserRole + 1,
|
||||||
|
NameRole,
|
||||||
|
NumberRole,
|
||||||
|
FocusedRole,
|
||||||
|
VisibleRole,
|
||||||
|
OutputRole,
|
||||||
|
WindowCountRole
|
||||||
|
};
|
||||||
|
Q_ENUM(Roles)
|
||||||
|
|
||||||
|
explicit WorkspaceModel(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
|
QHash<int, QByteArray> roleNames() const override;
|
||||||
|
|
||||||
|
int count() const;
|
||||||
|
void setWorkspaces(QVector<WorkspaceInfo> workspaces);
|
||||||
|
const WorkspaceInfo *workspaceAt(int row) const;
|
||||||
|
const WorkspaceInfo *focusedWorkspace() const;
|
||||||
|
const WorkspaceInfo *workspaceByName(const QString &name) const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void countChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QVector<WorkspaceInfo> m_workspaces;
|
||||||
|
};
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
#include "SwayIpcClient.hpp"
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QtGlobal>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr char kMagic[] = "i3-ipc";
|
||||||
|
constexpr int kMagicSize = 6;
|
||||||
|
constexpr int kHeaderSize = 14;
|
||||||
|
constexpr int kDefaultTimeoutMs = 2000;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
SwayIpcClient::SwayIpcClient(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_command(new QLocalSocket(this))
|
||||||
|
, m_event(new QLocalSocket(this))
|
||||||
|
{
|
||||||
|
connect(m_command, &QLocalSocket::disconnected, this, &SwayIpcClient::handleCommandDisconnected);
|
||||||
|
connect(m_event, &QLocalSocket::disconnected, this, &SwayIpcClient::handleEventDisconnected);
|
||||||
|
connect(m_event, &QLocalSocket::readyRead, this, &SwayIpcClient::processEventSocket);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SwayIpcClient::processEventSocket()
|
||||||
|
{
|
||||||
|
appendIncoming(m_event, &m_eventBuffer);
|
||||||
|
Message message;
|
||||||
|
while (takeMessage(&m_eventBuffer, &message)) {
|
||||||
|
if (m_inRequest > 0)
|
||||||
|
m_pendingEvents.append(std::move(message));
|
||||||
|
else
|
||||||
|
emit eventReceived(message.type, QJsonDocument::fromJson(message.payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SwayIpcClient::~SwayIpcClient()
|
||||||
|
{
|
||||||
|
disconnectFromSway();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SwayIpcClient::socketPath()
|
||||||
|
{
|
||||||
|
return QString::fromLocal8Bit(qgetenv("SWAYSOCK"));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayIpcClient::isConnected() const
|
||||||
|
{
|
||||||
|
return m_connected
|
||||||
|
&& m_command->state() == QLocalSocket::ConnectedState
|
||||||
|
&& m_event->state() == QLocalSocket::ConnectedState;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SwayIpcClient::disconnectFromSway()
|
||||||
|
{
|
||||||
|
m_event->blockSignals(true);
|
||||||
|
m_command->blockSignals(true);
|
||||||
|
if (m_event->state() != QLocalSocket::UnconnectedState)
|
||||||
|
m_event->disconnectFromServer();
|
||||||
|
if (m_command->state() != QLocalSocket::UnconnectedState)
|
||||||
|
m_command->disconnectFromServer();
|
||||||
|
m_event->blockSignals(false);
|
||||||
|
m_command->blockSignals(false);
|
||||||
|
m_eventBuffer.clear();
|
||||||
|
m_pendingEvents.clear();
|
||||||
|
|
||||||
|
if (m_connected) {
|
||||||
|
m_connected = false;
|
||||||
|
emit connectedChanged();
|
||||||
|
emit disconnected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayIpcClient::connectToSway()
|
||||||
|
{
|
||||||
|
const QString path = socketPath();
|
||||||
|
if (path.isEmpty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
disconnectFromSway();
|
||||||
|
|
||||||
|
m_command->connectToServer(path);
|
||||||
|
if (!m_command->waitForConnected(kDefaultTimeoutMs)) {
|
||||||
|
qWarning("nebula: Sway IPC command socket failed: %s", qPrintable(m_command->errorString()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_event->connectToServer(path);
|
||||||
|
if (!m_event->waitForConnected(kDefaultTimeoutMs)) {
|
||||||
|
qWarning("nebula: Sway IPC event socket failed: %s", qPrintable(m_event->errorString()));
|
||||||
|
m_command->disconnectFromServer();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_connected = true;
|
||||||
|
emit connectedChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonDocument SwayIpcClient::request(quint32 type, const QByteArray &payload)
|
||||||
|
{
|
||||||
|
if (m_command->state() != QLocalSocket::ConnectedState)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
++m_inRequest;
|
||||||
|
const bool wrote = writeMessage(m_command, type, payload);
|
||||||
|
Message reply;
|
||||||
|
const bool read = wrote && readMessage(m_command, &reply, kDefaultTimeoutMs);
|
||||||
|
--m_inRequest;
|
||||||
|
|
||||||
|
flushPendingEvents();
|
||||||
|
|
||||||
|
if (!read) {
|
||||||
|
qWarning("nebula: Sway IPC request failed (type %u)", type);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return QJsonDocument::fromJson(reply.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayIpcClient::subscribe(const QStringList &events)
|
||||||
|
{
|
||||||
|
if (m_event->state() != QLocalSocket::ConnectedState)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
QJsonArray array;
|
||||||
|
for (const QString &event : events)
|
||||||
|
array.append(event);
|
||||||
|
|
||||||
|
// Subscribe on the event connection so unsolicited events never interleave
|
||||||
|
// with command replies on the request socket.
|
||||||
|
m_event->blockSignals(true);
|
||||||
|
const bool wrote = writeMessage(m_event, Subscribe, QJsonDocument(array).toJson(QJsonDocument::Compact));
|
||||||
|
Message reply;
|
||||||
|
const bool read = wrote && readMessage(m_event, &reply, kDefaultTimeoutMs);
|
||||||
|
m_event->blockSignals(false);
|
||||||
|
|
||||||
|
if (m_event->bytesAvailable() > 0)
|
||||||
|
processEventSocket();
|
||||||
|
|
||||||
|
if (!read) {
|
||||||
|
qWarning("nebula: Sway IPC subscribe failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonDocument doc = QJsonDocument::fromJson(reply.payload);
|
||||||
|
if (!doc.isObject() || !doc.object().value(QStringLiteral("success")).toBool()) {
|
||||||
|
qWarning("nebula: Sway IPC subscribe was rejected");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayIpcClient::writeMessage(QLocalSocket *socket, quint32 type, const QByteArray &payload)
|
||||||
|
{
|
||||||
|
QByteArray header;
|
||||||
|
header.resize(kHeaderSize);
|
||||||
|
memcpy(header.data(), kMagic, kMagicSize);
|
||||||
|
const quint32 size = static_cast<quint32>(payload.size());
|
||||||
|
memcpy(header.data() + kMagicSize, &size, sizeof(size));
|
||||||
|
memcpy(header.data() + kMagicSize + sizeof(size), &type, sizeof(type));
|
||||||
|
|
||||||
|
if (socket->write(header) != header.size())
|
||||||
|
return false;
|
||||||
|
if (!payload.isEmpty() && socket->write(payload) != payload.size())
|
||||||
|
return false;
|
||||||
|
return socket->waitForBytesWritten(kDefaultTimeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayIpcClient::readMessage(QLocalSocket *socket, Message *message, int timeoutMs)
|
||||||
|
{
|
||||||
|
QByteArray buffer;
|
||||||
|
while (!takeMessage(&buffer, message)) {
|
||||||
|
if (socket->bytesAvailable() <= 0 && !socket->waitForReadyRead(timeoutMs))
|
||||||
|
return false;
|
||||||
|
appendIncoming(socket, &buffer);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SwayIpcClient::appendIncoming(QLocalSocket *socket, QByteArray *buffer)
|
||||||
|
{
|
||||||
|
buffer->append(socket->readAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayIpcClient::takeMessage(QByteArray *buffer, Message *message)
|
||||||
|
{
|
||||||
|
if (buffer->size() < kHeaderSize)
|
||||||
|
return false;
|
||||||
|
if (!buffer->startsWith(kMagic)) {
|
||||||
|
qWarning("nebula: Sway IPC header magic mismatch");
|
||||||
|
buffer->clear();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
quint32 size = 0;
|
||||||
|
quint32 type = 0;
|
||||||
|
memcpy(&size, buffer->constData() + kMagicSize, sizeof(size));
|
||||||
|
memcpy(&type, buffer->constData() + kMagicSize + sizeof(size), sizeof(type));
|
||||||
|
|
||||||
|
const int total = kHeaderSize + static_cast<int>(size);
|
||||||
|
if (buffer->size() < total)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
message->type = type;
|
||||||
|
message->payload = buffer->mid(kHeaderSize, static_cast<int>(size));
|
||||||
|
buffer->remove(0, total);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SwayIpcClient::flushPendingEvents()
|
||||||
|
{
|
||||||
|
if (m_inRequest > 0 || m_pendingEvents.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const QVector<Message> pending = std::move(m_pendingEvents);
|
||||||
|
m_pendingEvents.clear();
|
||||||
|
for (const Message &message : pending)
|
||||||
|
emit eventReceived(message.type, QJsonDocument::fromJson(message.payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SwayIpcClient::handleCommandDisconnected()
|
||||||
|
{
|
||||||
|
if (!m_connected)
|
||||||
|
return;
|
||||||
|
qWarning("nebula: Sway IPC command socket disconnected");
|
||||||
|
m_connected = false;
|
||||||
|
emit connectedChanged();
|
||||||
|
emit disconnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SwayIpcClient::handleEventDisconnected()
|
||||||
|
{
|
||||||
|
if (!m_connected)
|
||||||
|
return;
|
||||||
|
qWarning("nebula: Sway IPC event socket disconnected");
|
||||||
|
m_connected = false;
|
||||||
|
emit connectedChanged();
|
||||||
|
emit disconnected();
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QLocalSocket>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
class SwayIpcClient : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum MessageType : quint32 {
|
||||||
|
RunCommand = 0,
|
||||||
|
GetWorkspaces = 1,
|
||||||
|
Subscribe = 2,
|
||||||
|
GetOutputs = 3,
|
||||||
|
GetTree = 4,
|
||||||
|
GetMarks = 5,
|
||||||
|
GetVersion = 7
|
||||||
|
};
|
||||||
|
|
||||||
|
enum EventType : quint32 {
|
||||||
|
WorkspaceEvent = 0x80000000u,
|
||||||
|
OutputEvent = 0x80000001u,
|
||||||
|
WindowEvent = 0x80000003u,
|
||||||
|
BindingEvent = 0x80000005u,
|
||||||
|
ShutdownEvent = 0x80000006u
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit SwayIpcClient(QObject *parent = nullptr);
|
||||||
|
~SwayIpcClient() override;
|
||||||
|
|
||||||
|
bool connectToSway();
|
||||||
|
void disconnectFromSway();
|
||||||
|
bool isConnected() const;
|
||||||
|
|
||||||
|
QJsonDocument request(quint32 type, const QByteArray &payload = {});
|
||||||
|
bool subscribe(const QStringList &events);
|
||||||
|
|
||||||
|
static QString socketPath();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void connectedChanged();
|
||||||
|
void disconnected();
|
||||||
|
void eventReceived(quint32 type, const QJsonDocument &payload);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Message {
|
||||||
|
quint32 type = 0;
|
||||||
|
QByteArray payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool writeMessage(QLocalSocket *socket, quint32 type, const QByteArray &payload);
|
||||||
|
bool readMessage(QLocalSocket *socket, Message *message, int timeoutMs);
|
||||||
|
void appendIncoming(QLocalSocket *socket, QByteArray *buffer);
|
||||||
|
bool takeMessage(QByteArray *buffer, Message *message);
|
||||||
|
void processEventSocket();
|
||||||
|
void flushPendingEvents();
|
||||||
|
void handleCommandDisconnected();
|
||||||
|
void handleEventDisconnected();
|
||||||
|
|
||||||
|
QLocalSocket *m_command = nullptr;
|
||||||
|
QLocalSocket *m_event = nullptr;
|
||||||
|
QByteArray m_eventBuffer;
|
||||||
|
QVector<Message> m_pendingEvents;
|
||||||
|
int m_inRequest = 0;
|
||||||
|
bool m_connected = false;
|
||||||
|
};
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
#include "SwayWindowBackend.hpp"
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonValue>
|
||||||
|
#include <QVariant>
|
||||||
|
#include <QtGlobal>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr int kMaxConnectAttempts = 8;
|
||||||
|
|
||||||
|
QString quoteCriteriaValue(const QString &value)
|
||||||
|
{
|
||||||
|
QString escaped = value;
|
||||||
|
escaped.replace(QLatin1Char('\\'), QStringLiteral("\\\\"));
|
||||||
|
escaped.replace(QLatin1Char('"'), QStringLiteral("\\\""));
|
||||||
|
return escaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isScratchpadWorkspace(const QString &name)
|
||||||
|
{
|
||||||
|
return name == QLatin1String("__i3_scratch");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPseudoOutput(const QString &name)
|
||||||
|
{
|
||||||
|
return name == QLatin1String("__i3");
|
||||||
|
}
|
||||||
|
|
||||||
|
qint64 jsonId(const QJsonValue &value)
|
||||||
|
{
|
||||||
|
if (value.isDouble())
|
||||||
|
return static_cast<qint64>(value.toDouble());
|
||||||
|
return value.toVariant().toLongLong();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString floatingState(const QJsonObject &node)
|
||||||
|
{
|
||||||
|
return node.value(QStringLiteral("floating")).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool nodeIsFloating(const QJsonObject &node)
|
||||||
|
{
|
||||||
|
const QString state = floatingState(node);
|
||||||
|
if (state == QLatin1String("user_on") || state == QLatin1String("auto_on"))
|
||||||
|
return true;
|
||||||
|
return node.value(QStringLiteral("type")).toString() == QLatin1String("floating_con");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool nodeLooksLikeView(const QJsonObject &node)
|
||||||
|
{
|
||||||
|
const int pid = node.value(QStringLiteral("pid")).toInt();
|
||||||
|
if (pid <= 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const QString appId = node.value(QStringLiteral("app_id")).toString();
|
||||||
|
if (!appId.isEmpty())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (node.contains(QStringLiteral("window")) && !node.value(QStringLiteral("window")).isNull())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const QJsonObject properties = node.value(QStringLiteral("window_properties")).toObject();
|
||||||
|
return !properties.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyFrameGeometry(WindowInfo &window, const QJsonObject &node, const QJsonObject &frame)
|
||||||
|
{
|
||||||
|
const bool useFrame = !frame.isEmpty()
|
||||||
|
&& (frame.value(QStringLiteral("type")).toString() == QLatin1String("floating_con")
|
||||||
|
|| nodeIsFloating(frame));
|
||||||
|
const QJsonObject &source = useFrame ? frame : node;
|
||||||
|
|
||||||
|
const QJsonObject rect = source.value(QStringLiteral("rect")).toObject();
|
||||||
|
const QJsonObject deco = source.value(QStringLiteral("deco_rect")).toObject();
|
||||||
|
const QJsonObject content = source.value(QStringLiteral("window_rect")).toObject();
|
||||||
|
|
||||||
|
window.x = rect.value(QStringLiteral("x")).toInt();
|
||||||
|
window.y = rect.value(QStringLiteral("y")).toInt();
|
||||||
|
window.width = rect.value(QStringLiteral("width")).toInt();
|
||||||
|
window.height = rect.value(QStringLiteral("height")).toInt();
|
||||||
|
window.decoX = deco.value(QStringLiteral("x")).toInt();
|
||||||
|
window.decoY = deco.value(QStringLiteral("y")).toInt();
|
||||||
|
window.decoWidth = deco.value(QStringLiteral("width")).toInt();
|
||||||
|
window.decoHeight = deco.value(QStringLiteral("height")).toInt();
|
||||||
|
window.contentX = content.value(QStringLiteral("x")).toInt();
|
||||||
|
window.contentY = content.value(QStringLiteral("y")).toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
SwayWindowBackend::SwayWindowBackend(QObject *parent)
|
||||||
|
: WindowBackend(parent)
|
||||||
|
, m_ipc(new SwayIpcClient(this))
|
||||||
|
, m_retryTimer(new QTimer(this))
|
||||||
|
{
|
||||||
|
m_retryTimer->setSingleShot(true);
|
||||||
|
connect(m_retryTimer, &QTimer::timeout, this, &SwayWindowBackend::tryConnect);
|
||||||
|
connect(m_ipc, &SwayIpcClient::eventReceived, this, &SwayWindowBackend::handleEvent);
|
||||||
|
connect(m_ipc, &SwayIpcClient::connectedChanged, this, &SwayWindowBackend::connectedChanged);
|
||||||
|
connect(m_ipc, &SwayIpcClient::disconnected, this, [this]() {
|
||||||
|
m_windows.clear();
|
||||||
|
m_workspaces.clear();
|
||||||
|
m_focusedWindowId = 0;
|
||||||
|
emit windowsChanged();
|
||||||
|
emit workspacesChanged();
|
||||||
|
emit focusChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
tryConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::isConnected() const
|
||||||
|
{
|
||||||
|
return m_ipc->isConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<WindowInfo> SwayWindowBackend::windows() const
|
||||||
|
{
|
||||||
|
return m_windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<WorkspaceInfo> SwayWindowBackend::workspaces() const
|
||||||
|
{
|
||||||
|
return m_workspaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
qint64 SwayWindowBackend::focusedWindowId() const
|
||||||
|
{
|
||||||
|
return m_focusedWindowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::focusWindow(qint64 windowId)
|
||||||
|
{
|
||||||
|
if (m_minimized.contains(windowId) || (findWindow(windowId) && findWindow(windowId)->minimized)) {
|
||||||
|
if (!run(QStringLiteral("[con_id=%1] scratchpad show").arg(windowId)))
|
||||||
|
return false;
|
||||||
|
m_minimized.remove(windowId);
|
||||||
|
}
|
||||||
|
return run(QStringLiteral("[con_id=%1] focus").arg(windowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::closeWindow(qint64 windowId)
|
||||||
|
{
|
||||||
|
// `kill` asks the client to close; it does not SIGKILL the process.
|
||||||
|
const bool ok = run(QStringLiteral("[con_id=%1] kill").arg(windowId));
|
||||||
|
m_savedGeometry.remove(windowId);
|
||||||
|
m_maximized.remove(windowId);
|
||||||
|
m_minimized.remove(windowId);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::maximizeWindow(qint64 windowId)
|
||||||
|
{
|
||||||
|
saveGeometry(windowId);
|
||||||
|
m_maximized.insert(windowId);
|
||||||
|
m_minimized.remove(windowId);
|
||||||
|
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
|
||||||
|
"resize set width 100 ppt height 100 ppt, move position 0 ppt 0 ppt")
|
||||||
|
.arg(windowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::restoreWindow(qint64 windowId)
|
||||||
|
{
|
||||||
|
if (m_minimized.contains(windowId) || (findWindow(windowId) && findWindow(windowId)->minimized)) {
|
||||||
|
const bool ok = run(QStringLiteral("[con_id=%1] scratchpad show, floating enable").arg(windowId));
|
||||||
|
m_minimized.remove(windowId);
|
||||||
|
if (!ok)
|
||||||
|
return false;
|
||||||
|
if (!m_maximized.contains(windowId) && m_savedGeometry.contains(windowId)) {
|
||||||
|
const QRect rect = m_savedGeometry.value(windowId);
|
||||||
|
return run(QStringLiteral("[con_id=%1] resize set width %2 px height %3 px, "
|
||||||
|
"move absolute position %4 px %5 px")
|
||||||
|
.arg(windowId)
|
||||||
|
.arg(rect.width())
|
||||||
|
.arg(rect.height())
|
||||||
|
.arg(rect.x())
|
||||||
|
.arg(rect.y()));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_savedGeometry.contains(windowId)) {
|
||||||
|
const QRect rect = m_savedGeometry.take(windowId);
|
||||||
|
m_maximized.remove(windowId);
|
||||||
|
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
|
||||||
|
"resize set width %2 px height %3 px, "
|
||||||
|
"move absolute position %4 px %5 px")
|
||||||
|
.arg(windowId)
|
||||||
|
.arg(rect.width())
|
||||||
|
.arg(rect.height())
|
||||||
|
.arg(rect.x())
|
||||||
|
.arg(rect.y()));
|
||||||
|
}
|
||||||
|
|
||||||
|
m_maximized.remove(windowId);
|
||||||
|
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
|
||||||
|
"resize set width 70 ppt height 70 ppt, move position center")
|
||||||
|
.arg(windowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::minimizeWindow(qint64 windowId)
|
||||||
|
{
|
||||||
|
saveGeometry(windowId);
|
||||||
|
m_minimized.insert(windowId);
|
||||||
|
return run(QStringLiteral("[con_id=%1] move scratchpad").arg(windowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::setWindowFloating(qint64 windowId, bool floating)
|
||||||
|
{
|
||||||
|
return run(QStringLiteral("[con_id=%1] floating %2")
|
||||||
|
.arg(windowId)
|
||||||
|
.arg(floating ? QStringLiteral("enable") : QStringLiteral("disable")));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::moveWindow(qint64 windowId, int x, int y)
|
||||||
|
{
|
||||||
|
return run(QStringLiteral("[con_id=%1] move absolute position %2 px %3 px")
|
||||||
|
.arg(windowId)
|
||||||
|
.arg(x)
|
||||||
|
.arg(y));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::resizeWindow(qint64 windowId, int width, int height)
|
||||||
|
{
|
||||||
|
if (width < 1 || height < 1)
|
||||||
|
return false;
|
||||||
|
return run(QStringLiteral("[con_id=%1] resize set width %2 px height %3 px")
|
||||||
|
.arg(windowId)
|
||||||
|
.arg(width)
|
||||||
|
.arg(height));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::snapWindow(qint64 windowId, SnapEdge edge)
|
||||||
|
{
|
||||||
|
saveGeometry(windowId);
|
||||||
|
m_minimized.remove(windowId);
|
||||||
|
if (edge == SnapMaximize) {
|
||||||
|
m_maximized.insert(windowId);
|
||||||
|
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
|
||||||
|
"resize set width 100 ppt height 100 ppt, move position 0 ppt 0 ppt")
|
||||||
|
.arg(windowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
m_maximized.remove(windowId);
|
||||||
|
const QString x = (edge == SnapRight) ? QStringLiteral("50") : QStringLiteral("0");
|
||||||
|
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
|
||||||
|
"resize set width 50 ppt height 100 ppt, move position %2 ppt 0 ppt")
|
||||||
|
.arg(windowId)
|
||||||
|
.arg(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::switchWorkspace(const QString &workspaceId)
|
||||||
|
{
|
||||||
|
if (workspaceId.isEmpty())
|
||||||
|
return false;
|
||||||
|
return run(QStringLiteral("workspace \"%1\"").arg(quoteCriteriaValue(workspaceId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::updateFocusedGeometry()
|
||||||
|
{
|
||||||
|
if (!m_ipc->isConnected() || m_focusedWindowId == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const QJsonDocument tree = m_ipc->request(SwayIpcClient::GetTree);
|
||||||
|
if (!tree.isObject())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
QVector<WindowInfo> windows;
|
||||||
|
QVector<WorkspaceInfo> workspaces;
|
||||||
|
collect(tree.object(), {}, {}, {}, false, &windows, &workspaces);
|
||||||
|
|
||||||
|
for (WindowInfo &window : windows) {
|
||||||
|
if (window.id != m_focusedWindowId)
|
||||||
|
continue;
|
||||||
|
for (WindowInfo &cached : m_windows) {
|
||||||
|
if (cached.id != window.id)
|
||||||
|
continue;
|
||||||
|
if (cached.x == window.x
|
||||||
|
&& cached.y == window.y
|
||||||
|
&& cached.width == window.width
|
||||||
|
&& cached.height == window.height
|
||||||
|
&& cached.decoX == window.decoX
|
||||||
|
&& cached.decoY == window.decoY
|
||||||
|
&& cached.decoWidth == window.decoWidth
|
||||||
|
&& cached.decoHeight == window.decoHeight
|
||||||
|
&& cached.contentX == window.contentX
|
||||||
|
&& cached.contentY == window.contentY
|
||||||
|
&& cached.fullscreen == window.fullscreen)
|
||||||
|
return false;
|
||||||
|
cached.x = window.x;
|
||||||
|
cached.y = window.y;
|
||||||
|
cached.width = window.width;
|
||||||
|
cached.height = window.height;
|
||||||
|
cached.decoX = window.decoX;
|
||||||
|
cached.decoY = window.decoY;
|
||||||
|
cached.decoWidth = window.decoWidth;
|
||||||
|
cached.decoHeight = window.decoHeight;
|
||||||
|
cached.contentX = window.contentX;
|
||||||
|
cached.contentY = window.contentY;
|
||||||
|
cached.fullscreen = window.fullscreen;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SwayWindowBackend::tryConnect()
|
||||||
|
{
|
||||||
|
if (m_ipc->isConnected())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const QString path = SwayIpcClient::socketPath();
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
qWarning("nebula: SWAYSOCK is unset; WindowService will continue without compositor IPC");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
++m_connectAttempts;
|
||||||
|
if (!m_ipc->connectToSway()) {
|
||||||
|
if (m_connectAttempts < kMaxConnectAttempts) {
|
||||||
|
const int delay = 150 * m_connectAttempts;
|
||||||
|
qInfo("nebula: Sway IPC not ready, retrying in %d ms (%d/%d)",
|
||||||
|
delay, m_connectAttempts, kMaxConnectAttempts);
|
||||||
|
m_retryTimer->start(delay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qWarning("nebula: could not connect to Sway IPC at %s; window management is unavailable",
|
||||||
|
qPrintable(path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_ipc->subscribe({QStringLiteral("window"), QStringLiteral("workspace"), QStringLiteral("output")})) {
|
||||||
|
qWarning("nebula: Sway IPC subscribe failed; window management is unavailable");
|
||||||
|
m_ipc->disconnectFromSway();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qInfo("nebula: WindowService connected to Sway IPC");
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SwayWindowBackend::refresh()
|
||||||
|
{
|
||||||
|
if (!m_ipc->isConnected())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const QJsonDocument tree = m_ipc->request(SwayIpcClient::GetTree);
|
||||||
|
if (!tree.isObject())
|
||||||
|
return;
|
||||||
|
|
||||||
|
QVector<WindowInfo> windows;
|
||||||
|
QVector<WorkspaceInfo> workspaces;
|
||||||
|
collect(tree.object(), {}, {}, {}, false, &windows, &workspaces);
|
||||||
|
|
||||||
|
QHash<QString, int> counts;
|
||||||
|
qint64 focused = 0;
|
||||||
|
for (WindowInfo &window : windows) {
|
||||||
|
window.maximized = m_maximized.contains(window.id);
|
||||||
|
if (window.minimized)
|
||||||
|
m_minimized.insert(window.id);
|
||||||
|
else
|
||||||
|
m_minimized.remove(window.id);
|
||||||
|
if (!window.minimized)
|
||||||
|
counts[window.workspace] += 1;
|
||||||
|
if (window.focused && !window.minimized)
|
||||||
|
focused = window.id;
|
||||||
|
}
|
||||||
|
for (WorkspaceInfo &workspace : workspaces) {
|
||||||
|
workspace.windowCount = counts.value(workspace.name, 0);
|
||||||
|
if (workspace.focused)
|
||||||
|
continue;
|
||||||
|
for (const WindowInfo &window : windows) {
|
||||||
|
if (window.focused && !window.minimized && window.workspace == workspace.name) {
|
||||||
|
workspace.focused = true;
|
||||||
|
workspace.visible = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool focusDidChange = (focused != m_focusedWindowId);
|
||||||
|
m_windows = std::move(windows);
|
||||||
|
m_workspaces = std::move(workspaces);
|
||||||
|
m_focusedWindowId = focused;
|
||||||
|
|
||||||
|
emit windowsChanged();
|
||||||
|
emit workspacesChanged();
|
||||||
|
if (focusDidChange)
|
||||||
|
emit focusChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SwayWindowBackend::handleEvent(quint32 type, const QJsonDocument &payload)
|
||||||
|
{
|
||||||
|
if (type == SwayIpcClient::WindowEvent) {
|
||||||
|
const QJsonObject object = payload.object();
|
||||||
|
const QString change = object.value(QStringLiteral("change")).toString();
|
||||||
|
if (change == QLatin1String("new")) {
|
||||||
|
const qint64 id = jsonId(object.value(QStringLiteral("container")).toObject().value(QStringLiteral("id")));
|
||||||
|
QTimer::singleShot(0, this, [this, id]() { handleNewWindow(id); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (change == QLatin1String("close")) {
|
||||||
|
const qint64 id = jsonId(object.value(QStringLiteral("container")).toObject().value(QStringLiteral("id")));
|
||||||
|
m_savedGeometry.remove(id);
|
||||||
|
m_maximized.remove(id);
|
||||||
|
m_minimized.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_refreshQueued)
|
||||||
|
return;
|
||||||
|
m_refreshQueued = true;
|
||||||
|
QTimer::singleShot(0, this, [this]() {
|
||||||
|
m_refreshQueued = false;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void SwayWindowBackend::handleNewWindow(qint64 windowId)
|
||||||
|
{
|
||||||
|
refresh();
|
||||||
|
const WindowInfo *window = findWindow(windowId);
|
||||||
|
if (!window || window->minimized || window->fullscreen)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const int slot = m_cascadeIndex++ % 8;
|
||||||
|
if (slot == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const int offset = slot * 32;
|
||||||
|
run(QStringLiteral("[con_id=%1] move right %2 px, move down %3 px")
|
||||||
|
.arg(windowId)
|
||||||
|
.arg(offset)
|
||||||
|
.arg(offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::run(const QString &command)
|
||||||
|
{
|
||||||
|
if (!m_ipc->isConnected())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const QJsonDocument reply = m_ipc->request(SwayIpcClient::RunCommand, command.toUtf8());
|
||||||
|
const QJsonArray results = reply.array();
|
||||||
|
bool ok = !results.isEmpty();
|
||||||
|
for (const QJsonValue &value : results) {
|
||||||
|
const QJsonObject object = value.toObject();
|
||||||
|
if (!object.value(QStringLiteral("success")).toBool(true)) {
|
||||||
|
qWarning("nebula: compositor command failed: %s (%s)",
|
||||||
|
qPrintable(command),
|
||||||
|
qPrintable(object.value(QStringLiteral("error")).toString()));
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok && !m_refreshQueued) {
|
||||||
|
m_refreshQueued = true;
|
||||||
|
QTimer::singleShot(0, this, [this]() {
|
||||||
|
m_refreshQueued = false;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::saveGeometry(qint64 windowId)
|
||||||
|
{
|
||||||
|
const WindowInfo *window = findWindow(windowId);
|
||||||
|
if (!window)
|
||||||
|
return false;
|
||||||
|
if (!m_savedGeometry.contains(windowId) && !m_maximized.contains(windowId))
|
||||||
|
m_savedGeometry.insert(windowId, QRect(window->x, window->y, window->width, window->height));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WindowInfo *SwayWindowBackend::findWindow(qint64 windowId) const
|
||||||
|
{
|
||||||
|
for (const WindowInfo &window : m_windows) {
|
||||||
|
if (window.id == windowId)
|
||||||
|
return &window;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SwayWindowBackend::isShellChrome(const QJsonObject &node)
|
||||||
|
{
|
||||||
|
const QString appId = node.value(QStringLiteral("app_id")).toString();
|
||||||
|
if (appId == QLatin1String("org.nebulaos.shell") || appId.startsWith(QLatin1String("nebula-")))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const QString name = node.value(QStringLiteral("name")).toString();
|
||||||
|
if (name == QLatin1String("Nebula Desktop")
|
||||||
|
|| name == QLatin1String("Nebula Top Bar")
|
||||||
|
|| name == QLatin1String("Nebula Launcher")
|
||||||
|
|| name == QLatin1String("Nebula Window Chrome")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString shell = node.value(QStringLiteral("shell")).toString();
|
||||||
|
if (shell.contains(QLatin1String("layer"), Qt::CaseInsensitive))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SwayWindowBackend::collect(const QJsonObject &node,
|
||||||
|
const QJsonObject &frame,
|
||||||
|
const QString &workspace,
|
||||||
|
const QString &output,
|
||||||
|
bool onScratchpad,
|
||||||
|
QVector<WindowInfo> *windows,
|
||||||
|
QVector<WorkspaceInfo> *workspaces)
|
||||||
|
{
|
||||||
|
const QString type = node.value(QStringLiteral("type")).toString();
|
||||||
|
const QString name = node.value(QStringLiteral("name")).toString();
|
||||||
|
|
||||||
|
QString nextWorkspace = workspace;
|
||||||
|
QString nextOutput = output;
|
||||||
|
bool nextScratchpad = onScratchpad;
|
||||||
|
|
||||||
|
if (type == QLatin1String("output")) {
|
||||||
|
nextOutput = name;
|
||||||
|
if (isPseudoOutput(name))
|
||||||
|
nextScratchpad = true;
|
||||||
|
} else if (type == QLatin1String("workspace")) {
|
||||||
|
nextWorkspace = name;
|
||||||
|
nextScratchpad = isScratchpadWorkspace(name);
|
||||||
|
if (!nextScratchpad) {
|
||||||
|
WorkspaceInfo info;
|
||||||
|
info.id = name;
|
||||||
|
info.name = name;
|
||||||
|
info.number = node.value(QStringLiteral("num")).toInt(-1);
|
||||||
|
info.focused = node.value(QStringLiteral("focused")).toBool();
|
||||||
|
info.visible = node.value(QStringLiteral("visible")).toBool();
|
||||||
|
info.output = node.value(QStringLiteral("output")).toString(output);
|
||||||
|
const QJsonObject rect = node.value(QStringLiteral("rect")).toObject();
|
||||||
|
info.x = rect.value(QStringLiteral("x")).toInt();
|
||||||
|
info.y = rect.value(QStringLiteral("y")).toInt();
|
||||||
|
info.width = rect.value(QStringLiteral("width")).toInt();
|
||||||
|
info.height = rect.value(QStringLiteral("height")).toInt();
|
||||||
|
workspaces->append(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject nextFrame = frame;
|
||||||
|
if (type == QLatin1String("floating_con"))
|
||||||
|
nextFrame = node;
|
||||||
|
|
||||||
|
if (nodeLooksLikeView(node) && !isShellChrome(node)) {
|
||||||
|
WindowInfo window;
|
||||||
|
window.id = jsonId(node.value(QStringLiteral("id")));
|
||||||
|
window.title = name;
|
||||||
|
window.appId = node.value(QStringLiteral("app_id")).toString();
|
||||||
|
const QJsonObject properties = node.value(QStringLiteral("window_properties")).toObject();
|
||||||
|
window.windowClass = properties.value(QStringLiteral("class")).toString();
|
||||||
|
if (window.title.isEmpty())
|
||||||
|
window.title = properties.value(QStringLiteral("title")).toString();
|
||||||
|
window.workspace = nextWorkspace;
|
||||||
|
window.output = nextOutput;
|
||||||
|
window.focused = node.value(QStringLiteral("focused")).toBool();
|
||||||
|
window.floating = nodeIsFloating(node) || nodeIsFloating(nextFrame);
|
||||||
|
window.fullscreen = node.value(QStringLiteral("fullscreen_mode")).toInt() > 0;
|
||||||
|
window.minimized = nextScratchpad;
|
||||||
|
applyFrameGeometry(window, node, nextFrame);
|
||||||
|
windows->append(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonArray nodes = node.value(QStringLiteral("nodes")).toArray();
|
||||||
|
for (const QJsonValue &child : nodes)
|
||||||
|
collect(child.toObject(), nextFrame, nextWorkspace, nextOutput, nextScratchpad, windows, workspaces);
|
||||||
|
|
||||||
|
const QJsonArray floating = node.value(QStringLiteral("floating_nodes")).toArray();
|
||||||
|
for (const QJsonValue &child : floating)
|
||||||
|
collect(child.toObject(), nextFrame, nextWorkspace, nextOutput, nextScratchpad, windows, workspaces);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "SwayIpcClient.hpp"
|
||||||
|
#include "WindowBackend.hpp"
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QRect>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
class SwayWindowBackend : public WindowBackend
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit SwayWindowBackend(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
bool isConnected() const override;
|
||||||
|
QVector<WindowInfo> windows() const override;
|
||||||
|
QVector<WorkspaceInfo> workspaces() const override;
|
||||||
|
qint64 focusedWindowId() const override;
|
||||||
|
|
||||||
|
bool focusWindow(qint64 windowId) override;
|
||||||
|
bool closeWindow(qint64 windowId) override;
|
||||||
|
bool maximizeWindow(qint64 windowId) override;
|
||||||
|
bool restoreWindow(qint64 windowId) override;
|
||||||
|
bool minimizeWindow(qint64 windowId) override;
|
||||||
|
bool setWindowFloating(qint64 windowId, bool floating) override;
|
||||||
|
bool moveWindow(qint64 windowId, int x, int y) override;
|
||||||
|
bool resizeWindow(qint64 windowId, int width, int height) override;
|
||||||
|
bool snapWindow(qint64 windowId, SnapEdge edge) override;
|
||||||
|
bool switchWorkspace(const QString &workspaceId) override;
|
||||||
|
bool updateFocusedGeometry() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void tryConnect();
|
||||||
|
void refresh();
|
||||||
|
void handleEvent(quint32 type, const QJsonDocument &payload);
|
||||||
|
void handleNewWindow(qint64 windowId);
|
||||||
|
bool run(const QString &command);
|
||||||
|
bool saveGeometry(qint64 windowId);
|
||||||
|
const WindowInfo *findWindow(qint64 windowId) const;
|
||||||
|
static bool isShellChrome(const QJsonObject &node);
|
||||||
|
static void collect(const QJsonObject &node,
|
||||||
|
const QJsonObject &frame,
|
||||||
|
const QString &workspace,
|
||||||
|
const QString &output,
|
||||||
|
bool onScratchpad,
|
||||||
|
QVector<WindowInfo> *windows,
|
||||||
|
QVector<WorkspaceInfo> *workspaces);
|
||||||
|
|
||||||
|
SwayIpcClient *m_ipc = nullptr;
|
||||||
|
QTimer *m_retryTimer = nullptr;
|
||||||
|
QVector<WindowInfo> m_windows;
|
||||||
|
QVector<WorkspaceInfo> m_workspaces;
|
||||||
|
QHash<qint64, QRect> m_savedGeometry;
|
||||||
|
QSet<qint64> m_maximized;
|
||||||
|
QSet<qint64> m_minimized;
|
||||||
|
qint64 m_focusedWindowId = 0;
|
||||||
|
int m_connectAttempts = 0;
|
||||||
|
int m_cascadeIndex = 0;
|
||||||
|
bool m_refreshQueued = false;
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
if(NOT TARGET Qt6::Quick)
|
||||||
|
project(NebulaUI LANGUAGES CXX)
|
||||||
|
find_package(Qt6 6.4 REQUIRED COMPONENTS Quick)
|
||||||
|
qt_standard_project_setup(REQUIRES 6.4)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
qt_add_library(NebulaUI STATIC)
|
||||||
|
|
||||||
|
set(NEBULA_UI_QML_DIR "${CMAKE_CURRENT_SOURCE_DIR}/qml/Nebula/UI")
|
||||||
|
|
||||||
|
set(NEBULA_UI_QML_FILES
|
||||||
|
Theme.qml
|
||||||
|
NebulaButton.qml
|
||||||
|
NebulaCard.qml
|
||||||
|
NebulaIconButton.qml
|
||||||
|
NebulaTextField.qml
|
||||||
|
NebulaToolTip.qml
|
||||||
|
FocusFrame.qml
|
||||||
|
NebulaIcon.qml
|
||||||
|
)
|
||||||
|
|
||||||
|
set(NEBULA_UI_QML_PATHS)
|
||||||
|
foreach(qml_file IN LISTS NEBULA_UI_QML_FILES)
|
||||||
|
set(qml_path "${NEBULA_UI_QML_DIR}/${qml_file}")
|
||||||
|
set_source_files_properties("${qml_path}" PROPERTIES
|
||||||
|
QT_RESOURCE_ALIAS "${qml_file}"
|
||||||
|
)
|
||||||
|
list(APPEND NEBULA_UI_QML_PATHS "${qml_path}")
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
set_source_files_properties("${NEBULA_UI_QML_DIR}/Theme.qml" PROPERTIES
|
||||||
|
QT_QML_SINGLETON_TYPE TRUE
|
||||||
|
)
|
||||||
|
|
||||||
|
qt_add_qml_module(NebulaUI
|
||||||
|
URI Nebula.UI
|
||||||
|
VERSION 1.0
|
||||||
|
RESOURCE_PREFIX /qt/qml
|
||||||
|
QML_FILES ${NEBULA_UI_QML_PATHS}
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Nebula.UI
|
||||||
|
|
||||||
|
Shared Qt Quick/QML design system for NebulaOS shells.
|
||||||
|
|
||||||
|
Desktop and the future Bigscreen shell should both import:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import Nebula.UI
|
||||||
|
```
|
||||||
|
|
||||||
|
This module contains reusable visual language only:
|
||||||
|
|
||||||
|
- colour, spacing, type, and motion tokens (`Theme`)
|
||||||
|
- generic controls (`NebulaButton`, `NebulaCard`, `NebulaIconButton`, `NebulaTextField`, `NebulaToolTip`)
|
||||||
|
- focus treatment (`FocusFrame`)
|
||||||
|
- compact vector icons (`NebulaIcon`)
|
||||||
|
|
||||||
|
Do not put Desktop- or Bigscreen-specific chrome here (top bar, launcher, session flow).
|
||||||
|
|
||||||
|
Built as part of `shells/desktop/shell`. It is not a standalone application.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property Item target: parent
|
||||||
|
property real radius: Theme.radiusSmall
|
||||||
|
property bool active: target && target.activeFocus
|
||||||
|
|
||||||
|
anchors.fill: target
|
||||||
|
anchors.margins: -2
|
||||||
|
z: 20
|
||||||
|
enabled: false
|
||||||
|
visible: active
|
||||||
|
Accessible.ignored: true
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
color: "transparent"
|
||||||
|
border.width: 2
|
||||||
|
border.color: Theme.focus
|
||||||
|
radius: root.radius + 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string text: ""
|
||||||
|
property bool checked: false
|
||||||
|
property color textColor: checked ? Theme.accentStrong : Theme.textPrimary
|
||||||
|
|
||||||
|
default property alias icon: iconHolder.data
|
||||||
|
|
||||||
|
signal clicked()
|
||||||
|
|
||||||
|
implicitWidth: row.implicitWidth + 18
|
||||||
|
implicitHeight: Theme.controlHeight
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: root.text
|
||||||
|
Accessible.checkable: true
|
||||||
|
Accessible.checked: root.checked
|
||||||
|
Accessible.onPressAction: root.clicked()
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.radiusSmall
|
||||||
|
color: (hover.containsMouse || root.checked) ? Theme.accentSoft : "transparent"
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation {
|
||||||
|
duration: Theme.animationFast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: row
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: iconHolder
|
||||||
|
width: children.length > 0 ? 18 : 0
|
||||||
|
height: 18
|
||||||
|
visible: width > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.text
|
||||||
|
color: root.textColor
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
visible: root.text.length > 0
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: hover
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.clicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
FocusFrame {
|
||||||
|
radius: Theme.radiusSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
color: Theme.surface
|
||||||
|
border.color: Theme.border
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusLarge
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: shadow
|
||||||
|
z: -1
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.topMargin: 10
|
||||||
|
anchors.bottomMargin: -10
|
||||||
|
radius: root.radius
|
||||||
|
color: Theme.shadow
|
||||||
|
opacity: 0.85
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string name: "nebula"
|
||||||
|
property color color: Theme.textPrimary
|
||||||
|
property int size: 16
|
||||||
|
property real value: 1
|
||||||
|
|
||||||
|
implicitWidth: size
|
||||||
|
implicitHeight: size
|
||||||
|
Accessible.ignored: true
|
||||||
|
|
||||||
|
Canvas {
|
||||||
|
id: canvas
|
||||||
|
anchors.fill: parent
|
||||||
|
antialiasing: true
|
||||||
|
onPaint: root.paint(getContext("2d"))
|
||||||
|
}
|
||||||
|
|
||||||
|
onNameChanged: canvas.requestPaint()
|
||||||
|
onColorChanged: canvas.requestPaint()
|
||||||
|
onValueChanged: canvas.requestPaint()
|
||||||
|
onWidthChanged: canvas.requestPaint()
|
||||||
|
onHeightChanged: canvas.requestPaint()
|
||||||
|
|
||||||
|
function paint(ctx) {
|
||||||
|
ctx.reset()
|
||||||
|
ctx.strokeStyle = root.color
|
||||||
|
ctx.fillStyle = root.color
|
||||||
|
ctx.lineCap = "round"
|
||||||
|
ctx.lineJoin = "round"
|
||||||
|
ctx.scale(width / 24, height / 24)
|
||||||
|
|
||||||
|
switch (root.name) {
|
||||||
|
case "nebula":
|
||||||
|
paintNebula(ctx)
|
||||||
|
break
|
||||||
|
case "search":
|
||||||
|
paintSearch(ctx)
|
||||||
|
break
|
||||||
|
case "wifi":
|
||||||
|
paintWifi(ctx)
|
||||||
|
break
|
||||||
|
case "speaker":
|
||||||
|
paintSpeaker(ctx)
|
||||||
|
break
|
||||||
|
case "battery":
|
||||||
|
paintBattery(ctx)
|
||||||
|
break
|
||||||
|
case "power":
|
||||||
|
paintPower(ctx)
|
||||||
|
break
|
||||||
|
case "restart":
|
||||||
|
paintRestart(ctx)
|
||||||
|
break
|
||||||
|
case "folder":
|
||||||
|
paintFolder(ctx)
|
||||||
|
break
|
||||||
|
case "globe":
|
||||||
|
paintGlobe(ctx)
|
||||||
|
break
|
||||||
|
case "terminal":
|
||||||
|
paintTerminal(ctx)
|
||||||
|
break
|
||||||
|
case "settings":
|
||||||
|
paintSettings(ctx)
|
||||||
|
break
|
||||||
|
case "game":
|
||||||
|
paintGame(ctx)
|
||||||
|
break
|
||||||
|
case "photo":
|
||||||
|
paintPhoto(ctx)
|
||||||
|
break
|
||||||
|
case "music":
|
||||||
|
paintMusic(ctx)
|
||||||
|
break
|
||||||
|
case "document":
|
||||||
|
paintDocument(ctx)
|
||||||
|
break
|
||||||
|
case "application":
|
||||||
|
paintApplication(ctx)
|
||||||
|
break
|
||||||
|
case "minimize":
|
||||||
|
paintMinimize(ctx)
|
||||||
|
break
|
||||||
|
case "maximize":
|
||||||
|
paintMaximize(ctx)
|
||||||
|
break
|
||||||
|
case "restore":
|
||||||
|
paintRestore(ctx)
|
||||||
|
break
|
||||||
|
case "close":
|
||||||
|
paintClose(ctx)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
paintApplication(ctx)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintNebula(ctx) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(12, 12, 4.2, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(12, 12)
|
||||||
|
ctx.rotate(-26 * Math.PI / 180)
|
||||||
|
ctx.scale(10 / 4.15, 1)
|
||||||
|
ctx.lineWidth = 1.35 * (4.15 / 10)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(0, 0, 4.15, 0, Math.PI * 2)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintSearch(ctx) {
|
||||||
|
ctx.lineWidth = 1.6
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(11, 11, 6.25, 0, Math.PI * 2)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(15.8, 15.8)
|
||||||
|
ctx.lineTo(20, 20)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintWifi(ctx) {
|
||||||
|
const strength = root.value
|
||||||
|
ctx.lineWidth = 1.6
|
||||||
|
ctx.globalAlpha = strength > 0 ? 1 : 0.28
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(4.8, 15.2)
|
||||||
|
ctx.bezierCurveTo(8.8, 11.2, 15.2, 11.2, 19.2, 15.2)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.globalAlpha = strength > 1 ? 1 : 0.28
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(7.4, 17.4)
|
||||||
|
ctx.bezierCurveTo(10, 14.9, 14, 14.9, 16.6, 17.4)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(12, 20, 1.15, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintSpeaker(ctx) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(4.5, 9.4)
|
||||||
|
ctx.lineTo(7.6, 9.4)
|
||||||
|
ctx.lineTo(12, 6.2)
|
||||||
|
ctx.lineTo(12, 17.8)
|
||||||
|
ctx.lineTo(7.6, 14.6)
|
||||||
|
ctx.lineTo(4.5, 14.6)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(12.5, 12, 4.2, -0.7, 0.7)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.globalAlpha = 0.7
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(12.5, 12, 7.2, -0.85, 0.85)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintBattery(ctx) {
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.beginPath()
|
||||||
|
roundedRect(ctx, 2.5, 7.25, 16.5, 9.5, 2)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
roundedRect(ctx, 19.5, 10, 2, 4, 0.6)
|
||||||
|
ctx.fill()
|
||||||
|
const fillWidth = Math.max(1.5, (14 * Math.min(100, Math.max(0, root.value))) / 100)
|
||||||
|
ctx.beginPath()
|
||||||
|
roundedRect(ctx, 4.2, 9, fillWidth, 6, 1)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintPower(ctx) {
|
||||||
|
ctx.lineWidth = 1.7
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(12, 4.5)
|
||||||
|
ctx.lineTo(12, 11.7)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(12, 13, 6.2, Math.PI * 1.15, Math.PI * -0.15, false)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintRestart(ctx) {
|
||||||
|
ctx.lineWidth = 1.7
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(12, 12, 7, -0.2, Math.PI * 1.55, true)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(19, 5.2)
|
||||||
|
ctx.lineTo(19, 9.6)
|
||||||
|
ctx.lineTo(14.6, 9.6)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintMinimize(ctx) {
|
||||||
|
ctx.lineWidth = 1.7
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(6.4, 16.2)
|
||||||
|
ctx.lineTo(17.6, 16.2)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintMaximize(ctx) {
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.beginPath()
|
||||||
|
roundedRect(ctx, 6.2, 6.2, 11.6, 11.6, 2)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintRestore(ctx) {
|
||||||
|
ctx.lineWidth = 1.45
|
||||||
|
ctx.beginPath()
|
||||||
|
roundedRect(ctx, 8.6, 5.6, 9.4, 9.4, 1.7)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
roundedRect(ctx, 5.6, 8.6, 9.4, 9.4, 1.7)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintClose(ctx) {
|
||||||
|
ctx.lineWidth = 1.7
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(7.3, 7.3)
|
||||||
|
ctx.lineTo(16.7, 16.7)
|
||||||
|
ctx.moveTo(16.7, 7.3)
|
||||||
|
ctx.lineTo(7.3, 16.7)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintApplication(ctx) {
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.beginPath()
|
||||||
|
roundedRect(ctx, 4.2, 5.2, 15.6, 13.6, 2.2)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(4.2, 8.4)
|
||||||
|
ctx.lineTo(19.8, 8.4)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(7, 6.8, 0.7, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(9.4, 6.8, 0.7, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintFolder(ctx) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(3.5, 8.2)
|
||||||
|
ctx.quadraticCurveTo(3.5, 6.4, 5.3, 6.4)
|
||||||
|
ctx.lineTo(9.4, 6.4)
|
||||||
|
ctx.lineTo(11, 8.2)
|
||||||
|
ctx.lineTo(18.7, 8.2)
|
||||||
|
ctx.quadraticCurveTo(20.5, 8.2, 20.5, 10)
|
||||||
|
ctx.lineTo(20.5, 16.8)
|
||||||
|
ctx.quadraticCurveTo(20.5, 18.6, 18.7, 18.6)
|
||||||
|
ctx.lineTo(5.3, 18.6)
|
||||||
|
ctx.quadraticCurveTo(3.5, 18.6, 3.5, 16.8)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintGlobe(ctx) {
|
||||||
|
ctx.lineWidth = 1.6
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(12, 12, 7.25, 0, Math.PI * 2)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.lineWidth = 1.4
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(12, 12)
|
||||||
|
ctx.scale(3.1 / 7.25, 1)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(0, 0, 7.25, 0, Math.PI * 2)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.restore()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(5.2, 12)
|
||||||
|
ctx.lineTo(18.8, 12)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintTerminal(ctx) {
|
||||||
|
ctx.lineWidth = 1.7
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(7, 9.2)
|
||||||
|
ctx.lineTo(10.4, 12)
|
||||||
|
ctx.lineTo(7, 14.8)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(12.2, 15.2)
|
||||||
|
ctx.lineTo(17, 15.2)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintSettings(ctx) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(12, 12, 2.4, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.lineWidth = 1.6
|
||||||
|
const ticks = [
|
||||||
|
[12, 5.2, 12, 6.8],
|
||||||
|
[12, 17.2, 12, 18.8],
|
||||||
|
[5.2, 12, 6.8, 12],
|
||||||
|
[17.2, 12, 18.8, 12],
|
||||||
|
[7.2, 7.2, 8.3, 8.3],
|
||||||
|
[15.7, 15.7, 16.8, 16.8],
|
||||||
|
[16.8, 7.2, 15.7, 8.3],
|
||||||
|
[8.3, 15.7, 7.2, 16.8]
|
||||||
|
]
|
||||||
|
for (let i = 0; i < ticks.length; i++) {
|
||||||
|
const t = ticks[i]
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(t[0], t[1])
|
||||||
|
ctx.lineTo(t[2], t[3])
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintGame(ctx) {
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(5.2, 10.4)
|
||||||
|
ctx.lineTo(18.8, 10.4)
|
||||||
|
ctx.quadraticCurveTo(20.4, 10.4, 20.3, 12.1)
|
||||||
|
ctx.lineTo(19.8, 15.3)
|
||||||
|
ctx.quadraticCurveTo(19.6, 16.9, 18.5, 16.9)
|
||||||
|
ctx.lineTo(16.3, 16.9)
|
||||||
|
ctx.lineTo(15, 15.1)
|
||||||
|
ctx.lineTo(10, 15.1)
|
||||||
|
ctx.lineTo(8.7, 16.9)
|
||||||
|
ctx.lineTo(6.5, 16.9)
|
||||||
|
ctx.quadraticCurveTo(5.4, 16.9, 5.2, 15.3)
|
||||||
|
ctx.lineTo(4.7, 12.1)
|
||||||
|
ctx.quadraticCurveTo(4.6, 10.4, 5.2, 10.4)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(8, 12.4)
|
||||||
|
ctx.lineTo(8, 15.6)
|
||||||
|
ctx.moveTo(6.4, 14)
|
||||||
|
ctx.lineTo(9.6, 14)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(15.4, 13.2, 0.9, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(17.2, 15, 0.9, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintPhoto(ctx) {
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.beginPath()
|
||||||
|
roundedRect(ctx, 4, 6.2, 16, 11.6, 2)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(9, 10.2, 1.3, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(7.2, 15.8)
|
||||||
|
ctx.lineTo(10.6, 12.4)
|
||||||
|
ctx.lineTo(13.2, 15)
|
||||||
|
ctx.lineTo(15.4, 12.9)
|
||||||
|
ctx.lineTo(18.8, 15.8)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintMusic(ctx) {
|
||||||
|
ctx.lineWidth = 1.6
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(10, 16.6)
|
||||||
|
ctx.lineTo(10, 7.4)
|
||||||
|
ctx.lineTo(18, 5.8)
|
||||||
|
ctx.lineTo(18, 15.2)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(8.2, 16.6, 2.2, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(16.2, 15.2, 2.2, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintDocument(ctx) {
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(7, 5.5)
|
||||||
|
ctx.lineTo(13.2, 5.5)
|
||||||
|
ctx.lineTo(17.5, 10)
|
||||||
|
ctx.lineTo(17.5, 18.5)
|
||||||
|
ctx.quadraticCurveTo(17.5, 20, 16, 20)
|
||||||
|
ctx.lineTo(7, 20)
|
||||||
|
ctx.quadraticCurveTo(5.5, 20, 5.5, 18.5)
|
||||||
|
ctx.lineTo(5.5, 7)
|
||||||
|
ctx.quadraticCurveTo(5.5, 5.5, 7, 5.5)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(13.2, 5.5)
|
||||||
|
ctx.lineTo(13.2, 10)
|
||||||
|
ctx.lineTo(17.5, 10)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(8.8, 13.4)
|
||||||
|
ctx.lineTo(15.2, 13.4)
|
||||||
|
ctx.moveTo(8.8, 16.2)
|
||||||
|
ctx.lineTo(13.2, 16.2)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundedRect(ctx, x, y, w, h, r) {
|
||||||
|
const radius = Math.min(r, w / 2, h / 2)
|
||||||
|
ctx.moveTo(x + radius, y)
|
||||||
|
ctx.lineTo(x + w - radius, y)
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + radius)
|
||||||
|
ctx.lineTo(x + w, y + h - radius)
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h)
|
||||||
|
ctx.lineTo(x + radius, y + h)
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - radius)
|
||||||
|
ctx.lineTo(x, y + radius)
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y)
|
||||||
|
ctx.closePath()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string label: ""
|
||||||
|
property bool active: false
|
||||||
|
property bool danger: false
|
||||||
|
readonly property bool hovered: hover.containsMouse
|
||||||
|
readonly property bool pressed: hover.pressed
|
||||||
|
readonly property color iconColor: {
|
||||||
|
if (danger && hovered)
|
||||||
|
return Theme.danger
|
||||||
|
if (active)
|
||||||
|
return Theme.accentStrong
|
||||||
|
if (hovered)
|
||||||
|
return Theme.textPrimary
|
||||||
|
return Theme.textSecondary
|
||||||
|
}
|
||||||
|
|
||||||
|
default property alias icon: iconHolder.data
|
||||||
|
|
||||||
|
signal clicked()
|
||||||
|
|
||||||
|
implicitWidth: Theme.controlHeight
|
||||||
|
implicitHeight: Theme.controlHeight
|
||||||
|
activeFocusOnTab: true
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: root.label
|
||||||
|
Accessible.onPressAction: root.clicked()
|
||||||
|
|
||||||
|
Keys.onReturnPressed: {
|
||||||
|
event.accepted = true
|
||||||
|
root.clicked()
|
||||||
|
}
|
||||||
|
Keys.onSpacePressed: {
|
||||||
|
event.accepted = true
|
||||||
|
root.clicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.radiusSmall
|
||||||
|
color: {
|
||||||
|
if (root.danger && hover.pressed)
|
||||||
|
return Theme.dangerSoftStrong
|
||||||
|
if (root.danger && hover.containsMouse)
|
||||||
|
return Theme.dangerSoft
|
||||||
|
if (root.active)
|
||||||
|
return Theme.accentSoft
|
||||||
|
if (hover.pressed)
|
||||||
|
return Theme.pressedFill
|
||||||
|
if (hover.containsMouse)
|
||||||
|
return Theme.hoverFill
|
||||||
|
return "transparent"
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation {
|
||||||
|
duration: Theme.animationFast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: iconHolder
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: hover
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.clicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
FocusFrame {
|
||||||
|
radius: Theme.radiusSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
NebulaToolTip {
|
||||||
|
text: root.label
|
||||||
|
active: hover.containsMouse && !hover.pressed && root.label.length > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property alias text: input.text
|
||||||
|
property alias placeholderText: placeholder.text
|
||||||
|
property alias input: input
|
||||||
|
|
||||||
|
signal accepted()
|
||||||
|
|
||||||
|
implicitHeight: Theme.searchHeight
|
||||||
|
implicitWidth: 200
|
||||||
|
Accessible.role: Accessible.EditableText
|
||||||
|
Accessible.name: placeholder.text
|
||||||
|
Accessible.editable: true
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.radiusMedium
|
||||||
|
color: Theme.searchFill
|
||||||
|
border.width: 1
|
||||||
|
border.color: input.activeFocus ? Theme.borderStrong : Theme.border
|
||||||
|
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation {
|
||||||
|
duration: Theme.animationFast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: row
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
NebulaIcon {
|
||||||
|
id: searchIcon
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
name: "search"
|
||||||
|
size: 16
|
||||||
|
color: input.activeFocus ? Theme.textPrimary : Theme.textSecondary
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width - searchIcon.width - row.spacing
|
||||||
|
height: parent.height
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: placeholder
|
||||||
|
anchors.fill: parent
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
text: qsTr("Search")
|
||||||
|
color: Theme.textTertiary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
visible: input.text.length === 0 && !input.preeditText
|
||||||
|
}
|
||||||
|
|
||||||
|
TextInput {
|
||||||
|
id: input
|
||||||
|
anchors.fill: parent
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
color: Theme.textPrimary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
clip: true
|
||||||
|
selectByMouse: true
|
||||||
|
activeFocusOnTab: true
|
||||||
|
onAccepted: root.accepted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FocusFrame {
|
||||||
|
target: root
|
||||||
|
active: input.activeFocus
|
||||||
|
radius: Theme.radiusMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Window
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string text: ""
|
||||||
|
property bool active: false
|
||||||
|
property int delay: 550
|
||||||
|
property int timeout: 4000
|
||||||
|
|
||||||
|
width: 0
|
||||||
|
height: 0
|
||||||
|
enabled: false
|
||||||
|
Accessible.ignored: true
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: showTimer
|
||||||
|
interval: root.delay
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: hideTimer
|
||||||
|
interval: root.timeout
|
||||||
|
repeat: false
|
||||||
|
onTriggered: tip.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Window {
|
||||||
|
id: tip
|
||||||
|
flags: Qt.Popup | Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
|
||||||
|
color: "transparent"
|
||||||
|
visible: false
|
||||||
|
width: Math.ceil(tipText.implicitWidth) + 16
|
||||||
|
height: Math.ceil(tipText.implicitHeight) + 10
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.radiusSmall
|
||||||
|
color: Theme.surfaceRaised
|
||||||
|
border.color: Theme.borderStrong
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: tipText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.text
|
||||||
|
color: Theme.textPrimary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onActiveChanged: root.sync()
|
||||||
|
onTextChanged: {
|
||||||
|
if (tip.visible)
|
||||||
|
root.place()
|
||||||
|
else
|
||||||
|
root.sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
if (root.active && root.text.length > 0) {
|
||||||
|
showTimer.restart()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showTimer.stop()
|
||||||
|
hideTimer.stop()
|
||||||
|
tip.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (!root.active || root.text.length === 0)
|
||||||
|
return
|
||||||
|
const host = root.parent
|
||||||
|
if (!host)
|
||||||
|
return
|
||||||
|
const win = host.Window.window
|
||||||
|
if (win)
|
||||||
|
tip.transientParent = win
|
||||||
|
root.place()
|
||||||
|
tip.visible = true
|
||||||
|
hideTimer.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
function place() {
|
||||||
|
const host = root.parent
|
||||||
|
if (!host)
|
||||||
|
return
|
||||||
|
const pos = host.mapToGlobal(host.width / 2, host.height + 6)
|
||||||
|
const screenWidth = Screen.width
|
||||||
|
tip.x = Math.round(Math.max(8, Math.min(pos.x - tip.width / 2, screenWidth - tip.width - 8)))
|
||||||
|
tip.y = Math.round(pos.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
// Colours from the Nebula Desktop React prototype (tokens.css).
|
||||||
|
readonly property color background: "#0b0f16"
|
||||||
|
readonly property color backgroundDeep: "#080b11"
|
||||||
|
|
||||||
|
readonly property color surface: Qt.rgba(20 / 255, 26 / 255, 36 / 255, 0.78)
|
||||||
|
readonly property color surfaceRaised: Qt.rgba(32 / 255, 40 / 255, 54 / 255, 0.9)
|
||||||
|
readonly property color topBarBackground: Qt.rgba(10 / 255, 13 / 255, 18 / 255, 0.55)
|
||||||
|
|
||||||
|
readonly property color border: Qt.rgba(232 / 255, 236 / 255, 242 / 255, 0.08)
|
||||||
|
readonly property color borderStrong: Qt.rgba(232 / 255, 236 / 255, 242 / 255, 0.14)
|
||||||
|
|
||||||
|
readonly property color textPrimary: "#e8ecf2"
|
||||||
|
readonly property color textSecondary: "#8e99ab"
|
||||||
|
readonly property color textTertiary: "#6b7587"
|
||||||
|
|
||||||
|
readonly property color accent: "#7ea0c4"
|
||||||
|
readonly property color accentStrong: "#9bb6d4"
|
||||||
|
readonly property color accentSoft: Qt.rgba(126 / 255, 160 / 255, 196 / 255, 0.16)
|
||||||
|
|
||||||
|
readonly property color danger: "#c98888"
|
||||||
|
readonly property color focus: Qt.rgba(126 / 255, 160 / 255, 196 / 255, 0.55)
|
||||||
|
|
||||||
|
readonly property color hoverFill: Qt.rgba(1, 1, 1, 0.06)
|
||||||
|
readonly property color pressedFill: Qt.rgba(1, 1, 1, 0.10)
|
||||||
|
readonly property color dangerSoft: Qt.rgba(201 / 255, 136 / 255, 136 / 255, 0.18)
|
||||||
|
readonly property color dangerSoftStrong: Qt.rgba(201 / 255, 136 / 255, 136 / 255, 0.28)
|
||||||
|
readonly property color trafficClose: "#ff5f57"
|
||||||
|
readonly property color trafficMinimise: "#febc2e"
|
||||||
|
readonly property color trafficZoom: "#28c840"
|
||||||
|
readonly property color tileHover: Qt.rgba(1, 1, 1, 0.05)
|
||||||
|
readonly property color iconWell: Qt.rgba(1, 1, 1, 0.05)
|
||||||
|
readonly property color iconWellHighlight: Qt.rgba(1, 1, 1, 0.06)
|
||||||
|
readonly property color searchFill: Qt.rgba(8 / 255, 11 / 255, 17 / 255, 0.45)
|
||||||
|
readonly property color shadow: Qt.rgba(0, 0, 0, 0.42)
|
||||||
|
|
||||||
|
readonly property int radiusSmall: 6
|
||||||
|
readonly property int radiusMedium: 10
|
||||||
|
readonly property int radiusLarge: 14
|
||||||
|
|
||||||
|
readonly property int spacingSmall: 8
|
||||||
|
readonly property int spacingMedium: 16
|
||||||
|
readonly property int spacingLarge: 24
|
||||||
|
|
||||||
|
readonly property int animationFast: 120
|
||||||
|
readonly property int animationNormal: 200
|
||||||
|
|
||||||
|
readonly property int topBarHeight: 40
|
||||||
|
readonly property int controlHeight: 28
|
||||||
|
readonly property int searchHeight: 36
|
||||||
|
readonly property int trafficLightSize: 20
|
||||||
|
readonly property int trafficLightGap: 9
|
||||||
|
readonly property int windowChromeInset: 14
|
||||||
|
|
||||||
|
readonly property int fontSize: 13
|
||||||
|
readonly property int fontSizeApp: 12
|
||||||
|
readonly property int fontSizeSmall: 11
|
||||||
|
|
||||||
|
readonly property string fontFamily: "Ubuntu"
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# VMware / development-only launcher for nebula-shell.
|
||||||
|
#
|
||||||
|
# The Ubuntu VMware guest GPU cannot currently run the Qt Quick accelerated
|
||||||
|
# scene graph correctly. This wrapper sets QT_QUICK_BACKEND=software.
|
||||||
|
#
|
||||||
|
# Production sessions must start `nebula-shell` directly and must NOT export
|
||||||
|
# QT_QUICK_BACKEND. GPU-accelerated Qt Quick is the production default.
|
||||||
|
#
|
||||||
|
# Usage (from a running Nebula Sway session, or via nebula-sway.conf exec):
|
||||||
|
# scripts/run-nebula-vm.sh
|
||||||
|
#
|
||||||
|
# Disable autostart:
|
||||||
|
# NEBULA_SHELL_AUTOSTART=0 sway -c compositor/sway/nebula-sway.conf
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "${NEBULA_SHELL_AUTOSTART:-1}" = "0" ]; then
|
||||||
|
echo "nebula: shell autostart disabled (NEBULA_SHELL_AUTOSTART=0)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
repo_root=$(CDPATH= cd -- "${script_dir}/.." && pwd)
|
||||||
|
|
||||||
|
export XCURSOR_THEME="${XCURSOR_THEME:-Adwaita}"
|
||||||
|
export XCURSOR_SIZE="${XCURSOR_SIZE:-24}"
|
||||||
|
|
||||||
|
# Development-only. Do not copy this into production session files.
|
||||||
|
export QT_QUICK_BACKEND="${QT_QUICK_BACKEND:-software}"
|
||||||
|
|
||||||
|
resolve_shell() {
|
||||||
|
if [ -n "${NEBULA_SHELL:-}" ] && [ -x "${NEBULA_SHELL}" ]; then
|
||||||
|
printf '%s\n' "${NEBULA_SHELL}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for candidate in \
|
||||||
|
"${repo_root}/shells/desktop/shell/build/nebula-shell" \
|
||||||
|
"${repo_root}/build/nebula-shell" \
|
||||||
|
"${PWD}/shells/desktop/shell/build/nebula-shell" \
|
||||||
|
"${PWD}/build/nebula-shell"
|
||||||
|
do
|
||||||
|
if [ -x "${candidate}" ]; then
|
||||||
|
printf '%s\n' "${candidate}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if command -v nebula-shell >/dev/null 2>&1; then
|
||||||
|
command -v nebula-shell
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! shell_bin=$(resolve_shell); then
|
||||||
|
echo "nebula: nebula-shell not found." >&2
|
||||||
|
echo "Build it with:" >&2
|
||||||
|
echo " cd shells/desktop/shell && cmake -S . -B build && cmake --build build -j\$(nproc)" >&2
|
||||||
|
echo "Or set NEBULA_SHELL to the executable path." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "nebula: starting ${shell_bin} (QT_QUICK_BACKEND=${QT_QUICK_BACKEND})"
|
||||||
|
exec "${shell_bin}" "$@"
|
||||||
+13
-3
@@ -1,10 +1,20 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# NebulaOS session startup
|
# NebulaOS production session startup
|
||||||
|
#
|
||||||
|
# Production must start nebula-shell without QT_QUICK_BACKEND=software.
|
||||||
|
# GPU-accelerated Qt Quick is the production default.
|
||||||
|
#
|
||||||
|
# The Ubuntu VMware development VM is different: use
|
||||||
|
# compositor/sway/dev/run-sway-vm.sh (or sway -c compositor/sway/nebula-sway.conf
|
||||||
|
# from the repository root) so the development wrapper can set the software
|
||||||
|
# scene-graph backend.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
export XDG_CURRENT_DESKTOP=NebulaOS
|
export XDG_CURRENT_DESKTOP=NebulaOS
|
||||||
export XDG_SESSION_DESKTOP=nebula
|
export XDG_SESSION_DESKTOP=nebula
|
||||||
|
export XCURSOR_THEME="${XCURSOR_THEME:-Adwaita}"
|
||||||
|
export XCURSOR_SIZE="${XCURSOR_SIZE:-24}"
|
||||||
|
|
||||||
# Shell and services are wired here as they land
|
# Production desktop chrome: shells/desktop/shell (nebula-shell)
|
||||||
# exec nebula-shell-desktop
|
# exec nebula-shell
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
# Nebula Desktop
|
||||||
|
|
||||||
|
Production shell for the mouse-and-keyboard NebulaOS desktop. This is Nebula Desktop **0.2**: a usable floating-first session with compositor-backed window management.
|
||||||
|
|
||||||
|
The user can enter the development session, use the full output, open the Nebula launcher, start real installed applications (including Foot), move and resize them as ordinary windows, see the focused application in the top bar, and close windows from the GUI.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Nebula Desktop is a **Qt Quick / QML** Wayland shell. It draws system chrome only. It does not host application or game windows.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ubuntu
|
||||||
|
↓
|
||||||
|
Wayland
|
||||||
|
↓
|
||||||
|
Sway
|
||||||
|
temporary compositor
|
||||||
|
↓
|
||||||
|
Nebula Shell
|
||||||
|
C++20 + Qt 6 + Qt Quick/QML + LayerShellQt
|
||||||
|
```
|
||||||
|
|
||||||
|
Eventual target:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ubuntu/Linux
|
||||||
|
↓
|
||||||
|
Wayland
|
||||||
|
↓
|
||||||
|
Nebula compositor
|
||||||
|
↓
|
||||||
|
Nebula Shell
|
||||||
|
Qt Quick/QML
|
||||||
|
```
|
||||||
|
|
||||||
|
Normal applications remain ordinary Wayland / XWayland clients of the compositor:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nebula Launcher
|
||||||
|
↓
|
||||||
|
ApplicationService
|
||||||
|
↓
|
||||||
|
application process
|
||||||
|
↓
|
||||||
|
Wayland / XWayland
|
||||||
|
↓
|
||||||
|
Sway
|
||||||
|
```
|
||||||
|
|
||||||
|
Window management is a separate path. QML never talks to Sway:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nebula QML
|
||||||
|
↓
|
||||||
|
WindowService core/windows
|
||||||
|
↓
|
||||||
|
generic backend
|
||||||
|
↓
|
||||||
|
Sway IPC now
|
||||||
|
Nebula compositor later
|
||||||
|
↓
|
||||||
|
Sway
|
||||||
|
/ \
|
||||||
|
Wayland app XWayland app
|
||||||
|
```
|
||||||
|
|
||||||
|
Games, browsers, terminals, and other apps never pass through Qt Quick. Do not draw third-party application windows inside QML, and do not wrap Wayland clients in custom Nebula QML windows.
|
||||||
|
|
||||||
|
## Production stack
|
||||||
|
|
||||||
|
| Piece | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Language | C++20 |
|
||||||
|
| UI | Qt 6, Qt Quick, QML |
|
||||||
|
| Layer surfaces | LayerShellQt |
|
||||||
|
| Installed apps | `core/applications` (`import Nebula.Applications`) |
|
||||||
|
| Windows / workspaces | `core/windows` (`import Nebula.Windows`) |
|
||||||
|
| Build | CMake |
|
||||||
|
| Shared design system | `packages/nebula-ui` (`import Nebula.UI`) |
|
||||||
|
|
||||||
|
The executable is `nebula-shell`.
|
||||||
|
|
||||||
|
QML module URI: `Nebula.Shell`.
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
Independent Wayland layer-shell windows:
|
||||||
|
|
||||||
|
| Surface | Layer | Role |
|
||||||
|
|---------|-------|------|
|
||||||
|
| Desktop | `BACKGROUND` | Fills the current output, no exclusive zone, no keyboard focus |
|
||||||
|
| Top Bar | `TOP` | 40 logical pixels, exclusive zone, spans the output width |
|
||||||
|
| Launcher | `OVERLAY` | Covers the output below the top bar while open; hidden otherwise |
|
||||||
|
|
||||||
|
Stacking:
|
||||||
|
|
||||||
|
```text
|
||||||
|
OVERLAY
|
||||||
|
Nebula Launcher
|
||||||
|
|
||||||
|
TOP
|
||||||
|
Nebula Top Bar
|
||||||
|
|
||||||
|
NORMAL WAYLAND WINDOWS
|
||||||
|
Foot, Firefox, applications, games, …
|
||||||
|
|
||||||
|
BACKGROUND
|
||||||
|
Nebula Desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
Surfaces follow the live Wayland output geometry from Qt (`OutputTracker`). They do not assume 1920×1080 or other fixed desktop sizes. If VMware resizes the display, the shell updates.
|
||||||
|
|
||||||
|
## Window management
|
||||||
|
|
||||||
|
Nebula Desktop 0.2 is **floating-first**. Sway is still the compositor, but ordinary applications no longer tile to fill the workspace.
|
||||||
|
|
||||||
|
- New Wayland (`app_id`) and XWayland (`class`) windows open floating at about 70% of the **workspace** (usable area below the exclusive top bar), centred, with a simple cascade offset for subsequent windows.
|
||||||
|
- Sway provides a temporary restrained title bar and border so windows can be identified, dragged, and resized. Custom Nebula decorations are later work.
|
||||||
|
- Super+Left/Right mouse remains a development move/resize fallback. Super+Left/Right/Up/Down snap or maximise using workspace-relative sizes.
|
||||||
|
|
||||||
|
`WindowService` owns the logical model:
|
||||||
|
|
||||||
|
- `WindowModel` roles: `windowId`, `title`, `appId`, `windowClass`, `workspace`, `focused`, `floating`, `fullscreen`, `x`, `y`, `width`, `height`, plus `displayName`, `iconName`, `minimized`, `maximized`, `output`
|
||||||
|
- `WorkspaceModel` roles: `workspaceId`, `name`, `number`, `focused`, `visible`, `output`, `windowCount`
|
||||||
|
- Live updates from Sway IPC `window` / `workspace` / `output` events (no polling, no `swaymsg` from QML)
|
||||||
|
- Focused **normal application** only — desktop, top bar, launcher, and overlays are not the active app
|
||||||
|
- Operations: `focusWindow`, `closeWindow` (compositor close request, not process kill), `maximizeWindow`, `restoreWindow`, `setWindowFloating`, `moveWindow`, `resizeWindow`, `snapWindow`, `switchWorkspace`
|
||||||
|
- Maximise fills the workspace usable area (below the top bar). Restore returns the previous floating geometry when WindowService saved it
|
||||||
|
- Minimise exists on the service (Sway scratchpad internally). The UI does not mention scratchpad
|
||||||
|
|
||||||
|
If `SWAYSOCK` is unset or IPC fails, the shell keeps running. WindowService logs a warning and stays empty.
|
||||||
|
|
||||||
|
The top-bar context shows the focused application's user-facing name (via `ApplicationService` metadata when possible, otherwise a humanized `app_id` / class). With no normal window focused it shows **Desktop**.
|
||||||
|
|
||||||
|
The launcher has a temporary **Open Windows** list: click to focus, × to close. This is development-quality proof of the service, not Mission Control / Overview.
|
||||||
|
|
||||||
|
## Applications
|
||||||
|
|
||||||
|
`core/applications` discovers freedesktop `.desktop` entries from XDG data directories:
|
||||||
|
|
||||||
|
1. `$XDG_DATA_HOME/applications` (default `~/.local/share/applications`)
|
||||||
|
2. `$XDG_DATA_DIRS/applications` (default `/usr/local/share:/usr/share`)
|
||||||
|
|
||||||
|
Launcher-visible apps respect `Hidden=true`, `NoDisplay=true`, `OnlyShowIn` / `NotShowIn`, and `TryExec` when present. Localized `Name` values are used when available. `StartupWMClass` is stored so `WindowService` can map a compositor `app_id` / class to a friendly name.
|
||||||
|
|
||||||
|
Launching uses `QProcess::startDetached` after `QProcess::splitCommand`. Exec field codes (`%f`, `%F`, `%u`, `%U`, `%i`, `%c`, `%k`, `%%`, …) are handled for launches without file arguments. Desktop-entry strings are never passed to a shell.
|
||||||
|
|
||||||
|
Search matches name, generic name, comment, and categories. Searching `term` should find Foot (`GenericName=Terminal`). Searching `firefox` should find Firefox if it is installed.
|
||||||
|
|
||||||
|
Application icons use the system icon theme through Qt (`QIcon::fromTheme`). Unresolved icons use a Nebula fallback glyph, not a broken-image placeholder.
|
||||||
|
|
||||||
|
Mock launcher data remains only as a fallback when no installed applications are discovered.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
shells/desktop/
|
||||||
|
├── README.md this file
|
||||||
|
├── shell/ production Qt/QML shell
|
||||||
|
│ ├── CMakeLists.txt
|
||||||
|
│ ├── src/
|
||||||
|
│ └── qml/
|
||||||
|
└── prototype-react/ archived design reference only
|
||||||
|
```
|
||||||
|
|
||||||
|
Shared visual language lives in `packages/nebula-ui`. Application discovery lives in `core/applications`. Window management lives in `core/windows`.
|
||||||
|
|
||||||
|
## Linux build
|
||||||
|
|
||||||
|
This project will not compile on Windows. LayerShellQt is a Wayland API and is not stubbed.
|
||||||
|
|
||||||
|
On the Ubuntu NebulaOS VM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install \
|
||||||
|
qt6-base-dev \
|
||||||
|
qt6-declarative-dev \
|
||||||
|
qt6-wayland \
|
||||||
|
liblayershellqtinterface-dev \
|
||||||
|
qml6-module-org-kde-layershell \
|
||||||
|
qml6-module-qtquick \
|
||||||
|
qml6-module-qtquick-window \
|
||||||
|
adwaita-icon-theme \
|
||||||
|
cmake \
|
||||||
|
build-essential
|
||||||
|
```
|
||||||
|
|
||||||
|
Qt 6.4 or newer is required. `loadFromModule` is used on Qt 6.5+.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd shells/desktop/shell
|
||||||
|
|
||||||
|
rm -rf build
|
||||||
|
cmake -S . -B build
|
||||||
|
cmake --build build -j$(nproc)
|
||||||
|
```
|
||||||
|
|
||||||
|
The production binary is `shells/desktop/shell/build/nebula-shell`. It does **not** force `QT_QUICK_BACKEND=software`. GPU-accelerated Qt Quick is the production default.
|
||||||
|
|
||||||
|
`WindowService` and the Sway IPC backend are linked as the `NebulaWindows` target (`core/windows`). Do not compile those `.cpp` files into the shell through relative paths.
|
||||||
|
|
||||||
|
### QML lint
|
||||||
|
|
||||||
|
If the installed Qt provides `qmllint`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qmllint \
|
||||||
|
shells/desktop/shell/qml/*.qml \
|
||||||
|
shells/desktop/shell/qml/surfaces/*.qml \
|
||||||
|
shells/desktop/shell/qml/components/*.qml \
|
||||||
|
shells/desktop/shell/qml/mock/*.qml \
|
||||||
|
packages/nebula-ui/qml/Nebula/UI/*.qml
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not use ESLint or Oxlint on the production Qt shell.
|
||||||
|
|
||||||
|
## Development session (VMware)
|
||||||
|
|
||||||
|
From the repository root, the intended flow is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sway -c compositor/sway/nebula-sway.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
or, recommended on the VM (sets cursor env and starts Sway from the repo root):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sh compositor/sway/dev/run-sway-vm.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
That should:
|
||||||
|
|
||||||
|
1. start Sway without a Sway bar
|
||||||
|
2. autostart `nebula-shell` through `scripts/run-nebula-vm.sh`
|
||||||
|
3. fill the current output
|
||||||
|
4. show a normal Adwaita 24px cursor
|
||||||
|
5. open launched apps as floating windows below the top bar
|
||||||
|
6. let the Nebula launcher start, focus, and close Foot and other installed apps
|
||||||
|
|
||||||
|
Skip autostart while debugging the compositor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEBULA_SHELL_AUTOSTART=0 sway -c compositor/sway/nebula-sway.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
### VMware software rendering
|
||||||
|
|
||||||
|
The VMware virtual GPU currently cannot run the Qt Quick accelerated path correctly. The **development wrapper** therefore sets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QT_QUICK_BACKEND=software
|
||||||
|
```
|
||||||
|
|
||||||
|
That variable is not a production default. `scripts/run-nebula-vm.sh` is the only place that sets it automatically. Production `nebula-session` / `nebula-shell` must run GPU-accelerated.
|
||||||
|
|
||||||
|
Manual equivalent, if you start the shell yourself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QT_QUICK_BACKEND=software ./build/nebula-shell
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output / resolution
|
||||||
|
|
||||||
|
The development Sway config uses scale `1` and does not pin a resolution. Sway uses the output's preferred mode so the session follows the current display size. Window placement uses compositor workspace geometry, not 1920×1080.
|
||||||
|
|
||||||
|
Inspect outputs and modes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swaymsg -t get_outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
Set a mode explicitly if needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swaymsg output <name> mode 1600x900
|
||||||
|
swaymsg output <name> scale 1
|
||||||
|
```
|
||||||
|
|
||||||
|
In VMware, also set the guest display (or Autofit Guest) so the advertised modes match the host window.
|
||||||
|
|
||||||
|
### Cursor
|
||||||
|
|
||||||
|
Sway owns the cursor:
|
||||||
|
|
||||||
|
```text
|
||||||
|
XCURSOR_THEME=Adwaita
|
||||||
|
XCURSOR_SIZE=24
|
||||||
|
seat * xcursor_theme Adwaita 24
|
||||||
|
```
|
||||||
|
|
||||||
|
Install `adwaita-icon-theme` if the theme is missing. A branded Nebula cursor is a later task. `WLR_NO_HARDWARE_CURSORS=1` is set only by the VMware Sway wrapper.
|
||||||
|
|
||||||
|
### Emergency shortcuts
|
||||||
|
|
||||||
|
These remain for development fallback. They are not the normal UX once the GUI launcher and window list work.
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| Super+Enter | open Foot |
|
||||||
|
| Super+Left Mouse | move window |
|
||||||
|
| Super+Right Mouse | resize window |
|
||||||
|
| Super+Left / Right / Up | snap / maximise in the usable area |
|
||||||
|
| Super+Down | restore default floating size |
|
||||||
|
| Super+Shift+Q | close focused window |
|
||||||
|
| Super+Shift+E | exit Sway |
|
||||||
|
| Super+Shift+C | reload Sway config |
|
||||||
|
|
||||||
|
## Design reference
|
||||||
|
|
||||||
|
`prototype-react/` is an archived React/Vite UI. It is **not** the production shell. Keep it until the Qt shell reaches visual and behavioural parity, then remove it in a later cleanup.
|
||||||
|
|
||||||
|
## Current limitations
|
||||||
|
|
||||||
|
- Sway is still the compositor. The future Nebula compositor is not started.
|
||||||
|
- Window decorations are Sway's temporary title bar/border, not Nebula chrome.
|
||||||
|
- The Open Windows section is a temporary launcher list, not Overview / Mission Control.
|
||||||
|
- Clicking the desktop background may not clear application focus; Sway still considers the last client focused. The top bar follows compositor focus.
|
||||||
|
- Minimise is available on `WindowService` but is not a first-class desktop UI yet.
|
||||||
|
- Keyboard snap shortcuts are development conveniences, not a Snap Layouts UI.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Not implemented in this milestone:
|
||||||
|
|
||||||
|
- Mission Control / Overview, dock, live window thumbnails
|
||||||
|
- custom Nebula titlebar renderer / CSD protocol
|
||||||
|
- PipeWire, NetworkManager, BlueZ, UPower
|
||||||
|
- notifications, XDG portals, Polkit
|
||||||
|
- login/lock screen, greetd
|
||||||
|
- a custom Nebula compositor
|
||||||
|
- Nebula Bigscreen
|
||||||
|
- Gamescope
|
||||||
|
|
||||||
|
Those come later. Bigscreen is expected to reuse `Nebula.UI`, `Nebula.Applications`, and `Nebula.Windows`.
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# Nebula Desktop native host
|
|
||||||
|
|
||||||
This directory will contain the Linux native shell host for Nebula Desktop.
|
|
||||||
|
|
||||||
The host is a C++ process that creates **several** GTK4 windows, each backed by WebKitGTK 6.0 and placed with **gtk4-layer-shell**. It does not composite application windows. Those remain ordinary Wayland / XWayland clients of the compositor.
|
|
||||||
|
|
||||||
Planned stack:
|
|
||||||
|
|
||||||
- C++
|
|
||||||
- GTK4
|
|
||||||
- gtk4-layer-shell
|
|
||||||
- WebKitGTK 6.0
|
|
||||||
- Wayland
|
|
||||||
|
|
||||||
## Surfaces
|
|
||||||
|
|
||||||
The React UI in `shells/desktop/ui` can render each shell layer independently via a query parameter. The native host will create one WebKitGTK view per surface and append that parameter to the load URL.
|
|
||||||
|
|
||||||
| Surface | Query | Layer Shell layer | Placement |
|
|
||||||
|-----------|-----------------------|-------------------|-----------|
|
|
||||||
| Desktop | `?surface=desktop` | `BACKGROUND` | Anchored to all monitor edges |
|
|
||||||
| Top Bar | `?surface=topbar` | `TOP` | Anchored top/left/right, fixed shell-defined height, exclusive zone enabled |
|
|
||||||
| Launcher | `?surface=launcher` | `OVERLAY` | Hidden/unmapped when closed |
|
|
||||||
|
|
||||||
The host must not hardcode a Vite port. It loads a base URL and appends the surface parameter.
|
|
||||||
|
|
||||||
## Stacking
|
|
||||||
|
|
||||||
```text
|
|
||||||
OVERLAY
|
|
||||||
Nebula Launcher
|
|
||||||
notifications
|
|
||||||
OSDs
|
|
||||||
|
|
||||||
TOP
|
|
||||||
Nebula Top Bar ← exclusive zone; apps do not go under it
|
|
||||||
|
|
||||||
NORMAL APPLICATION WINDOWS
|
|
||||||
Firefox, Steam, Blender, games, terminals, …
|
|
||||||
|
|
||||||
BACKGROUND
|
|
||||||
Nebula Desktop
|
|
||||||
wallpaper
|
|
||||||
```
|
|
||||||
|
|
||||||
React draws **shell chrome only**. Applications and games render through the compositor, not through WebKit.
|
|
||||||
|
|
||||||
```text
|
|
||||||
Application / game
|
|
||||||
→ Wayland / XWayland
|
|
||||||
→ compositor
|
|
||||||
→ GPU
|
|
||||||
```
|
|
||||||
|
|
||||||
## Loading modes
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
The host will load a configurable Vite origin, conceptually:
|
|
||||||
|
|
||||||
```text
|
|
||||||
NEBULA_SHELL_DEV_URL=http://127.0.0.1:5174
|
|
||||||
```
|
|
||||||
|
|
||||||
and request:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{NEBULA_SHELL_DEV_URL}/?surface=desktop
|
|
||||||
{NEBULA_SHELL_DEV_URL}/?surface=topbar
|
|
||||||
{NEBULA_SHELL_DEV_URL}/?surface=launcher
|
|
||||||
```
|
|
||||||
|
|
||||||
The port is environment configuration, not architecture. A combined browser preview (`/?surface=preview`, also the default) is for Windows UI work only; the native host should load the three surfaces separately.
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
The host will load the compiled assets from `npm run build` in `shells/desktop/ui/dist`, still using the same `?surface=` parameters. It will not depend on a Vite server.
|
|
||||||
|
|
||||||
## Shell bridge
|
|
||||||
|
|
||||||
Each WebKit view talks to the host through a JavaScript bridge (`shellBridge` in the React UI). The development build uses an in-page mock. The native host will inject the real implementation so the top bar can toggle the launcher, and so launching an app can unmap the overlay and update the top-bar context.
|
|
||||||
|
|
||||||
`nebula.*` remains the API boundary for system services (apps, audio, network, power). Those stay mocked until Linux services land.
|
|
||||||
|
|
||||||
## Current development compositor
|
|
||||||
|
|
||||||
Sway is the temporary compositor. See `compositor/sway/nebula-sway.conf`.
|
|
||||||
|
|
||||||
## Build notes
|
|
||||||
|
|
||||||
This host is Linux-only. Do not expect it to compile on Windows. Implementation is the next Linux-side task.
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Nebula Desktop UI — prototype (archived)
|
||||||
|
|
||||||
|
```text
|
||||||
|
DESIGN REFERENCE ONLY
|
||||||
|
NOT THE PRODUCTION NEBULA SHELL
|
||||||
|
```
|
||||||
|
|
||||||
|
This directory is the original React + Vite desktop chrome. It remains only as a visual and behavioural reference while the Qt Quick/QML shell catches up.
|
||||||
|
|
||||||
|
**Do not add production functionality here.**
|
||||||
|
|
||||||
|
The production Nebula Desktop shell is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
shells/desktop/shell
|
||||||
|
C++20 + Qt 6 + Qt Quick/QML + LayerShellQt
|
||||||
|
```
|
||||||
|
|
||||||
|
This prototype must not be launched as the OS shell. It does not talk to Wayland layer-shell, and it is not wired to Nebula Core.
|
||||||
|
|
||||||
|
## What this is for
|
||||||
|
|
||||||
|
- comparing layout, colour, spacing, and motion against `shells/desktop/shell`
|
||||||
|
- checking launcher search, mock launch, and top-bar treatment
|
||||||
|
- discarding this tree later, once Qt parity is accepted
|
||||||
|
|
||||||
|
## Surfaces (browser preview only)
|
||||||
|
|
||||||
|
| URL | Renders |
|
||||||
|
|-----|---------|
|
||||||
|
| `/?surface=preview` (default) | Combined mockup |
|
||||||
|
| `/?surface=desktop` | Background only |
|
||||||
|
| `/?surface=topbar` | Top bar only |
|
||||||
|
| `/?surface=launcher` | Launcher only |
|
||||||
|
|
||||||
|
## Preview on a development machine
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Oxlint applies to this JavaScript prototype only. It is not used for the Qt shell.
|
||||||
|
Before Width: | Height: | Size: 325 B After Width: | Height: | Size: 325 B |
Vendored
+5
-5
@@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Nebula JavaScript API
|
* Nebula JavaScript API
|
||||||
*
|
*
|
||||||
* Boundary between the React shell UI and the future native Nebula Core:
|
* Placeholder APIs for the archived React prototype.
|
||||||
*
|
*
|
||||||
* React UI → nebula.* → native host / Nebula Core
|
* Archived React prototype → nebula.* placeholders
|
||||||
*
|
*
|
||||||
* Every method here is a placeholder. Implementations must not call Node
|
* Production system APIs will be C++ services exposed to QML, not this
|
||||||
* APIs, spawn processes, or talk to D-Bus, PipeWire, NetworkManager, or
|
* JavaScript module. Methods here must not call Node APIs, spawn processes,
|
||||||
* Wayland. The WebKitGTK host will later inject a real bridge.
|
* or talk to D-Bus, PipeWire, NetworkManager, or Wayland.
|
||||||
*
|
*
|
||||||
* The shell is chrome only. Applications and games render as compositor
|
* The shell is chrome only. Applications and games render as compositor
|
||||||
* surfaces, not inside this React tree.
|
* surfaces, not inside this React tree.
|
||||||
Vendored
+2
-3
@@ -1,9 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Shell coordination bridge.
|
* Shell coordination bridge.
|
||||||
*
|
*
|
||||||
* Top bar, launcher, and desktop will later live in separate WebKitGTK views.
|
* In-page mock for coordinating the archived browser preview.
|
||||||
* They must not share React component state. The native host will replace this
|
* Production shell state is the QML ShellState singleton, not this bridge.
|
||||||
* module with an injected C++ bridge.
|
|
||||||
*
|
*
|
||||||
* This development mock keeps in-page state and broadcasts it with window
|
* This development mock keeps in-page state and broadcasts it with window
|
||||||
* CustomEvents. No networking, WebSockets, or Node APIs.
|
* CustomEvents. No networking, WebSockets, or Node APIs.
|
||||||
+2
-3
@@ -1,9 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Shell surface routing.
|
* Shell surface routing.
|
||||||
*
|
*
|
||||||
* The native host will load this UI in separate WebKitGTK views, each with a
|
* Browser preview routing for the archived React prototype.
|
||||||
* surface query parameter. Never hardcode a host or port — the loader decides
|
* Production surfaces are separate Qt Quick windows, not WebKit views.
|
||||||
* the URL; this module only reads the current location.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const SURFACES = {
|
export const SURFACES = {
|
||||||
+2
-1
@@ -9,7 +9,8 @@ import './surfaces.css';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Browser-only composition of the three shell surfaces.
|
* Browser-only composition of the three shell surfaces.
|
||||||
* Production loads each surface in its own WebKitGTK view.
|
* Browser-only composition of the three shell surfaces.
|
||||||
|
* Archived design reference — production chrome is Qt Quick/QML.
|
||||||
*/
|
*/
|
||||||
export function PreviewSurface() {
|
export function PreviewSurface() {
|
||||||
const { launcherOpen } = useShellState();
|
const { launcherOpen } = useShellState();
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
project(nebula-shell LANGUAGES CXX)
|
||||||
|
|
||||||
|
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||||
|
message(FATAL_ERROR
|
||||||
|
"nebula-shell is a Linux Wayland shell using LayerShellQt.\n"
|
||||||
|
"Build and run it on the Ubuntu NebulaOS VM. Windows is not a supported target, "
|
||||||
|
"and LayerShellQt is not stubbed with other platform APIs.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
|
find_package(Qt6 6.4 REQUIRED COMPONENTS Core Gui Qml Quick Network)
|
||||||
|
find_package(LayerShellQt REQUIRED)
|
||||||
|
|
||||||
|
qt_standard_project_setup(REQUIRES 6.4)
|
||||||
|
|
||||||
|
set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
|
||||||
|
|
||||||
|
add_subdirectory(
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/../../../packages/nebula-ui"
|
||||||
|
"${CMAKE_CURRENT_BINARY_DIR}/nebula-ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
add_subdirectory(
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/../../../core/applications"
|
||||||
|
"${CMAKE_CURRENT_BINARY_DIR}/nebula-applications"
|
||||||
|
)
|
||||||
|
|
||||||
|
add_subdirectory(
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/../../../core/windows"
|
||||||
|
"${CMAKE_CURRENT_BINARY_DIR}/nebula-windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
qt_add_executable(nebula-shell
|
||||||
|
src/main.cpp
|
||||||
|
src/DesktopIconProvider.cpp
|
||||||
|
src/DesktopIconProvider.hpp
|
||||||
|
)
|
||||||
|
|
||||||
|
set(NEBULA_SHELL_QML_FILES
|
||||||
|
qml/Main.qml
|
||||||
|
qml/ShellState.qml
|
||||||
|
qml/surfaces/DesktopSurface.qml
|
||||||
|
qml/surfaces/TopBarSurface.qml
|
||||||
|
qml/surfaces/WindowChromeSurface.qml
|
||||||
|
qml/surfaces/LauncherSurface.qml
|
||||||
|
qml/components/TopBar.qml
|
||||||
|
qml/components/WindowControls.qml
|
||||||
|
qml/components/TrafficLightButton.qml
|
||||||
|
qml/components/NebulaButton.qml
|
||||||
|
qml/components/Clock.qml
|
||||||
|
qml/components/SystemArea.qml
|
||||||
|
qml/components/Launcher.qml
|
||||||
|
qml/components/AppItem.qml
|
||||||
|
qml/components/OpenWindowItem.qml
|
||||||
|
qml/mock/MockApps.qml
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach(qml_file IN LISTS NEBULA_SHELL_QML_FILES)
|
||||||
|
get_filename_component(qml_name "${qml_file}" NAME)
|
||||||
|
set_source_files_properties("${qml_file}" PROPERTIES
|
||||||
|
QT_RESOURCE_ALIAS "${qml_name}"
|
||||||
|
)
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
set_source_files_properties(qml/ShellState.qml PROPERTIES
|
||||||
|
QT_QML_SINGLETON_TYPE TRUE
|
||||||
|
QT_RESOURCE_ALIAS ShellState.qml
|
||||||
|
)
|
||||||
|
set_source_files_properties(qml/mock/MockApps.qml PROPERTIES
|
||||||
|
QT_QML_SINGLETON_TYPE TRUE
|
||||||
|
QT_RESOURCE_ALIAS MockApps.qml
|
||||||
|
)
|
||||||
|
|
||||||
|
qt_add_qml_module(nebula-shell
|
||||||
|
URI Nebula.Shell
|
||||||
|
VERSION 1.0
|
||||||
|
RESOURCE_PREFIX /qt/qml
|
||||||
|
QML_FILES ${NEBULA_SHELL_QML_FILES}
|
||||||
|
SOURCES
|
||||||
|
src/OutputTracker.hpp
|
||||||
|
src/OutputTracker.cpp
|
||||||
|
DEPENDENCIES
|
||||||
|
QtQuick
|
||||||
|
Nebula.UI
|
||||||
|
Nebula.Applications
|
||||||
|
Nebula.Windows
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(nebula-shell
|
||||||
|
PRIVATE
|
||||||
|
Qt6::Core
|
||||||
|
Qt6::Gui
|
||||||
|
Qt6::Qml
|
||||||
|
Qt6::Quick
|
||||||
|
LayerShellQt::Interface
|
||||||
|
NebulaUI
|
||||||
|
NebulaApplications
|
||||||
|
NebulaWindows
|
||||||
|
)
|
||||||
|
|
||||||
|
if(TARGET NebulaUIplugin)
|
||||||
|
target_link_libraries(nebula-shell PRIVATE NebulaUIplugin)
|
||||||
|
endif()
|
||||||
|
if(TARGET NebulaApplicationsplugin)
|
||||||
|
target_link_libraries(nebula-shell PRIVATE NebulaApplicationsplugin)
|
||||||
|
endif()
|
||||||
|
if(TARGET NebulaWindowsplugin)
|
||||||
|
target_link_libraries(nebula-shell PRIVATE NebulaWindowsplugin)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Generated nebula-shell_qmltyperegistrations.cpp includes "OutputTracker.hpp"
|
||||||
|
# via __has_include. Keep src/ on the include path so that succeeds.
|
||||||
|
target_include_directories(nebula-shell PRIVATE
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}"
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
||||||
|
)
|
||||||
|
|
||||||
|
set_source_files_properties(
|
||||||
|
"${CMAKE_CURRENT_BINARY_DIR}/nebula-shell_qmltyperegistrations.cpp"
|
||||||
|
PROPERTIES
|
||||||
|
COMPILE_OPTIONS "-include;${CMAKE_CURRENT_SOURCE_DIR}/src/OutputTracker.hpp"
|
||||||
|
)
|
||||||
|
|
||||||
|
set_target_properties(nebula-shell PROPERTIES
|
||||||
|
OUTPUT_NAME nebula-shell
|
||||||
|
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Shared Qt discovers QML plugins at runtime. Skip qmlimportscanner / static
|
||||||
|
# plugin importing so Qt 6.8+ does not warn about missing Qt6::*plugin targets.
|
||||||
|
if(QT6_IS_SHARED_LIBS_BUILD)
|
||||||
|
set_target_properties(nebula-shell PROPERTIES
|
||||||
|
QT_QML_MODULE_NO_IMPORT_SCAN TRUE
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||||
|
target_compile_options(nebula-shell PRIVATE -Wall -Wextra)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
id: shell
|
||||||
|
|
||||||
|
readonly property DesktopSurface desktop: DesktopSurface {}
|
||||||
|
readonly property TopBarSurface topBar: TopBarSurface {}
|
||||||
|
readonly property WindowChromeSurface windowChrome: WindowChromeSurface {}
|
||||||
|
readonly property LauncherSurface launcher: LauncherSurface {}
|
||||||
|
|
||||||
|
readonly property Shortcut closeLauncherShortcut: Shortcut {
|
||||||
|
sequence: "Escape"
|
||||||
|
enabled: ShellState.launcherOpen
|
||||||
|
context: Qt.ApplicationShortcut
|
||||||
|
onActivated: ShellState.closeLauncher()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Nebula.Windows
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
property bool launcherOpen: false
|
||||||
|
|
||||||
|
readonly property string activeApplication: WindowService.focusedApplicationName.length > 0
|
||||||
|
? WindowService.focusedApplicationName
|
||||||
|
: qsTr("Desktop")
|
||||||
|
|
||||||
|
function openLauncher() {
|
||||||
|
launcherOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLauncher() {
|
||||||
|
launcherOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLauncher() {
|
||||||
|
launcherOpen = !launcherOpen
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Nebula.UI
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string variant: "tile"
|
||||||
|
property var item
|
||||||
|
signal selected(var item)
|
||||||
|
|
||||||
|
readonly property bool rowVariant: variant === "row"
|
||||||
|
readonly property int iconSize: rowVariant ? 32 : (variant === "recent" ? 32 : 40)
|
||||||
|
readonly property int glyphSize: rowVariant ? 16 : 18
|
||||||
|
readonly property string iconName: item && item.iconName ? item.iconName : ""
|
||||||
|
readonly property string fallbackGlyph: {
|
||||||
|
if (item && item.glyph)
|
||||||
|
return item.glyph
|
||||||
|
if (item && item.kind)
|
||||||
|
return item.kind
|
||||||
|
return "application"
|
||||||
|
}
|
||||||
|
readonly property url iconSource: iconName.length > 0
|
||||||
|
? ("image://desktopicon/" + encodeURIComponent(iconName))
|
||||||
|
: ""
|
||||||
|
|
||||||
|
implicitWidth: rowVariant ? parent ? parent.width : 200 : 90
|
||||||
|
implicitHeight: rowVariant ? 48 : 78
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: item ? item.name : ""
|
||||||
|
Accessible.onPressAction: root.selected(item)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.radiusMedium
|
||||||
|
color: hover.containsMouse ? Theme.tileHover : "transparent"
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation {
|
||||||
|
duration: Theme.animationFast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
visible: !root.rowVariant
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.topMargin: 10
|
||||||
|
anchors.bottomMargin: 8
|
||||||
|
anchors.leftMargin: 6
|
||||||
|
anchors.rightMargin: 6
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: root.iconSize
|
||||||
|
height: root.iconSize
|
||||||
|
radius: root.variant === "recent" ? 9 : 11
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
color: Theme.iconWell
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Theme.iconWellHighlight
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: tileIcon
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: root.iconSize - 8
|
||||||
|
height: root.iconSize - 8
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
asynchronous: true
|
||||||
|
smooth: true
|
||||||
|
cache: true
|
||||||
|
visible: status === Image.Ready
|
||||||
|
source: root.iconSource
|
||||||
|
}
|
||||||
|
|
||||||
|
NebulaIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: tileIcon.status !== Image.Ready
|
||||||
|
name: root.fallbackGlyph
|
||||||
|
size: root.glyphSize
|
||||||
|
color: item && item.tint ? item.tint : Theme.accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: item ? item.name : ""
|
||||||
|
color: Theme.textPrimary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeApp
|
||||||
|
elide: Text.ElideRight
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
visible: root.rowVariant
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: root.iconSize
|
||||||
|
height: root.iconSize
|
||||||
|
radius: 9
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: Theme.iconWell
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: rowIcon
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: root.iconSize - 8
|
||||||
|
height: root.iconSize - 8
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
asynchronous: true
|
||||||
|
smooth: true
|
||||||
|
cache: true
|
||||||
|
visible: status === Image.Ready
|
||||||
|
source: root.iconSource
|
||||||
|
}
|
||||||
|
|
||||||
|
NebulaIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: rowIcon.status !== Image.Ready
|
||||||
|
name: root.fallbackGlyph
|
||||||
|
size: root.glyphSize
|
||||||
|
color: item && item.tint ? item.tint : Theme.accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width - root.iconSize - 10
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: item ? item.name : ""
|
||||||
|
color: Theme.textPrimary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeApp
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: item && item.subtitle ? true : false
|
||||||
|
text: item && item.subtitle ? item.subtitle : ""
|
||||||
|
color: Theme.textTertiary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: hover
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.selected(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
FocusFrame {
|
||||||
|
radius: Theme.radiusMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQml
|
||||||
|
import Nebula.UI
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property date now: new Date()
|
||||||
|
|
||||||
|
text: formatClock(now)
|
||||||
|
color: Theme.textPrimary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
leftPadding: 8
|
||||||
|
rightPadding: 8
|
||||||
|
height: Theme.controlHeight
|
||||||
|
Accessible.role: Accessible.StaticText
|
||||||
|
Accessible.name: formatFull(now)
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 1000
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
onTriggered: root.now = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClock(date) {
|
||||||
|
const locale = Qt.locale()
|
||||||
|
const weekday = locale.toString(date, "ddd")
|
||||||
|
const time = locale.toString(date, locale.timeFormat(Locale.ShortFormat))
|
||||||
|
return weekday + " " + time
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFull(date) {
|
||||||
|
return Qt.locale().toString(date, Locale.LongFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Nebula.UI
|
||||||
|
import Nebula.Applications
|
||||||
|
import Nebula.Windows
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property real yOffset: 0
|
||||||
|
|
||||||
|
implicitWidth: 420
|
||||||
|
implicitHeight: 14 + searchField.implicitHeight + 14 + bodyColumn.implicitHeight + 14 + footer.height + 14
|
||||||
|
transform: Translate { y: root.yOffset }
|
||||||
|
transformOrigin: Item.TopLeft
|
||||||
|
Accessible.role: Accessible.Dialog
|
||||||
|
Accessible.name: qsTr("Launcher")
|
||||||
|
|
||||||
|
readonly property bool usingInstalledApps: ApplicationService.count > 0
|
||||||
|
readonly property bool searching: searchField.text.trim().length > 0
|
||||||
|
readonly property var mockMatches: {
|
||||||
|
const query = searchField.text.trim().toLowerCase()
|
||||||
|
const apps = MockApps.apps
|
||||||
|
if (!query)
|
||||||
|
return apps
|
||||||
|
return apps.filter((app) => {
|
||||||
|
const haystack = (app.name + " " + (app.comment || "")).toLowerCase()
|
||||||
|
return haystack.indexOf(query) !== -1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
readonly property int resultCount: usingInstalledApps
|
||||||
|
? ApplicationService.filteredModel.count
|
||||||
|
: mockMatches.length
|
||||||
|
|
||||||
|
Keys.onEscapePressed: {
|
||||||
|
event.accepted = true
|
||||||
|
ShellState.closeLauncher()
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ShellState
|
||||||
|
function onLauncherOpenChanged() {
|
||||||
|
if (ShellState.launcherOpen) {
|
||||||
|
searchField.text = ""
|
||||||
|
searchField.input.forceActiveFocus()
|
||||||
|
} else {
|
||||||
|
searchField.text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NebulaCard {
|
||||||
|
id: card
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: column
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
NebulaTextField {
|
||||||
|
id: searchField
|
||||||
|
width: parent.width
|
||||||
|
placeholderText: qsTr("Search applications")
|
||||||
|
onTextChanged: ApplicationService.filter = text
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: 1
|
||||||
|
height: 14
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
id: body
|
||||||
|
width: parent.width
|
||||||
|
height: Math.max(0, column.height - searchField.height - footer.height - 29)
|
||||||
|
clip: true
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: bodyColumn.implicitHeight
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
flickableDirection: Flickable.VerticalFlick
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: bodyColumn
|
||||||
|
width: body.width
|
||||||
|
spacing: 18
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
visible: !root.searching && !root.usingInstalledApps && MockApps.recents.length > 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: qsTr("Recent")
|
||||||
|
color: Theme.textSecondary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
font.letterSpacing: 0.4
|
||||||
|
}
|
||||||
|
|
||||||
|
Grid {
|
||||||
|
id: recentGrid
|
||||||
|
width: parent.width
|
||||||
|
columns: 4
|
||||||
|
columnSpacing: 6
|
||||||
|
rowSpacing: 6
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: MockApps.recents
|
||||||
|
delegate: AppItem {
|
||||||
|
required property var modelData
|
||||||
|
width: (recentGrid.width - 18) / 4
|
||||||
|
variant: "recent"
|
||||||
|
item: modelData
|
||||||
|
onSelected: root.launchApp(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.searching ? qsTr("Results") : qsTr("Applications")
|
||||||
|
color: Theme.textSecondary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
font.letterSpacing: 0.4
|
||||||
|
}
|
||||||
|
|
||||||
|
Grid {
|
||||||
|
id: appGrid
|
||||||
|
visible: root.resultCount > 0
|
||||||
|
width: parent.width
|
||||||
|
columns: 4
|
||||||
|
columnSpacing: 6
|
||||||
|
rowSpacing: 6
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.usingInstalledApps ? ApplicationService.filteredModel : []
|
||||||
|
delegate: AppItem {
|
||||||
|
required property string desktopId
|
||||||
|
required property string name
|
||||||
|
required property string comment
|
||||||
|
required property string iconName
|
||||||
|
width: (appGrid.width - 18) / 4
|
||||||
|
variant: "tile"
|
||||||
|
item: ({
|
||||||
|
desktopId: desktopId,
|
||||||
|
name: name,
|
||||||
|
comment: comment,
|
||||||
|
iconName: iconName
|
||||||
|
})
|
||||||
|
onSelected: root.launchApp(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.usingInstalledApps ? [] : root.mockMatches
|
||||||
|
delegate: AppItem {
|
||||||
|
required property var modelData
|
||||||
|
width: (appGrid.width - 18) / 4
|
||||||
|
variant: "tile"
|
||||||
|
item: modelData
|
||||||
|
onSelected: root.launchApp(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: root.resultCount === 0
|
||||||
|
width: parent.width
|
||||||
|
topPadding: 8
|
||||||
|
text: qsTr("No matching applications")
|
||||||
|
color: Theme.textTertiary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
visible: !root.searching && WindowService.windowCount > 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: qsTr("Open Windows")
|
||||||
|
color: Theme.textSecondary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
font.letterSpacing: 0.4
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: WindowService.model
|
||||||
|
delegate: OpenWindowItem {
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
visible: !root.searching && !root.usingInstalledApps && MockApps.projects.length > 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: qsTr("Recent projects")
|
||||||
|
color: Theme.textSecondary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
font.letterSpacing: 0.4
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: MockApps.projects
|
||||||
|
delegate: AppItem {
|
||||||
|
required property var modelData
|
||||||
|
width: parent.width
|
||||||
|
variant: "row"
|
||||||
|
item: modelData
|
||||||
|
onSelected: root.openProject(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: 1
|
||||||
|
height: 14
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: Theme.border
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: footer
|
||||||
|
width: parent.width
|
||||||
|
height: 38
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 28
|
||||||
|
height: 28
|
||||||
|
radius: 14
|
||||||
|
color: Theme.accentSoft
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: MockApps.user.initials
|
||||||
|
color: Theme.accentStrong
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeApp
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: MockApps.user.name
|
||||||
|
color: Theme.textPrimary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: qsTr("Local session")
|
||||||
|
color: Theme.textTertiary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
NebulaIconButton {
|
||||||
|
id: restartButton
|
||||||
|
label: qsTr("Restart")
|
||||||
|
onClicked: console.info("nebula: restart requested")
|
||||||
|
NebulaIcon {
|
||||||
|
name: "restart"
|
||||||
|
size: 16
|
||||||
|
color: restartButton.iconColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NebulaIconButton {
|
||||||
|
id: shutdownButton
|
||||||
|
label: qsTr("Shut down")
|
||||||
|
danger: true
|
||||||
|
onClicked: console.info("nebula: shutdown requested")
|
||||||
|
NebulaIcon {
|
||||||
|
name: "power"
|
||||||
|
size: 16
|
||||||
|
color: shutdownButton.iconColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchApp(app) {
|
||||||
|
if (app && app.desktopId)
|
||||||
|
ApplicationService.launch(app.desktopId)
|
||||||
|
ShellState.closeLauncher()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProject(project) {
|
||||||
|
ShellState.closeLauncher()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Nebula.UI
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
implicitWidth: row.implicitWidth + 18
|
||||||
|
implicitHeight: Theme.controlHeight
|
||||||
|
property bool checked: false
|
||||||
|
|
||||||
|
signal clicked()
|
||||||
|
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: qsTr("Nebula")
|
||||||
|
Accessible.checkable: true
|
||||||
|
Accessible.checked: root.checked
|
||||||
|
Accessible.onPressAction: root.clicked()
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.radiusSmall
|
||||||
|
color: (hover.containsMouse || root.checked) ? Theme.accentSoft : "transparent"
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation {
|
||||||
|
duration: Theme.animationFast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: row
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
NebulaIcon {
|
||||||
|
name: "nebula"
|
||||||
|
size: 18
|
||||||
|
color: root.checked ? Theme.accentStrong : Theme.textPrimary
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: qsTr("Nebula")
|
||||||
|
color: root.checked ? Theme.accentStrong : Theme.textPrimary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: hover
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.clicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
FocusFrame {
|
||||||
|
radius: Theme.radiusSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Nebula.UI
|
||||||
|
import Nebula.Windows
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property string windowId
|
||||||
|
required property string displayName
|
||||||
|
required property string iconName
|
||||||
|
required property bool focused
|
||||||
|
required property bool minimized
|
||||||
|
|
||||||
|
implicitWidth: parent ? parent.width : 200
|
||||||
|
implicitHeight: 36
|
||||||
|
Accessible.role: Accessible.ListItem
|
||||||
|
Accessible.name: displayName
|
||||||
|
Accessible.onPressAction: root.activate()
|
||||||
|
|
||||||
|
readonly property url iconSource: iconName.length > 0
|
||||||
|
? ("image://desktopicon/" + encodeURIComponent(iconName))
|
||||||
|
: ""
|
||||||
|
|
||||||
|
function activate() {
|
||||||
|
WindowService.focusWindow(windowId)
|
||||||
|
ShellState.closeLauncher()
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.radiusSmall
|
||||||
|
color: {
|
||||||
|
if (hover.containsMouse)
|
||||||
|
return Theme.tileHover
|
||||||
|
if (root.focused)
|
||||||
|
return Theme.accentSoft
|
||||||
|
return "transparent"
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation {
|
||||||
|
duration: Theme.animationFast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.rightMargin: 4
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 22
|
||||||
|
height: 22
|
||||||
|
radius: 6
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: Theme.iconWell
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: icon
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: 14
|
||||||
|
height: 14
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
asynchronous: true
|
||||||
|
smooth: true
|
||||||
|
cache: true
|
||||||
|
visible: status === Image.Ready
|
||||||
|
source: root.iconSource
|
||||||
|
}
|
||||||
|
|
||||||
|
NebulaIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: icon.status !== Image.Ready
|
||||||
|
name: "application"
|
||||||
|
size: 12
|
||||||
|
color: Theme.accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width - 22 - 28 - 16
|
||||||
|
text: root.displayName
|
||||||
|
color: {
|
||||||
|
if (root.minimized)
|
||||||
|
return Theme.textTertiary
|
||||||
|
if (root.focused)
|
||||||
|
return Theme.textPrimary
|
||||||
|
return Theme.textSecondary
|
||||||
|
}
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeApp
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: 28
|
||||||
|
height: parent.height
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "×"
|
||||||
|
color: closeHover.containsMouse ? Theme.danger : Theme.textTertiary
|
||||||
|
font.pixelSize: 14
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: closeHover
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: WindowService.closeWindow(root.windowId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: qsTr("Close %1").arg(root.displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: hover
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.rightMargin: 28
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
FocusFrame {
|
||||||
|
radius: Theme.radiusSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Nebula.UI
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
// Placeholder system status until native services exist.
|
||||||
|
readonly property int wifiStrength: 3
|
||||||
|
readonly property int volumePercent: 72
|
||||||
|
readonly property int batteryPercent: 84
|
||||||
|
|
||||||
|
NebulaIconButton {
|
||||||
|
id: networkButton
|
||||||
|
label: qsTr("Network connected, Nebula-Net")
|
||||||
|
NebulaIcon {
|
||||||
|
name: "wifi"
|
||||||
|
size: 16
|
||||||
|
color: networkButton.iconColor
|
||||||
|
value: root.wifiStrength
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NebulaIconButton {
|
||||||
|
id: volumeButton
|
||||||
|
label: qsTr("Volume %1%").arg(root.volumePercent)
|
||||||
|
NebulaIcon {
|
||||||
|
name: "speaker"
|
||||||
|
size: 16
|
||||||
|
color: volumeButton.iconColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NebulaIconButton {
|
||||||
|
id: powerButton
|
||||||
|
label: qsTr("Battery %1%").arg(root.batteryPercent)
|
||||||
|
NebulaIcon {
|
||||||
|
name: "battery"
|
||||||
|
size: 16
|
||||||
|
color: powerButton.iconColor
|
||||||
|
value: root.batteryPercent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Clock {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Nebula.UI
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
implicitHeight: Theme.topBarHeight
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
color: Theme.topBarBackground
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
height: 1
|
||||||
|
color: Theme.border
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: left
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 6
|
||||||
|
anchors.right: systemArea.left
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
NebulaButton {
|
||||||
|
id: nebulaButton
|
||||||
|
checked: ShellState.launcherOpen
|
||||||
|
onClicked: ShellState.toggleLauncher()
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: leftDivider
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 1
|
||||||
|
height: 14
|
||||||
|
color: Theme.borderStrong
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: Math.min(implicitWidth, Math.max(0, left.width - nebulaButton.width - leftDivider.width - left.spacing * 2))
|
||||||
|
text: ShellState.activeApplication
|
||||||
|
color: Theme.textSecondary
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
elide: Text.ElideRight
|
||||||
|
maximumLineCount: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemArea {
|
||||||
|
id: systemArea
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Nebula.UI
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string label: ""
|
||||||
|
property color fill: Theme.trafficClose
|
||||||
|
property string glyph: "close"
|
||||||
|
property bool showGlyph: false
|
||||||
|
readonly property bool hovered: hover.containsMouse
|
||||||
|
readonly property bool pressed: hover.pressed
|
||||||
|
|
||||||
|
signal clicked()
|
||||||
|
|
||||||
|
implicitWidth: Theme.trafficLightSize + 6
|
||||||
|
implicitHeight: Theme.trafficLightSize + 6
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: root.label
|
||||||
|
Accessible.onPressAction: root.clicked()
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: disc
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: Theme.trafficLightSize
|
||||||
|
height: Theme.trafficLightSize
|
||||||
|
radius: width / 2
|
||||||
|
color: hover.pressed ? Qt.darker(root.fill, 1.18) : root.fill
|
||||||
|
border.width: 1
|
||||||
|
border.color: Qt.rgba(0, 0, 0, 0.22)
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation {
|
||||||
|
duration: Theme.animationFast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 1
|
||||||
|
width: parent.width - 4
|
||||||
|
height: Math.max(2, parent.height * 0.38)
|
||||||
|
radius: height / 2
|
||||||
|
color: Qt.rgba(1, 1, 1, 0.28)
|
||||||
|
Accessible.ignored: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Canvas {
|
||||||
|
id: glyph
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: Math.round(Theme.trafficLightSize * 0.58)
|
||||||
|
height: width
|
||||||
|
antialiasing: true
|
||||||
|
opacity: root.showGlyph ? 1 : 0
|
||||||
|
visible: opacity > 0.01
|
||||||
|
onWidthChanged: requestPaint()
|
||||||
|
onHeightChanged: requestPaint()
|
||||||
|
onPaint: {
|
||||||
|
const ctx = getContext("2d")
|
||||||
|
ctx.reset()
|
||||||
|
ctx.scale(width / 8, height / 8)
|
||||||
|
ctx.strokeStyle = Qt.rgba(0.18, 0.12, 0.1, 0.78)
|
||||||
|
ctx.fillStyle = ctx.strokeStyle
|
||||||
|
ctx.lineWidth = 1.35
|
||||||
|
ctx.lineCap = "round"
|
||||||
|
ctx.lineJoin = "round"
|
||||||
|
|
||||||
|
switch (root.glyph) {
|
||||||
|
case "minimize":
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(1.2, 4)
|
||||||
|
ctx.lineTo(6.8, 4)
|
||||||
|
ctx.stroke()
|
||||||
|
break
|
||||||
|
case "zoom":
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(1.4, 4)
|
||||||
|
ctx.lineTo(6.6, 4)
|
||||||
|
ctx.moveTo(4, 1.4)
|
||||||
|
ctx.lineTo(4, 6.6)
|
||||||
|
ctx.stroke()
|
||||||
|
break
|
||||||
|
case "restore":
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.rect(2.3, 0.7, 4.3, 4.3)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.rect(0.7, 2.3, 4.3, 4.3)
|
||||||
|
ctx.stroke()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(1.6, 1.6)
|
||||||
|
ctx.lineTo(6.4, 6.4)
|
||||||
|
ctx.moveTo(6.4, 1.6)
|
||||||
|
ctx.lineTo(1.6, 6.4)
|
||||||
|
ctx.stroke()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: Theme.animationFast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onGlyphChanged: glyph.requestPaint()
|
||||||
|
onShowGlyphChanged: if (showGlyph) glyph.requestPaint()
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: hover
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.clicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
NebulaToolTip {
|
||||||
|
text: root.label
|
||||||
|
active: hover.containsMouse && !hover.pressed && root.label.length > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Nebula.UI
|
||||||
|
import Nebula.Windows
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property bool maximized: WindowService.focusedWindowMaximized
|
||||||
|
readonly property bool hovered: closeButton.hovered || minimizeButton.hovered || zoomButton.hovered
|
||||||
|
|
||||||
|
implicitWidth: row.implicitWidth
|
||||||
|
implicitHeight: row.implicitHeight
|
||||||
|
Accessible.role: Accessible.Grouping
|
||||||
|
Accessible.name: qsTr("Window controls")
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: row
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.trafficLightGap
|
||||||
|
|
||||||
|
TrafficLightButton {
|
||||||
|
id: closeButton
|
||||||
|
fill: Theme.trafficClose
|
||||||
|
glyph: "close"
|
||||||
|
label: qsTr("Close")
|
||||||
|
showGlyph: root.hovered
|
||||||
|
onClicked: WindowService.closeFocusedWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
TrafficLightButton {
|
||||||
|
id: minimizeButton
|
||||||
|
fill: Theme.trafficMinimise
|
||||||
|
glyph: "minimize"
|
||||||
|
label: qsTr("Minimise")
|
||||||
|
showGlyph: root.hovered
|
||||||
|
onClicked: WindowService.minimizeFocusedWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
TrafficLightButton {
|
||||||
|
id: zoomButton
|
||||||
|
fill: Theme.trafficZoom
|
||||||
|
glyph: root.maximized ? "restore" : "zoom"
|
||||||
|
label: root.maximized ? qsTr("Restore") : qsTr("Maximise")
|
||||||
|
showGlyph: root.hovered
|
||||||
|
onClicked: WindowService.toggleMaximizeFocusedWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
readonly property var apps: [
|
||||||
|
{
|
||||||
|
id: "files",
|
||||||
|
name: "Files",
|
||||||
|
comment: "Browse files and folders",
|
||||||
|
recent: true,
|
||||||
|
glyph: "folder",
|
||||||
|
tint: "#c4a574"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "firefox",
|
||||||
|
name: "Firefox",
|
||||||
|
comment: "Web browser",
|
||||||
|
recent: true,
|
||||||
|
glyph: "globe",
|
||||||
|
tint: "#d08a5a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "terminal",
|
||||||
|
name: "Terminal",
|
||||||
|
comment: "Command line",
|
||||||
|
recent: true,
|
||||||
|
glyph: "terminal",
|
||||||
|
tint: "#7eaa8e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings",
|
||||||
|
name: "Settings",
|
||||||
|
comment: "System preferences",
|
||||||
|
recent: false,
|
||||||
|
glyph: "settings",
|
||||||
|
tint: "#8e99ab"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "steam",
|
||||||
|
name: "Steam",
|
||||||
|
comment: "Games",
|
||||||
|
recent: true,
|
||||||
|
glyph: "game",
|
||||||
|
tint: "#6f8fb8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "photos",
|
||||||
|
name: "Photos",
|
||||||
|
comment: "Pictures and albums",
|
||||||
|
recent: false,
|
||||||
|
glyph: "photo",
|
||||||
|
tint: "#8aa8c4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "music",
|
||||||
|
name: "Music",
|
||||||
|
comment: "Listen and organise",
|
||||||
|
recent: false,
|
||||||
|
glyph: "music",
|
||||||
|
tint: "#b48a9a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "text-editor",
|
||||||
|
name: "Text Editor",
|
||||||
|
comment: "Edit documents",
|
||||||
|
recent: false,
|
||||||
|
glyph: "document",
|
||||||
|
tint: "#9aa7bc"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
readonly property var recents: apps.filter((app) => app.recent)
|
||||||
|
|
||||||
|
readonly property var projects: [
|
||||||
|
{
|
||||||
|
id: "nebulaos",
|
||||||
|
name: "NebulaOS",
|
||||||
|
subtitle: "Repository",
|
||||||
|
kind: "folder"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "website",
|
||||||
|
name: "Website",
|
||||||
|
subtitle: "Project",
|
||||||
|
kind: "folder"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session-notes",
|
||||||
|
name: "Session notes",
|
||||||
|
subtitle: "Document",
|
||||||
|
kind: "document"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
readonly property var user: ({
|
||||||
|
name: "User",
|
||||||
|
initials: "N"
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Window
|
||||||
|
import org.kde.layershell 1.0 as LayerShell
|
||||||
|
import Nebula.UI
|
||||||
|
|
||||||
|
Window {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property int outputWidth: OutputTracker.width > 0
|
||||||
|
? OutputTracker.width
|
||||||
|
: (screen && screen.width > 0 ? screen.width : Screen.width)
|
||||||
|
readonly property int outputHeight: OutputTracker.height > 0
|
||||||
|
? OutputTracker.height
|
||||||
|
: (screen && screen.height > 0 ? screen.height : Screen.height)
|
||||||
|
|
||||||
|
title: qsTr("Nebula Desktop")
|
||||||
|
color: "transparent"
|
||||||
|
flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
|
||||||
|
visible: true
|
||||||
|
width: outputWidth
|
||||||
|
height: outputHeight
|
||||||
|
|
||||||
|
LayerShell.Window.scope: "nebula-desktop"
|
||||||
|
LayerShell.Window.layer: LayerShell.Window.LayerBackground
|
||||||
|
LayerShell.Window.anchors: LayerShell.Window.AnchorTop
|
||||||
|
| LayerShell.Window.AnchorBottom
|
||||||
|
| LayerShell.Window.AnchorLeft
|
||||||
|
| LayerShell.Window.AnchorRight
|
||||||
|
LayerShell.Window.exclusionZone: 0
|
||||||
|
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
|
||||||
|
|
||||||
|
Canvas {
|
||||||
|
id: backdrop
|
||||||
|
anchors.fill: parent
|
||||||
|
onPaint: {
|
||||||
|
const ctx = getContext("2d")
|
||||||
|
ctx.reset()
|
||||||
|
ctx.fillStyle = Theme.background
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
|
||||||
|
let glow = ctx.createRadialGradient(width * 0.12, height * -0.08, 0, width * 0.12, height * -0.08, width * 0.7)
|
||||||
|
glow.addColorStop(0, "rgba(110, 140, 180, 0.16)")
|
||||||
|
glow.addColorStop(0.52, "rgba(110, 140, 180, 0)")
|
||||||
|
ctx.fillStyle = glow
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
|
||||||
|
glow = ctx.createRadialGradient(width * 0.88, height * 1.08, 0, width * 0.88, height * 1.08, width * 0.45)
|
||||||
|
glow.addColorStop(0, "rgba(42, 68, 98, 0.28)")
|
||||||
|
glow.addColorStop(0.58, "rgba(42, 68, 98, 0)")
|
||||||
|
ctx.fillStyle = glow
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
|
||||||
|
glow = ctx.createRadialGradient(width * 0.62, height * 0.38, 0, width * 0.62, height * 0.38, width * 0.28)
|
||||||
|
glow.addColorStop(0, "rgba(72, 92, 120, 0.08)")
|
||||||
|
glow.addColorStop(0.7, "rgba(72, 92, 120, 0)")
|
||||||
|
ctx.fillStyle = glow
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
|
||||||
|
const veil = ctx.createLinearGradient(0, 0, 0, height)
|
||||||
|
veil.addColorStop(0, "rgba(8, 11, 17, 0.22)")
|
||||||
|
veil.addColorStop(0.16, "rgba(8, 11, 17, 0)")
|
||||||
|
veil.addColorStop(0.78, "rgba(8, 11, 17, 0)")
|
||||||
|
veil.addColorStop(1, "rgba(8, 11, 17, 0.38)")
|
||||||
|
ctx.fillStyle = veil
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
onWidthChanged: requestPaint()
|
||||||
|
onHeightChanged: requestPaint()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Window
|
||||||
|
import org.kde.layershell 1.0 as LayerShell
|
||||||
|
import Nebula.UI
|
||||||
|
|
||||||
|
Window {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property int outputWidth: OutputTracker.width > 0
|
||||||
|
? OutputTracker.width
|
||||||
|
: (screen && screen.width > 0 ? screen.width : Screen.width)
|
||||||
|
readonly property int outputHeight: OutputTracker.height > 0
|
||||||
|
? OutputTracker.height
|
||||||
|
: (screen && screen.height > 0 ? screen.height : Screen.height)
|
||||||
|
|
||||||
|
title: qsTr("Nebula Launcher")
|
||||||
|
color: "transparent"
|
||||||
|
flags: Qt.FramelessWindowHint
|
||||||
|
visible: ShellState.launcherOpen || launcher.opacity > 0.01
|
||||||
|
width: outputWidth
|
||||||
|
height: Math.max(1, outputHeight - Theme.topBarHeight)
|
||||||
|
|
||||||
|
LayerShell.Window.scope: "nebula-launcher"
|
||||||
|
LayerShell.Window.layer: LayerShell.Window.LayerOverlay
|
||||||
|
LayerShell.Window.anchors: LayerShell.Window.AnchorTop
|
||||||
|
| LayerShell.Window.AnchorBottom
|
||||||
|
| LayerShell.Window.AnchorLeft
|
||||||
|
| LayerShell.Window.AnchorRight
|
||||||
|
LayerShell.Window.margins.top: Theme.topBarHeight
|
||||||
|
LayerShell.Window.exclusionZone: 0
|
||||||
|
LayerShell.Window.keyboardInteractivity: ShellState.launcherOpen
|
||||||
|
? LayerShell.Window.KeyboardInteractivityExclusive
|
||||||
|
: LayerShell.Window.KeyboardInteractivityNone
|
||||||
|
|
||||||
|
Item {
|
||||||
|
anchors.fill: parent
|
||||||
|
focus: ShellState.launcherOpen
|
||||||
|
Keys.onEscapePressed: {
|
||||||
|
event.accepted = true
|
||||||
|
ShellState.closeLauncher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
onClicked: ShellState.closeLauncher()
|
||||||
|
}
|
||||||
|
|
||||||
|
Launcher {
|
||||||
|
id: launcher
|
||||||
|
x: 10
|
||||||
|
y: 10
|
||||||
|
width: Math.min(420, parent.width - 20)
|
||||||
|
height: Math.min(implicitHeight, Math.min(640, parent.height - 24))
|
||||||
|
opacity: ShellState.launcherOpen ? 1 : 0
|
||||||
|
yOffset: ShellState.launcherOpen ? 0 : -6
|
||||||
|
scale: ShellState.launcherOpen ? 1 : 0.985
|
||||||
|
focus: ShellState.launcherOpen
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: Theme.animationNormal
|
||||||
|
easing.type: Easing.OutCubic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on yOffset {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: Theme.animationNormal
|
||||||
|
easing.type: Easing.OutCubic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on scale {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: Theme.animationNormal
|
||||||
|
easing.type: Easing.OutCubic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Window
|
||||||
|
import org.kde.layershell 1.0 as LayerShell
|
||||||
|
import Nebula.UI
|
||||||
|
|
||||||
|
Window {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property int outputWidth: OutputTracker.width > 0
|
||||||
|
? OutputTracker.width
|
||||||
|
: (screen && screen.width > 0 ? screen.width : Screen.width)
|
||||||
|
|
||||||
|
title: qsTr("Nebula Top Bar")
|
||||||
|
color: "transparent"
|
||||||
|
flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
|
||||||
|
visible: true
|
||||||
|
width: outputWidth
|
||||||
|
height: Theme.topBarHeight
|
||||||
|
|
||||||
|
LayerShell.Window.scope: "nebula-topbar"
|
||||||
|
LayerShell.Window.layer: LayerShell.Window.LayerTop
|
||||||
|
LayerShell.Window.anchors: LayerShell.Window.AnchorTop
|
||||||
|
| LayerShell.Window.AnchorLeft
|
||||||
|
| LayerShell.Window.AnchorRight
|
||||||
|
LayerShell.Window.exclusionZone: Theme.topBarHeight
|
||||||
|
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
|
||||||
|
|
||||||
|
TopBar {
|
||||||
|
anchors.fill: parent
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Window
|
||||||
|
import org.kde.layershell 1.0 as LayerShell
|
||||||
|
import Nebula.UI
|
||||||
|
import Nebula.Windows
|
||||||
|
|
||||||
|
Window {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property bool active: WindowService.hasFocusedWindow
|
||||||
|
&& !WindowService.focusedWindowFullscreen
|
||||||
|
&& !ShellState.launcherOpen
|
||||||
|
readonly property int chromeX: WindowService.focusedWindowX
|
||||||
|
+ Theme.windowChromeInset
|
||||||
|
- WindowService.focusedWorkspaceX
|
||||||
|
readonly property int chromeY: {
|
||||||
|
const barH = WindowService.focusedWindowTitleBarHeight
|
||||||
|
const centered = WindowService.focusedWindowTitleBarY
|
||||||
|
+ Math.max(0, Math.round((barH - height) / 2))
|
||||||
|
return centered - WindowService.focusedWorkspaceY
|
||||||
|
}
|
||||||
|
|
||||||
|
title: qsTr("Nebula Window Chrome")
|
||||||
|
color: "transparent"
|
||||||
|
flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
|
||||||
|
visible: root.active
|
||||||
|
width: Math.max(1, controls.implicitWidth)
|
||||||
|
height: Math.max(1, controls.implicitHeight)
|
||||||
|
|
||||||
|
LayerShell.Window.scope: "nebula-window-chrome"
|
||||||
|
LayerShell.Window.layer: LayerShell.Window.LayerTop
|
||||||
|
LayerShell.Window.anchors: LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorTop
|
||||||
|
LayerShell.Window.margins.left: Math.max(0, chromeX)
|
||||||
|
LayerShell.Window.margins.top: Math.max(0, chromeY)
|
||||||
|
LayerShell.Window.exclusionZone: 0
|
||||||
|
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
|
||||||
|
|
||||||
|
WindowControls {
|
||||||
|
id: controls
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#include "DesktopIconProvider.hpp"
|
||||||
|
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QIcon>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QIcon resolveDesktopIcon(const QString &name)
|
||||||
|
{
|
||||||
|
if (name.isEmpty())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const QFileInfo direct(name);
|
||||||
|
if (direct.isAbsolute() && direct.exists())
|
||||||
|
return QIcon(name);
|
||||||
|
|
||||||
|
QIcon themed = QIcon::fromTheme(name);
|
||||||
|
if (!themed.isNull())
|
||||||
|
return themed;
|
||||||
|
|
||||||
|
QString base = name;
|
||||||
|
if (base.endsWith(QLatin1String(".png"), Qt::CaseInsensitive)
|
||||||
|
|| base.endsWith(QLatin1String(".svg"), Qt::CaseInsensitive)
|
||||||
|
|| base.endsWith(QLatin1String(".xpm"), Qt::CaseInsensitive)) {
|
||||||
|
base = QFileInfo(base).completeBaseName();
|
||||||
|
themed = QIcon::fromTheme(base);
|
||||||
|
if (!themed.isNull())
|
||||||
|
return themed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList pixmapDirs = {
|
||||||
|
QStringLiteral("/usr/share/pixmaps"),
|
||||||
|
QStringLiteral("/usr/share/icons/hicolor/48x48/apps"),
|
||||||
|
QStringLiteral("/usr/share/icons/hicolor/scalable/apps"),
|
||||||
|
};
|
||||||
|
const QStringList suffixes = {
|
||||||
|
QString(),
|
||||||
|
QStringLiteral(".png"),
|
||||||
|
QStringLiteral(".svg"),
|
||||||
|
QStringLiteral(".xpm"),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const QString &dir : pixmapDirs) {
|
||||||
|
for (const QString &suffix : suffixes) {
|
||||||
|
const QString candidate = dir + QLatin1Char('/') + (suffix.isEmpty() ? name : base) + suffix;
|
||||||
|
if (QFileInfo::exists(candidate))
|
||||||
|
return QIcon(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
DesktopIconProvider::DesktopIconProvider()
|
||||||
|
: QQuickImageProvider(QQuickImageProvider::Pixmap)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QPixmap DesktopIconProvider::requestPixmap(const QString &id, QSize *size, const QSize &requestedSize)
|
||||||
|
{
|
||||||
|
const QString iconName = QUrl::fromPercentEncoding(id.toUtf8());
|
||||||
|
const QIcon icon = resolveDesktopIcon(iconName);
|
||||||
|
const QSize target = requestedSize.isValid() && requestedSize.width() > 0 && requestedSize.height() > 0
|
||||||
|
? requestedSize
|
||||||
|
: QSize(48, 48);
|
||||||
|
|
||||||
|
if (icon.isNull()) {
|
||||||
|
if (size)
|
||||||
|
*size = target;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const QPixmap pixmap = icon.pixmap(target);
|
||||||
|
if (pixmap.isNull()) {
|
||||||
|
if (size)
|
||||||
|
*size = target;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size)
|
||||||
|
*size = pixmap.size();
|
||||||
|
return pixmap;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user