three-finger tap opens a window sheet, not the overview
This commit is contained in:
parent
3aea8398a9
commit
bf8bc013ef
21 changed files with 1476 additions and 49 deletions
|
|
@ -408,7 +408,7 @@ pub enum Action {
|
|||
/// `LOCK-DPMS-LESSONS.md` §7 says it outright — "wake sources move the
|
||||
/// panel, only sessiond moves the lock".
|
||||
Unblank,
|
||||
/// Raise the window overview.
|
||||
/// Raise the window action sheet for one window.
|
||||
///
|
||||
/// A touch gesture bound the way every other control is: viewtop reports
|
||||
/// what the fingers did, the machine decides what it means, and the
|
||||
|
|
@ -421,7 +421,13 @@ pub enum Action {
|
|||
/// The compositor has to do the *recognition* because contacts only exist
|
||||
/// there, which is why this arrives already named rather than as raw
|
||||
/// touches. What it must not do is decide what a name means.
|
||||
Overview,
|
||||
///
|
||||
/// This replaced `Overview`, which the same tap used to raise. Two gestures
|
||||
/// reaching one surface is the one-decider problem in miniature: the rail's
|
||||
/// swipe already opens the overview, so the tap spent its whole existence
|
||||
/// duplicating a gesture the thumb already had. `target` is what makes the
|
||||
/// difference — the sheet is *about a window*, and the tap now knows which.
|
||||
WindowSheet { target: u64 },
|
||||
/// Request the session lock, because something wants the panel dark and
|
||||
/// the session is not locked yet.
|
||||
///
|
||||
|
|
@ -1714,13 +1720,27 @@ impl DeviceStateMachine {
|
|||
/// chose is worse than one that fires nothing, and the binding table this
|
||||
/// is a placeholder for is `DeviceStatePolicy`'s to hold — persisted and
|
||||
/// agent-writable, so a triple-tap can be re-pointed without a rebuild.
|
||||
pub fn touch_gesture(&mut self, fingers: u8, gesture: TouchGesture) -> Vec<Action> {
|
||||
let bound = fingers == 3 && matches!(gesture, TouchGesture::Tap);
|
||||
pub fn touch_gesture(
|
||||
&mut self,
|
||||
fingers: u8,
|
||||
gesture: TouchGesture,
|
||||
target: Option<u64>,
|
||||
) -> Vec<Action> {
|
||||
// A three-finger tap raises the window action sheet for the window it
|
||||
// landed on. It used to raise the overview, and that was wrong twice
|
||||
// over: the rail's swipe already reaches the same surface — a gesture
|
||||
// that duplicates another gesture is a defect by construction — and
|
||||
// the tap arrived with no subject at all, because the compositor
|
||||
// dropped the centroid, so the overview was the only thing it *could*
|
||||
// name. Now that the target travels with the gesture, the tap can be
|
||||
// about the window under the fingers, which is what it was always for.
|
||||
let bound = fingers == 3 && matches!(gesture, TouchGesture::Tap) && target.is_some();
|
||||
self.record_decision(
|
||||
"touch-gesture",
|
||||
serde_json::json!({
|
||||
"fingers": fingers,
|
||||
"gesture": gesture.as_str(),
|
||||
"target": target,
|
||||
"bound": bound,
|
||||
}),
|
||||
"touch gesture recognised",
|
||||
|
|
@ -1728,14 +1748,20 @@ impl DeviceStateMachine {
|
|||
if !bound {
|
||||
return Vec::new();
|
||||
}
|
||||
// Not while the screen is dark: the overview is content, and putting
|
||||
// Not while the screen is dark: the sheet is content, and putting
|
||||
// content on an unauthenticated glass is what §4's disclosure rules
|
||||
// exist to prevent. A tap on a dark panel is a wake, and that is the
|
||||
// power button's business, not this one's.
|
||||
if !self.panel_on {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![Action::Overview]
|
||||
// `target` is Some — `bound` required it. A tap on the wallpaper falls
|
||||
// out above with `bound: false` in the trail rather than raising an
|
||||
// empty sheet, which is the "why did a blank sheet appear" bug the
|
||||
// no-window case exists to avoid.
|
||||
vec![Action::WindowSheet {
|
||||
target: target.unwrap_or_default(),
|
||||
}]
|
||||
}
|
||||
|
||||
pub fn tick_at(&mut self, now: Instant) -> Vec<Action> {
|
||||
|
|
@ -3205,10 +3231,19 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn three_fingers_raise_the_overview() {
|
||||
fn three_fingers_raise_the_sheet_for_the_window_they_landed_on() {
|
||||
let (mut sm, _t0) = unlocked_and_lit();
|
||||
let actions = sm.touch_gesture(3, TouchGesture::Tap);
|
||||
assert_eq!(actions, vec![Action::Overview]);
|
||||
let actions = sm.touch_gesture(3, TouchGesture::Tap, Some(7));
|
||||
assert_eq!(actions, vec![Action::WindowSheet { target: 7 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_three_finger_tap_on_the_wallpaper_raises_nothing() {
|
||||
// The no-window case, decided rather than discovered on device: a tap
|
||||
// with no subject is inert. The alternative is a sheet with nothing to
|
||||
// act on, which is the "why did a blank sheet appear" bug.
|
||||
let (mut sm, _t0) = unlocked_and_lit();
|
||||
assert!(sm.touch_gesture(3, TouchGesture::Tap, None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -3223,24 +3258,25 @@ mod tests {
|
|||
TouchGesture::SwipeLeft,
|
||||
TouchGesture::SwipeRight,
|
||||
] {
|
||||
assert!(sm.touch_gesture(3, g).is_empty(), "{g:?}");
|
||||
assert!(sm.touch_gesture(3, g, Some(1)).is_empty(), "{g:?}");
|
||||
}
|
||||
for fingers in [1u8, 2, 4, 5] {
|
||||
assert!(
|
||||
sm.touch_gesture(fingers, TouchGesture::Tap).is_empty(),
|
||||
sm.touch_gesture(fingers, TouchGesture::Tap, Some(1))
|
||||
.is_empty(),
|
||||
"{fingers} fingers"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_overview_does_not_open_on_a_dark_panel() {
|
||||
// The overview is content, and content on an unauthenticated glass is
|
||||
fn the_sheet_does_not_open_on_a_dark_panel() {
|
||||
// The sheet is content, and content on an unauthenticated glass is
|
||||
// what the disclosure rules exist to prevent. A tap on a dark panel is
|
||||
// a wake, and that is the power button's business.
|
||||
let (mut sm, _t0) = unlocked_and_lit();
|
||||
sm.set_panel(false);
|
||||
assert!(sm.touch_gesture(3, TouchGesture::Tap).is_empty());
|
||||
assert!(sm.touch_gesture(3, TouchGesture::Tap, Some(1)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ pub const VERBS: &[VerbDoc] = &[
|
|||
mutates: true,
|
||||
summary: "a recognised touch gesture; the machine decides what it means",
|
||||
refuses: &[RefusalCode::InvalidArgument],
|
||||
example: r#"{"op":"gesture","fingers":3,"gesture":"tap"}"#,
|
||||
example: r#"{"op":"gesture","fingers":3,"gesture":"tap","target":1}"#,
|
||||
},
|
||||
VerbDoc {
|
||||
op: "panel",
|
||||
|
|
@ -315,7 +315,20 @@ pub enum Request {
|
|||
/// recognise them. The line §12 draws still holds — the compositor names
|
||||
/// what the fingers did and the machine decides what it means. A compositor
|
||||
/// that both recognised and acted would be the eighth blind actor.
|
||||
Gesture { fingers: u8, gesture: TouchGesture },
|
||||
///
|
||||
/// `target` is the window the gesture landed on, named by the compositor
|
||||
/// through the same hit test a finger goes through. It travels with the
|
||||
/// gesture because that mapping exists nowhere else — it accounts for the
|
||||
/// zone strip and any pose — and a machine re-deriving it from a centroid
|
||||
/// would eventually disagree with what the hand actually hit. Absent means
|
||||
/// the gesture landed on the wallpaper, which is a real answer and a
|
||||
/// different one from "no window exists".
|
||||
Gesture {
|
||||
fingers: u8,
|
||||
gesture: TouchGesture,
|
||||
#[serde(default)]
|
||||
target: Option<u64>,
|
||||
},
|
||||
/// The DPMS executor reports the panel's real power state. The machine
|
||||
/// keeps the panel as a field, not a state: "locked with the screen off"
|
||||
/// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark
|
||||
|
|
|
|||
|
|
@ -341,6 +341,11 @@ fn execute(shared: &Arc<Shared>, action: Action) {
|
|||
return;
|
||||
}
|
||||
|
||||
// The sheet's target, rendered once and outliving the match so the table's
|
||||
// `&str` arguments can borrow it. Every other row's arguments are static;
|
||||
// this is the first action that carries a value into its own command line.
|
||||
let sheet_target: String;
|
||||
|
||||
let (program, args, label): (&str, Vec<&str>, &str) = match action {
|
||||
// sessiond guarantees this runs once per dim, which is what the old
|
||||
// hypridle `-s`/`-r` listener could not: a second save while already
|
||||
|
|
@ -372,14 +377,26 @@ fn execute(shared: &Arc<Shared>, action: Action) {
|
|||
vec!["set-volume", "@DEFAULT_AUDIO_SINK@", "5%-"],
|
||||
"volume-down",
|
||||
),
|
||||
// The shell owns the overview; the machine owns what opens it. Same
|
||||
// shape as every other row here — a named tool with named arguments,
|
||||
// one place, in the trail.
|
||||
Action::Overview => (
|
||||
"qs",
|
||||
vec!["-c", "souveraine", "ipc", "call", "overview", "toggle"],
|
||||
"overview-toggle",
|
||||
),
|
||||
// The shell owns the sheet; the machine owns what opens it, and for
|
||||
// which window. Same shape as every other row — a named tool with
|
||||
// named arguments, one place, in the trail — except that the window is
|
||||
// an argument, because a sheet with no subject is the bug this replaced.
|
||||
Action::WindowSheet { target } => {
|
||||
sheet_target = target.to_string();
|
||||
(
|
||||
"qs",
|
||||
vec![
|
||||
"-c",
|
||||
"souveraine",
|
||||
"ipc",
|
||||
"call",
|
||||
"windowSheet",
|
||||
"open",
|
||||
&sheet_target,
|
||||
],
|
||||
"window-sheet-open",
|
||||
)
|
||||
}
|
||||
Action::Blank => {
|
||||
// The invariant, enforced where it cannot be reasoned around: the
|
||||
// panel does not go dark unless logind says this session is
|
||||
|
|
@ -1256,10 +1273,14 @@ fn handle_request(
|
|||
}
|
||||
serde_json::json!({ "ok": true })
|
||||
}
|
||||
Request::Gesture { fingers, gesture } => {
|
||||
Request::Gesture {
|
||||
fingers,
|
||||
gesture,
|
||||
target,
|
||||
} => {
|
||||
let actions = {
|
||||
let mut d = shared.lock();
|
||||
d.device_state.touch_gesture(fingers, gesture)
|
||||
d.device_state.touch_gesture(fingers, gesture, target)
|
||||
};
|
||||
let bound = !actions.is_empty();
|
||||
for action in actions {
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ ApplicationWindow {
|
|||
icon: "wallpaper",
|
||||
component: "modules/settings/WallpaperConfig.qml"
|
||||
},
|
||||
{
|
||||
name: Translation.tr("Home screen"),
|
||||
icon: "apps",
|
||||
component: "modules/settings/OverviewConfig.qml"
|
||||
},
|
||||
{
|
||||
name: Translation.tr("Dock"),
|
||||
icon: "dock_to_bottom",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ modules/souveraine/selection/SelectionActions.qml souveraine/modules/souveraine/
|
|||
modules/souveraine/selection/SelectionAction.qml souveraine/modules/souveraine/selection/SelectionAction.qml
|
||||
modules/souveraine/selection/SelectionSeparator.qml souveraine/modules/souveraine/selection/SelectionSeparator.qml
|
||||
modules/souveraine/selection/qmldir souveraine/modules/souveraine/selection/qmldir
|
||||
modules/souveraine/windowSheet/WindowSheet.qml souveraine/modules/souveraine/windowSheet/WindowSheet.qml
|
||||
modules/souveraine/windowSheet/SheetButton.qml souveraine/modules/souveraine/windowSheet/SheetButton.qml
|
||||
modules/souveraine/windowSheet/qmldir souveraine/modules/souveraine/windowSheet/qmldir
|
||||
services/ViewtopControl.qml souveraine/services/ViewtopControl.qml
|
||||
modules/souveraine/subconscious/SubconsciousTicker.qml souveraine/modules/souveraine/subconscious/SubconsciousTicker.qml
|
||||
modules/souveraine/subconscious/SubconsciousEventPanel.qml souveraine/modules/souveraine/subconscious/SubconsciousEventPanel.qml
|
||||
modules/souveraine/subconscious/qmldir souveraine/modules/souveraine/subconscious/qmldir
|
||||
|
|
@ -86,6 +90,7 @@ modules/common/ShellModel.qml souveraine/modules/common/ShellModel.qml
|
|||
modules/common/functions/Session.qml souveraine/modules/common/functions/Session.qml
|
||||
modules/common/widgets/ContentPage.qml souveraine/modules/common/widgets/ContentPage.qml
|
||||
modules/common/widgets/FullscreenPolkitWindow.qml souveraine/modules/common/widgets/FullscreenPolkitWindow.qml
|
||||
modules/common/widgets/FloatingActionButton.qml souveraine/modules/common/widgets/FloatingActionButton.qml
|
||||
modules/common/widgets/StyledToolTip.qml souveraine/modules/common/widgets/StyledToolTip.qml
|
||||
modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml
|
||||
modules/settings/NetworkConfig.qml souveraine/modules/settings/NetworkConfig.qml
|
||||
|
|
@ -93,6 +98,7 @@ modules/settings/DisplayConfig.qml souveraine/modules/settings/DisplayConfig.qm
|
|||
modules/settings/SoundConfig.qml souveraine/modules/settings/SoundConfig.qml
|
||||
modules/settings/LockConfig.qml souveraine/modules/settings/LockConfig.qml
|
||||
modules/settings/WallpaperConfig.qml souveraine/modules/settings/WallpaperConfig.qml
|
||||
modules/settings/OverviewConfig.qml souveraine/modules/settings/OverviewConfig.qml
|
||||
modules/settings/DockConfig.qml souveraine/modules/settings/DockConfig.qml
|
||||
modules/settings/NavigationConfig.qml souveraine/modules/settings/NavigationConfig.qml
|
||||
modules/settings/KeyboardConfig.qml souveraine/modules/settings/KeyboardConfig.qml
|
||||
|
|
@ -109,6 +115,8 @@ modules/ii/dock/DockStack.qml souveraine/modules/ii/dock/DockStack.qml
|
|||
modules/ii/appInventory/AppInventory.qml souveraine/modules/ii/appInventory/AppInventory.qml
|
||||
modules/ii/appInventory/AppInventoryScope.qml souveraine/modules/ii/appInventory/AppInventoryScope.qml
|
||||
modules/ii/overview/Overview.qml souveraine/modules/ii/overview/Overview.qml
|
||||
modules/ii/overview/AppGrid.qml souveraine/modules/ii/overview/AppGrid.qml
|
||||
modules/ii/background/widgets/clock/CookieClock.qml souveraine/modules/ii/background/widgets/clock/CookieClock.qml
|
||||
modules/ii/screenCorners/ScreenCorners.qml souveraine/modules/ii/screenCorners/ScreenCorners.qml
|
||||
modules/ii/onScreenKeyboard/OnScreenKeyboard.qml souveraine/modules/ii/onScreenKeyboard/OnScreenKeyboard.qml
|
||||
modules/common/Persistent.qml souveraine/modules/common/Persistent.qml
|
||||
|
|
|
|||
65
surfaces/quickshell/ii-phone/scripts/ai/gemini-categorize-wallpaper.sh
Executable file
65
surfaces/quickshell/ii-phone/scripts/ai/gemini-categorize-wallpaper.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -z "$1" ]]; then
|
||||
echo "Usage: $0 <image_path> [model] [prompt]"
|
||||
echo "Tip: set GEMINI_WALLPAPER_MODEL and/or GEMINI_WALLPAPER_PROMPT to provide defaults."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Variables
|
||||
SOURCE_IMG_PATH="$1"
|
||||
MODEL="${2:-${GEMINI_WALLPAPER_MODEL:-gemini-3.6-flash}}" # Souveraine overlay: gemini-2.5-flash 404s for new free-tier keys (2026-08)
|
||||
WALLPAPER_NAME="$(basename "$SOURCE_IMG_PATH")"
|
||||
PROMPT="${3:-${GEMINI_WALLPAPER_PROMPT:-Categorize the wallpaper. Its file name is $WALLPAPER_NAME}}"
|
||||
RESIZED_IMG_PATH="/tmp/quickshell/ai/wallpaper.jpg"
|
||||
|
||||
# Resize image for speed
|
||||
mkdir -p "$(dirname "$RESIZED_IMG_PATH")"
|
||||
magick "$SOURCE_IMG_PATH" -resize 200x -quality 50 "$RESIZED_IMG_PATH"
|
||||
|
||||
# Get API key
|
||||
API_KEY=$(secret-tool lookup 'application' 'illogical-impulse' | jq -r '.apiKeys.gemini')
|
||||
|
||||
# Encode image to base64
|
||||
if [[ "$(base64 --version 2>&1)" = *"FreeBSD"* ]]; then
|
||||
B64FLAGS="--input"
|
||||
else
|
||||
B64FLAGS="-w0"
|
||||
fi
|
||||
B64DATA="$(base64 $B64FLAGS $RESIZED_IMG_PATH)"
|
||||
# echo $B64DATA
|
||||
|
||||
# Prepare request data
|
||||
payload='{
|
||||
"contents": [{
|
||||
"parts":[
|
||||
{
|
||||
"inline_data": {
|
||||
"mime_type":"image/jpeg",
|
||||
"data": "'"$B64DATA"'"
|
||||
}
|
||||
},
|
||||
{"text": "'"$PROMPT"'"}
|
||||
]
|
||||
}],
|
||||
"generationConfig": {
|
||||
"responseMimeType": "text/x.enum",
|
||||
"responseSchema": {
|
||||
"type": "string",
|
||||
"enum": [ "abstract", "anime", "city", "minimalist", "landscape", "plants", "person", "space" ]
|
||||
},
|
||||
"temperature": 0
|
||||
}
|
||||
}'
|
||||
# echo "$payload" | jq
|
||||
|
||||
# Make the request
|
||||
response=$(curl "https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent" \
|
||||
-H "x-goog-api-key: $API_KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST \
|
||||
-d "$payload" 2> /dev/null)
|
||||
# echo "$response" | jq
|
||||
|
||||
# Write the result
|
||||
echo "$response" | jq -r '.candidates[0].content.parts[0].text'
|
||||
|
|
@ -179,7 +179,17 @@ Singleton {
|
|||
property string style: "cookie" // Options: "cookie", "digital"
|
||||
property string styleLocked: "cookie" // Options: "cookie", "digital"
|
||||
property JsonObject cookie: JsonObject {
|
||||
// aiStyling gates the categorize step in
|
||||
// switchwall.sh (it must be true for wallpapers to
|
||||
// be AI-categorized on apply). aiPreset is the
|
||||
// SEPARATE gate for CookieClock applying a
|
||||
// category preset over the user's configured clock
|
||||
// style — split 2026-08-05 after turning on
|
||||
// aiStyling rewrote the phone's clock
|
||||
// (applyStyle writes these keys). Default off: the
|
||||
// clock keeps whatever the user set.
|
||||
property bool aiStyling: false
|
||||
property bool aiPreset: false
|
||||
property int sides: 14
|
||||
property string dialNumberStyle: "full" // Options: "dots" , "numbers", "full" , "none"
|
||||
property string hourHandStyle: "fill" // Options: "classic", "fill", "hollow", "hide"
|
||||
|
|
@ -612,6 +622,15 @@ Singleton {
|
|||
property bool orderRightLeft: false
|
||||
property bool orderBottomUp: false
|
||||
property bool centerIcons: true
|
||||
// The app drawer's own grid (TASK-14). Kept nested instead of
|
||||
// overloading rows/columns above — those are the desktop
|
||||
// overview's workspace-grid knobs, and the drawer is a
|
||||
// different density. The settings app writes these.
|
||||
property JsonObject appGrid: JsonObject {
|
||||
property int columns: 4
|
||||
property int rows: 5
|
||||
property int iconSize: 44
|
||||
}
|
||||
}
|
||||
|
||||
property JsonObject regionSelector: JsonObject {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
/**
|
||||
* Material 3 FAB.
|
||||
*
|
||||
* Souveraine override of ii's widget: the label inside the collapsed Revealer
|
||||
* centers on the button instead of the Revealer (see the comment at
|
||||
* buttonText), breaking the implicitHeight binding loop upstream logs on every
|
||||
* startup. Everything else is verbatim ii — diff against ii-base before
|
||||
* re-applying if ii updates.
|
||||
*/
|
||||
RippleButton {
|
||||
id: root
|
||||
property string iconText: "add"
|
||||
property bool expanded: false
|
||||
property real baseSize: 56
|
||||
property real elementSpacing: 5
|
||||
implicitWidth: expanded ? (Math.max(contentRowLayout.implicitWidth + 10 * 2, baseSize)) : baseSize
|
||||
implicitHeight: baseSize
|
||||
buttonRadius: baseSize / 14 * 4
|
||||
colBackground: Appearance.colors.colPrimaryContainer
|
||||
colBackgroundHover: Appearance.colors.colPrimaryContainerHover
|
||||
colRipple: Appearance.colors.colPrimaryContainerActive
|
||||
property color colOnBackground: Appearance.colors.colOnPrimaryContainer
|
||||
contentItem: Row {
|
||||
id: contentRowLayout
|
||||
property real horizontalMargins: (root.baseSize - icon.width) / 2
|
||||
anchors {
|
||||
verticalCenter: parent?.verticalCenter
|
||||
left: parent?.left
|
||||
leftMargin: contentRowLayout.horizontalMargins
|
||||
}
|
||||
spacing: 0
|
||||
|
||||
MaterialSymbol {
|
||||
id: icon
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
iconSize: 26
|
||||
color: root.colOnBackground
|
||||
text: root.iconText
|
||||
}
|
||||
Loader {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.buttonText?.length > 0
|
||||
active: true
|
||||
sourceComponent: Revealer {
|
||||
visible: root.expanded || implicitWidth > 0
|
||||
reveal: root.expanded
|
||||
implicitWidth: reveal ? (buttonText.implicitWidth + root.elementSpacing + contentRowLayout.horizontalMargins) : 0
|
||||
StyledText {
|
||||
id: buttonText
|
||||
anchors {
|
||||
left: parent.left
|
||||
leftMargin: root.elementSpacing
|
||||
// Center on the BUTTON, not the Revealer (2026-08-05).
|
||||
// Centering on the Revealer bound the text's y to the
|
||||
// Revealer's height, whose implicitHeight is
|
||||
// childrenRect.height, which depends on the text's
|
||||
// y — the implicitHeight binding loop logged on every
|
||||
// startup (FloatingActionButton.qml[45:30]). The
|
||||
// Revealer and the button share a center anyway, so
|
||||
// this is visually identical and acyclic.
|
||||
verticalCenter: root.verticalCenter
|
||||
}
|
||||
text: root.buttonText
|
||||
color: Appearance.colors.colOnPrimaryContainer
|
||||
font.pixelSize: 14
|
||||
font.weight: 450
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
// Souveraine fork of ii's CookieClock. One change: the category-preset gate
|
||||
// reads cookie.aiPreset instead of cookie.aiStyling (see the comment at
|
||||
// setClockPreset). Diff against ii-base before re-applying if ii updates.
|
||||
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Qt5Compat.GraphicalEffects
|
||||
import Quickshell.Io
|
||||
|
||||
import qs.modules.ii.background.widgets.clock.dateIndicator
|
||||
import qs.modules.ii.background.widgets.clock.minuteMarks
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
readonly property string clockStyle: Config.options.background.widgets.clock.style
|
||||
|
||||
property real implicitSize: 230
|
||||
|
||||
property color colShadow: Appearance.colors.colShadow
|
||||
property color colBackground: Appearance.colors.colPrimaryContainer
|
||||
property color colOnBackground: ColorUtils.mix(Appearance.colors.colSecondary, Appearance.colors.colPrimaryContainer, 0.15)
|
||||
property color colBackgroundInfo: ColorUtils.mix(Appearance.colors.colPrimary, Appearance.colors.colPrimaryContainer, 0.55)
|
||||
property color colHourHand: Appearance.colors.colPrimary
|
||||
property color colMinuteHand: Appearance.colors.colTertiary
|
||||
property color colSecondHand: Appearance.colors.colPrimary
|
||||
|
||||
readonly property list<string> clockNumbers: DateTime.time.split(/[: ]/)
|
||||
readonly property int clockHour: parseInt(clockNumbers[0]) % 12
|
||||
readonly property int clockMinute: DateTime.clock.minutes
|
||||
readonly property int clockSecond: DateTime.clock.seconds
|
||||
|
||||
implicitWidth: implicitSize
|
||||
implicitHeight: implicitSize
|
||||
|
||||
function applyStyle(sides, dialStyle, hourHandStyle, minuteHandStyle, secondHandStyle, dateStyle) {
|
||||
Config.options.background.widgets.clock.cookie.sides = sides
|
||||
Config.options.background.widgets.clock.cookie.dialNumberStyle = dialStyle
|
||||
Config.options.background.widgets.clock.cookie.hourHandStyle = hourHandStyle
|
||||
Config.options.background.widgets.clock.cookie.minuteHandStyle = minuteHandStyle
|
||||
Config.options.background.widgets.clock.cookie.secondHandStyle = secondHandStyle
|
||||
Config.options.background.widgets.clock.cookie.dateStyle = dateStyle
|
||||
}
|
||||
|
||||
function setClockPreset(category) {
|
||||
// Souveraine fork (2026-08-05): gate is cookie.aiPreset, not
|
||||
// cookie.aiStyling. Upstream runs both the wallpaper-categorizer
|
||||
// (switchwall.sh) and this preset override off the one flag, so
|
||||
// turning categorization on rewrote users' configured clock styles
|
||||
// via applyStyle. aiStyling stays true for the pipeline; this
|
||||
// surface only moves when aiPreset is explicitly on. Verbatim
|
||||
// upstream otherwise.
|
||||
if (!Config.options.background.widgets.clock.cookie.aiPreset) return;
|
||||
if (category === "") return;
|
||||
print("[Cookie clock] Setting clock preset for category: " + category)
|
||||
// "abstract", "anime", "city", "minimalist", "landscape", "plants", "person", "space"
|
||||
if (category == "abstract") {
|
||||
applyStyle(9, "none", "fill", "medium", "dot", "bubble")
|
||||
} else if (category == "anime") {
|
||||
applyStyle(7, "none", "fill", "bold", "dot", "bubble")
|
||||
} else if (category == "city" || category == "space") {
|
||||
applyStyle(23, "full", "hollow", "thin", "classic", "bubble")
|
||||
} else if (category == "minimalist") {
|
||||
applyStyle(6, "none", "fill", "bold", "dot", "hide")
|
||||
} else if (category == "landscape") {
|
||||
applyStyle(14, "full", "hollow", "medium", "classic", "bubble")
|
||||
} else if (category == "plants") {
|
||||
applyStyle(9, "dots", "fill", "bold", "dot", "border")
|
||||
} else if (category == "person") {
|
||||
applyStyle(14, "full", "classic", "classic", "classic", "rect")
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: categoryFileView
|
||||
path: Config.ready ? Directories.generatedWallpaperCategoryPath : ""
|
||||
watchChanges: true
|
||||
onFileChanged: reload()
|
||||
onLoaded: {
|
||||
root.setClockPreset(categoryFileView.text().trim())
|
||||
}
|
||||
}
|
||||
|
||||
property bool useSineCookie: Config.options.background.widgets.clock.cookie.useSineCookie
|
||||
StyledDropShadow {
|
||||
target: root.useSineCookie ? sineCookieLoader : roundedPolygonCookieLoader
|
||||
|
||||
RotationAnimation on rotation {
|
||||
running: Config.options.background.widgets.clock.cookie.constantlyRotate
|
||||
duration: 30000
|
||||
easing.type: Easing.Linear
|
||||
loops: Animation.Infinite
|
||||
from: 360
|
||||
to: 0
|
||||
}
|
||||
}
|
||||
Loader {
|
||||
id: sineCookieLoader
|
||||
z: 0
|
||||
visible: false // The DropShadow already draws it
|
||||
active: root.useSineCookie
|
||||
sourceComponent: SineCookie {
|
||||
implicitSize: root.implicitSize
|
||||
sides: Config.options.background.widgets.clock.cookie.sides
|
||||
color: root.colBackground
|
||||
}
|
||||
}
|
||||
Loader {
|
||||
id: roundedPolygonCookieLoader
|
||||
z: 0
|
||||
visible: false // The DropShadow already draws it
|
||||
active: !root.useSineCookie
|
||||
sourceComponent: MaterialCookie {
|
||||
implicitSize: root.implicitSize
|
||||
sides: Config.options.background.widgets.clock.cookie.sides
|
||||
color: root.colBackground
|
||||
}
|
||||
}
|
||||
|
||||
// Hour/minutes numbers/dots/lines
|
||||
MinuteMarks {
|
||||
anchors.fill: parent
|
||||
color: root.colOnBackground
|
||||
}
|
||||
|
||||
// Stupid extra hour marks in the middle
|
||||
FadeLoader {
|
||||
id: hourMarksLoader
|
||||
anchors.centerIn: parent
|
||||
shown: Config.options.background.widgets.clock.cookie.hourMarks
|
||||
sourceComponent: HourMarks {
|
||||
implicitSize: 135 * (1.75 - 0.75 * hourMarksLoader.opacity)
|
||||
color: root.colOnBackground
|
||||
colOnBackground: ColorUtils.mix(root.colBackgroundInfo, root.colOnBackground, 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
// Number column in the middle
|
||||
FadeLoader {
|
||||
id: timeColumnLoader
|
||||
anchors.centerIn: parent
|
||||
shown: Config.options.background.widgets.clock.cookie.timeIndicators
|
||||
scale: 1.4 - 0.4 * timeColumnLoader.shown
|
||||
Behavior on scale {
|
||||
animation: Appearance.animation.elementResize.numberAnimation.createObject(this)
|
||||
}
|
||||
|
||||
sourceComponent: TimeColumn {
|
||||
color: root.colBackgroundInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Minute hand
|
||||
FadeLoader {
|
||||
anchors.fill: parent
|
||||
z: 1
|
||||
shown: Config.options.background.widgets.clock.cookie.minuteHandStyle !== "hide"
|
||||
sourceComponent: MinuteHand {
|
||||
anchors.fill: parent
|
||||
clockMinute: root.clockMinute
|
||||
style: Config.options.background.widgets.clock.cookie.minuteHandStyle
|
||||
color: root.colMinuteHand
|
||||
}
|
||||
}
|
||||
|
||||
// Hour hand
|
||||
FadeLoader {
|
||||
anchors.fill: parent
|
||||
z: item?.style === "hollow" ? 0 : 2
|
||||
shown: Config.options.background.widgets.clock.cookie.hourHandStyle !== "hide"
|
||||
sourceComponent: HourHand {
|
||||
clockHour: root.clockHour
|
||||
clockMinute: root.clockMinute
|
||||
style: Config.options.background.widgets.clock.cookie.hourHandStyle
|
||||
color: root.colHourHand
|
||||
}
|
||||
}
|
||||
|
||||
// Second hand
|
||||
FadeLoader {
|
||||
id: secondHandLoader
|
||||
z: (Config.options.background.widgets.clock.cookie.secondHandStyle === "line") ? 2 : 3
|
||||
shown: Config.options.time.secondPrecision && Config.options.background.widgets.clock.cookie.secondHandStyle !== "hide"
|
||||
anchors.fill: parent
|
||||
sourceComponent: SecondHand {
|
||||
id: secondHand
|
||||
clockSecond: root.clockSecond
|
||||
style: Config.options.background.widgets.clock.cookie.secondHandStyle
|
||||
color: root.colSecondHand
|
||||
}
|
||||
}
|
||||
|
||||
// Center dot
|
||||
FadeLoader {
|
||||
z: 4
|
||||
anchors.centerIn: parent
|
||||
shown: Config.options.background.widgets.clock.cookie.minuteHandStyle !== "bold"
|
||||
sourceComponent: Rectangle {
|
||||
color: Config.options.background.widgets.clock.cookie.minuteHandStyle === "medium" ? root.colBackground : root.colMinuteHand
|
||||
implicitWidth: 6
|
||||
implicitHeight: implicitWidth
|
||||
radius: width / 2
|
||||
}
|
||||
}
|
||||
|
||||
// Date
|
||||
FadeLoader {
|
||||
anchors.fill: parent
|
||||
shown: Config.options.background.widgets.clock.cookie.dateStyle !== "hide"
|
||||
|
||||
sourceComponent: DateIndicator {
|
||||
color: root.colBackgroundInfo
|
||||
style: Config.options.background.widgets.clock.cookie.dateStyle
|
||||
}
|
||||
}
|
||||
}
|
||||
201
surfaces/quickshell/modules/ii/overview/AppGrid.qml
Normal file
201
surfaces/quickshell/modules/ii/overview/AppGrid.qml
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// The phone app drawer: every desktop entry as a paged icon grid.
|
||||
//
|
||||
// TASK-14. The overview pane is stock ii's *desktop* overview — workspace
|
||||
// thumbnails + search. On a 5.5" phone the workspace grid is dead weight, so
|
||||
// the body becomes what a phone Home key opens: an app grid. This lives in its
|
||||
// own file so Overview.qml only swaps the body widget and ii updates stay
|
||||
// mergeable (the header comment in Overview.qml, "diff against upstream before
|
||||
// re-applying"; the original stock body is `ii-base/modules/ii/overview/
|
||||
// OverviewWidget.qml`).
|
||||
//
|
||||
// Deliberately NOT the workspace grid's sibling: the reference shell's
|
||||
// overview is built on windows, and running windows have their own surface —
|
||||
// WindowOverview, raised by mission control (the pill's second-stage swipe). An
|
||||
// app drawer is for *finding* something, not for *switching* to what is
|
||||
// running, so this grid has no notion of workspaces or of what is currently
|
||||
// focused. It is the launcher half of the split; WindowOverview is the task
|
||||
// half. Do not merge them: that conflation is what landed the running-cards on
|
||||
// the Home page (the `overviewOpen || missionControlOpen` body in Overview.qml
|
||||
// that this file replaces).
|
||||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// --- Layout ----------------------------------------------------------
|
||||
// 4x5 on the 540x1080 portrait. The reference shell's layout search
|
||||
// collapses to a single column of full-width cards in portrait — that is
|
||||
// the *task* surface. An app drawer is density, not drama, so this is a
|
||||
// dense grid: 4 columns keeps a thumb travel across a page, 5 rows fills
|
||||
// the 0.78-height body without needing the whole 1080. Columns, rows and
|
||||
// icon size are user-settable in the settings app via
|
||||
// Config.options.overview.appGrid (see Config.qml); the fallbacks match
|
||||
// the phone's measured defaults. `property JsonObject` reads resolve
|
||||
// against the active config file at load, not against Config.qml's base
|
||||
// values — so these are fallbacks, not overrides.
|
||||
readonly property int columns: Math.max(1, Math.min(10,
|
||||
Config?.options?.overview?.appGrid?.columns ?? 4))
|
||||
readonly property int rows: Math.max(1, Math.min(10,
|
||||
Config?.options?.overview?.appGrid?.rows ?? 5))
|
||||
readonly property int iconSize: Math.max(24, Math.min(96,
|
||||
Config?.options?.overview?.appGrid?.iconSize ?? 44))
|
||||
readonly property int perPage: columns * rows
|
||||
readonly property real pageMargin: 14
|
||||
readonly property real cellSpacing: 6
|
||||
|
||||
// --- Data ------------------------------------------------------------
|
||||
// AppSearch.list is the canonical deduped DesktopEntry list (the same one
|
||||
// the search backend reads), sorted here alphabetically. Paging is a
|
||||
// property, not a ListModel, so a desktop-entry change recomputes cleanly.
|
||||
readonly property var apps: {
|
||||
const all = AppSearch.list.slice()
|
||||
.filter(app => app?.name)
|
||||
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
||||
return all;
|
||||
}
|
||||
readonly property var pages: {
|
||||
const result = [];
|
||||
for (let i = 0; i < root.apps.length; i += root.perPage)
|
||||
result.push(root.apps.slice(i, i + root.perPage));
|
||||
return result;
|
||||
}
|
||||
readonly property int pageCount: root.pages.length
|
||||
|
||||
// How far open, 0..1 — the same single-progress idiom WindowOverview uses
|
||||
// so this surface reads as arriving, not appearing. The overview's own
|
||||
// gate; mission control drives its own surface.
|
||||
property real progress: GlobalStates.overviewOpen ? 1 : 0
|
||||
Behavior on progress {
|
||||
NumberAnimation {
|
||||
duration: 260
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
transform: Translate { y: (1 - root.progress) * 24 }
|
||||
opacity: root.progress
|
||||
|
||||
// Nothing installed worth showing. Kept distinct from a broken grid on
|
||||
// purpose — the same silence-was-the-bug discipline WindowOverview's
|
||||
// "No open windows" label exists for.
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
visible: root.apps.length === 0
|
||||
text: qsTr("No apps")
|
||||
opacity: 0.6
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: list
|
||||
anchors.fill: parent
|
||||
// Room for the page dots below the grid.
|
||||
anchors.bottomMargin: 22
|
||||
// Paged horizontally, per TASK-14 ("paged"), matching the task
|
||||
// surface's axis so the thumb does one learned motion for both.
|
||||
orientation: ListView.Horizontal
|
||||
snapMode: ListView.SnapOneItem
|
||||
highlightRangeMode: ListView.StrictlyEnforceRange
|
||||
preferredHighlightBegin: 0
|
||||
preferredHighlightEnd: width
|
||||
spacing: 0
|
||||
clip: true
|
||||
visible: root.apps.length > 0
|
||||
model: root.pages
|
||||
|
||||
// The page the strip is on, live (not ListView.currentIndex, which
|
||||
// only follows keyboard/programmatic selection); feeds the dots.
|
||||
readonly property int pageIndex: Math.max(0, Math.min(root.pageCount - 1,
|
||||
Math.round(list.contentX / list.width)))
|
||||
|
||||
delegate: Item {
|
||||
id: page
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: list.width
|
||||
height: list.height
|
||||
|
||||
readonly property real tileW: (width - root.pageMargin * 2
|
||||
- root.cellSpacing * (root.columns - 1)) / root.columns
|
||||
readonly property real tileH: (height - root.cellSpacing * (root.rows - 1)) / root.rows
|
||||
|
||||
Grid {
|
||||
anchors.fill: parent
|
||||
anchors.margins: root.pageMargin
|
||||
columns: root.columns
|
||||
rows: root.rows
|
||||
columnSpacing: root.cellSpacing
|
||||
rowSpacing: root.cellSpacing
|
||||
|
||||
Repeater {
|
||||
model: page.modelData
|
||||
delegate: RippleButton {
|
||||
required property var modelData
|
||||
implicitWidth: page.tileW
|
||||
implicitHeight: page.tileH
|
||||
buttonRadius: Appearance?.rounding?.normal ?? 12
|
||||
// Launch closes the drawer; the tapped app takes the
|
||||
// screen. Same close-before-launch order SearchItem
|
||||
// uses — the entry must not execute into an overview
|
||||
// still covering the monitor.
|
||||
onClicked: {
|
||||
GlobalStates.overviewOpen = false;
|
||||
modelData.execute();
|
||||
}
|
||||
contentItem: Column {
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: 12
|
||||
spacing: 6
|
||||
|
||||
IconImage {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
source: Quickshell.iconPath(modelData.icon, "image-missing")
|
||||
implicitWidth: root.iconSize
|
||||
implicitHeight: root.iconSize
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignTop
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
text: modelData.name
|
||||
font.pixelSize: Appearance?.font?.pixelSize?.small ?? 12
|
||||
color: Appearance?.colors?.colOnLayer0 ?? "#fff"
|
||||
opacity: 0.85
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Page dots. One per page, the current one in the accent; only shown when
|
||||
// there is more than one page (a single dot that cannot move is noise).
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 4
|
||||
visible: root.pageCount > 1
|
||||
spacing: 6
|
||||
Repeater {
|
||||
model: root.pageCount
|
||||
delegate: Rectangle {
|
||||
required property int index
|
||||
width: 7
|
||||
height: 7
|
||||
radius: width / 2
|
||||
color: index === list.pageIndex
|
||||
? (Appearance?.colors?.colPrimary ?? "#a0c8ff")
|
||||
: "#66ffffff"
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
// Pixel3Arch patch to ii's stock Overview.qml (2026-07-07): opening the
|
||||
// search/overview automatically brings up the on-screen keyboard
|
||||
// (GlobalStates.oskOpen), and closing it closes the keyboard too.
|
||||
// Pixel3Arch patch to ii's stock Overview.qml. History:
|
||||
//
|
||||
// Also: while the OSK is open, this panel does NOT register with
|
||||
// 2026-07-07 — opened the search/overview with the on-screen keyboard
|
||||
// (GlobalStates.oskOpen) and closed it with the keyboard too.
|
||||
// 2026-08-05 — finesse pass (TASK-14): the keyboard no longer auto-raises
|
||||
// with the drawer; it belongs to the search pane and is summoned by tapping
|
||||
// it (see oskSummoner). The drawer gained a translucent theme sheet
|
||||
// (panelBg), the dock is suppressed while it is open, and both bodies fill
|
||||
// 0.78 of the panel height instead of 0.7.
|
||||
//
|
||||
// While the OSK is open, this panel does NOT register with
|
||||
// GlobalFocusGrab.addDismissable — see the onOskOpenChanged handler below.
|
||||
// Reason: GlobalFocusGrab's HyprlandFocusGrab (hyprland_focus_grab_v1, a
|
||||
// real Wayland protocol) clears whenever a tap lands outside its
|
||||
|
|
@ -21,6 +27,7 @@ import qs
|
|||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions as CF
|
||||
import Qt.labs.synchronizer
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
|
@ -34,6 +41,12 @@ import qs.modules.souveraine.navigation
|
|||
Scope {
|
||||
id: overviewScope
|
||||
property bool dontAutoCancelSearch: false
|
||||
// Whether panelWindow is currently registered with GlobalFocusGrab so an
|
||||
// outside tap dismisses the drawer. Gated on the OSK (see onOskOpenChanged)
|
||||
// and deliberately tracked rather than add/remove blind: adding the same
|
||||
// window twice is the double-add race the registration comment below warns
|
||||
// about, and re-opening the drawer while already registered would re-add.
|
||||
property bool panelDismissableRegistered: false
|
||||
|
||||
PanelWindow {
|
||||
id: panelWindow
|
||||
|
|
@ -60,7 +73,13 @@ Scope {
|
|||
color: "transparent"
|
||||
|
||||
mask: Region {
|
||||
item: (GlobalStates.overviewOpen || GlobalStates.missionControlOpen) ? columnLayout : null
|
||||
// The window only exists where the drawer does: panelBg (which
|
||||
// wraps the column with a breathing margin) is the drawn surface
|
||||
// AND the input region. This replaced `columnLayout` when the
|
||||
// panel sheet was added — the mask must cover what panelBg
|
||||
// covers, or the sheet renders clipped and its margin is dead
|
||||
// input space.
|
||||
item: panelBg
|
||||
}
|
||||
|
||||
anchors {
|
||||
|
|
@ -78,36 +97,72 @@ Scope {
|
|||
overviewScope.dontAutoCancelSearch = false;
|
||||
GlobalFocusGrab.dismiss();
|
||||
GlobalStates.oskOpen = false;
|
||||
// Undo the drawer-time suppression (see below) so the dock
|
||||
// returns to its pre-drawer posture: its own swipe-down
|
||||
// state, or the empty-desktop reveal it had coming.
|
||||
GlobalStates.dockSuppressed = false;
|
||||
} else {
|
||||
if (!overviewScope.dontAutoCancelSearch) {
|
||||
searchWidget.cancelSearch();
|
||||
}
|
||||
// Search wants text input — bring up the on-screen
|
||||
// keyboard automatically (Pixel3Arch, 2026-07-07). See
|
||||
// overlays/quickshell-ii-patches/OnScreenKeyboard.qml.
|
||||
// This also enters oskOpen=true, so the dismissable
|
||||
// registration below (onOskOpenChanged) intentionally
|
||||
// does NOT add panelWindow here — it's added only once
|
||||
// oskOpen settles, to avoid a double-add race.
|
||||
GlobalStates.oskOpen = true;
|
||||
// Keyboard-less on open — 2026-08-05: the OSK no longer
|
||||
// auto-raises with the drawer. The keyboard belongs to the
|
||||
// search pane: tapping it summons the OSK (see oskSummoner
|
||||
// below), tapping anything else doesn't. The field still
|
||||
// auto-focuses so the first tap has somewhere to go.
|
||||
GlobalStates.oskOpen = false;
|
||||
// No OSK: the drawer is dismissable by outside tap, and it
|
||||
// must register right here — onOskOpenChanged only fires on
|
||||
// a keyboard toggle, which no longer happens on open.
|
||||
if (!overviewScope.panelDismissableRegistered) {
|
||||
GlobalFocusGrab.addDismissable(panelWindow);
|
||||
overviewScope.panelDismissableRegistered = true;
|
||||
}
|
||||
// The drawer takes the space, so the dock goes down while
|
||||
// it is open — and suppression, not dockRevealed=false:
|
||||
// the dock is pinned on the phone, and a pinned dock
|
||||
// ignores dockRevealed entirely (Dock.qml computeDockState:
|
||||
// only dockSuppressed beats effectivePinned). The overview
|
||||
// button lives on the dock, which is why the drawer needs
|
||||
// other doors in (pill swipe home / IPC). The close branch
|
||||
// above restores the flag.
|
||||
GlobalStates.dockSuppressed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Registering as dismissable is gated on the OSK's state, not
|
||||
// just overviewOpen — see the file-header comment for why. This
|
||||
// fires both when overview opens (right after oskOpen flips true
|
||||
// above) and whenever the OSK is toggled independently while
|
||||
// overview stays open (e.g. closing the keyboard without closing
|
||||
// search).
|
||||
// just overviewOpen — see the file-header comment for why. The
|
||||
// drawer registers itself in onOverviewOpenChanged (the OSK no
|
||||
// longer flips on open); this handler only re-tracks when the OSK
|
||||
// is toggled independently while the drawer stays open.
|
||||
// One surface at a time. Home and the running-work view are opened by
|
||||
// different gestures, but they are not modes to be stacked: a swipe to
|
||||
// mission control while the drawer is up should replace it, not float
|
||||
// the cards over the grid. Neither flag's writers enforce this, so the
|
||||
// body's owner does.
|
||||
Connections {
|
||||
target: GlobalStates
|
||||
function onOverviewOpenChanged() {
|
||||
if (GlobalStates.overviewOpen)
|
||||
GlobalStates.missionControlOpen = false;
|
||||
}
|
||||
function onMissionControlOpenChanged() {
|
||||
if (GlobalStates.missionControlOpen)
|
||||
GlobalStates.overviewOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: GlobalStates
|
||||
function onOskOpenChanged() {
|
||||
if (!GlobalStates.overviewOpen) return;
|
||||
if (GlobalStates.oskOpen) {
|
||||
GlobalFocusGrab.removeDismissable(panelWindow);
|
||||
} else {
|
||||
overviewScope.panelDismissableRegistered = false;
|
||||
} else if (!overviewScope.panelDismissableRegistered) {
|
||||
GlobalFocusGrab.addDismissable(panelWindow);
|
||||
overviewScope.panelDismissableRegistered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -127,8 +182,8 @@ Scope {
|
|||
}
|
||||
|
||||
// Tap-to-dismiss for the dead space INSIDE the overview page: the
|
||||
// window's input mask only covers columnLayout, and the workspace
|
||||
// grid's gaps (between workspaces, around the grid) consume nothing —
|
||||
// window's input mask only covers the panel sheet (panelBg), and the
|
||||
// grid's gaps (between tiles, around the grid) consume nothing —
|
||||
// taps there used to be silently ignored, which read as the page
|
||||
// being stuck. Declared before (= stacked below) the column, sized to
|
||||
// the overview grid only, so the search bar's padding stays inert and
|
||||
|
|
@ -152,6 +207,33 @@ Scope {
|
|||
}
|
||||
}
|
||||
|
||||
// The drawer's sheet. A solid, slightly translucent layer of the
|
||||
// theme surface behind the search bar and grid — the "blur/look"
|
||||
// finesse item. Deliberately NOT a real GaussianBlur: that needs
|
||||
// Qt5Compat and dies on the phone's GLES-ish backend (2026-08-05),
|
||||
// so this is the phone-safe substitute — an opaque-enough sheet that
|
||||
// the wallpaper reads as a soft base behind it. Declared before the
|
||||
// column (stacked below it) so nothing here can eat taps; it is
|
||||
// input-transparent anyway.
|
||||
Rectangle {
|
||||
id: panelBg
|
||||
visible: columnLayout.visible
|
||||
anchors.fill: columnLayout
|
||||
// The sheet breathes past the column so the drawer reads as a
|
||||
// panel, not as widgets floating on the wallpaper. The mask
|
||||
// follows this same box (see `mask` above).
|
||||
anchors.leftMargin: -18
|
||||
anchors.rightMargin: -18
|
||||
anchors.topMargin: -6
|
||||
anchors.bottomMargin: -14
|
||||
radius: Appearance?.rounding?.large ?? 20
|
||||
// colLayer1 at ~88% — opaque enough that wallpaper detail behind
|
||||
// the grid is noise, translucent enough to still be "surface".
|
||||
color: CF.ColorUtils.transparentize(Appearance?.colors?.colLayer1 ?? "#1a1a1a", 0.12)
|
||||
border.width: 1
|
||||
border.color: CF.ColorUtils.transparentize(Appearance?.colors?.colOnLayer1 ?? "#ffffff", 0.88)
|
||||
}
|
||||
|
||||
Column {
|
||||
id: columnLayout
|
||||
// Both ways in. The panel, the mask and the loader were all moved
|
||||
|
|
@ -191,21 +273,79 @@ Scope {
|
|||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
active: (GlobalStates.overviewOpen || GlobalStates.missionControlOpen)
|
||||
&& (Config?.options.overview.enable ?? true)
|
||||
// Two ways in, two bodies — TASK-14's split. The overview
|
||||
// (Home) is the app drawer: search above (this file's
|
||||
// SearchWidget) and AppGrid below. Mission control, raised by
|
||||
// the pill's second-stage swipe, is the running-work view:
|
||||
// WindowOverview's cards alone, deliberately no search and no
|
||||
// keyboard (see `keyboardFocus` above). Previously this one
|
||||
// body rendered the running cards for BOTH flags, which left
|
||||
// Home showing what is running instead of what can run.
|
||||
sourceComponent: GlobalStates.missionControlOpen
|
||||
? windowOverviewComponent : appGridComponent
|
||||
}
|
||||
|
||||
Component {
|
||||
id: windowOverviewComponent
|
||||
// WindowOverview, not OverviewWidget. The latter draws a grid
|
||||
// of workspaces from HyprlandData; viewtop has neither, so it
|
||||
// rendered an empty frame and read as the overview being
|
||||
// broken. See WindowOverview.qml's header.
|
||||
sourceComponent: WindowOverview {
|
||||
WindowOverview {
|
||||
width: overviewLoader.parent.width
|
||||
height: panelWindow.height * 0.7
|
||||
height: panelWindow.height * 0.78
|
||||
visible: (panelWindow.searchingText == "")
|
||||
onActivated: toplevel => {
|
||||
toplevel?.activate();
|
||||
GlobalStates.overviewOpen = false;
|
||||
GlobalStates.missionControlOpen = false;
|
||||
}
|
||||
onClosed: toplevel => toplevel?.close()
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: appGridComponent
|
||||
// The phone app drawer. TASK-14: a paged grid from the
|
||||
// desktop-entry list, alphabetical; search stays exactly
|
||||
// as-is on top. See AppGrid.qml's header.
|
||||
AppGrid {
|
||||
width: overviewLoader.parent.width
|
||||
height: panelWindow.height * 0.78
|
||||
visible: (panelWindow.searchingText == "")
|
||||
}
|
||||
}
|
||||
}
|
||||
// The search pane is now the keyboard's door (2026-08-05): the OSK
|
||||
// no longer auto-raises with the drawer, it raises when this pane is
|
||||
// TAPPED. This overlay sits above the search bar but outside its
|
||||
// widget tree, so it can add behavior without forking SearchWidget:
|
||||
// it re-focuses the field first (a tap on the grid may have left it)
|
||||
// and only then opens the keyboard — the OSK's input redirection
|
||||
// returns keys to whatever had focus before it opened, so focus must
|
||||
// land BEFORE the keyboard does. The press propagates on afterwards,
|
||||
// so the field still gets its normal tap.
|
||||
MouseArea {
|
||||
id: oskSummoner
|
||||
enabled: GlobalStates.overviewOpen && !GlobalStates.oskOpen
|
||||
visible: columnLayout.visible
|
||||
// searchWidget is inside the column, not a sibling, so anchors
|
||||
// cannot bind it — same idiom as the tap-to-dismiss MouseArea:
|
||||
// geometry in columnLayout-local terms, offset by the column's
|
||||
// own position within the panel.
|
||||
x: columnLayout.x + searchWidget.x
|
||||
y: columnLayout.y + searchWidget.y
|
||||
width: searchWidget.width
|
||||
height: searchWidget.height
|
||||
acceptedButtons: Qt.LeftButton
|
||||
propagateComposedEvents: true
|
||||
onPressed: (mouse) => {
|
||||
if (!GlobalStates.oskOpen) {
|
||||
searchWidget.focusSearchInput();
|
||||
GlobalStates.oskOpen = true;
|
||||
}
|
||||
mouse.accepted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
AppGrid 1.0 AppGrid.qml
|
||||
Overview 1.0 Overview.qml
|
||||
OverviewWidget 1.0 OverviewWidget.qml
|
||||
OverviewWindow 1.0 OverviewWindow.qml
|
||||
|
|
|
|||
80
surfaces/quickshell/modules/settings/OverviewConfig.qml
Normal file
80
surfaces/quickshell/modules/settings/OverviewConfig.qml
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
// Home screen (app drawer) — the TASK-14 split's launcher half. The drawer
|
||||
// reads its grid straight from Config (see AppGrid.qml), so these knobs are
|
||||
// the single source; the grid re-lays-out on the next drawer open. Rows and
|
||||
// columns at the phone's 540x1080: 4 columns keeps a thumb travel across a
|
||||
// page, 5 rows fills the panel without scrolling. The old desktop overview's
|
||||
// rows/columns are untouched — this page owns Config.options.overview.appGrid.
|
||||
|
||||
ContentPage {
|
||||
forceWidth: true
|
||||
|
||||
ContentSection {
|
||||
icon: "grid_view"
|
||||
title: Translation.tr("App drawer")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "apps"
|
||||
text: Translation.tr("Enable drawer")
|
||||
checked: Config.options.overview.enable
|
||||
onCheckedChanged: {
|
||||
Config.options.overview.enable = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("The Home surface: search on top, the app grid below. Off, the pill's swipe-home does nothing.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "view_column"
|
||||
text: Translation.tr("Columns")
|
||||
value: Config.options.overview.appGrid.columns
|
||||
from: 1
|
||||
to: 10
|
||||
stepSize: 1
|
||||
onValueChanged: {
|
||||
Config.options.overview.appGrid.columns = value;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Icons per row. More columns = denser grid and smaller tiles.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "view_agenda"
|
||||
text: Translation.tr("Rows")
|
||||
value: Config.options.overview.appGrid.rows
|
||||
from: 1
|
||||
to: 10
|
||||
stepSize: 1
|
||||
onValueChanged: {
|
||||
Config.options.overview.appGrid.rows = value;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Icon rows per page. More rows fills the screen; fewer leaves room for the page dots.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "photo_size_select_large"
|
||||
text: Translation.tr("Icon size (px)")
|
||||
value: Config.options.overview.appGrid.iconSize
|
||||
from: 24
|
||||
to: 96
|
||||
stepSize: 4
|
||||
onValueChanged: {
|
||||
Config.options.overview.appGrid.iconSize = value;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("The icon glyph itself; labels always scale to the tile.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ KeyboardConfig 1.0 KeyboardConfig.qml
|
|||
LockConfig 1.0 LockConfig.qml
|
||||
NavigationConfig 1.0 NavigationConfig.qml
|
||||
NetworkConfig 1.0 NetworkConfig.qml
|
||||
OverviewConfig 1.0 OverviewConfig.qml
|
||||
QuickConfig 1.0 QuickConfig.qml
|
||||
ServicesConfig 1.0 ServicesConfig.qml
|
||||
SettingsHome 1.0 SettingsHome.qml
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
// One circular verb on the window action sheet.
|
||||
//
|
||||
// Sized against the selection chip's failure rather than against a desktop
|
||||
// idea of a button: 76 px of circle and a 34 px glyph, because the chip
|
||||
// shipped at 44 px with 18-20 px icons and was "completely untappable" on
|
||||
// device, twice. Android's own floor for a touch target is 48 dp and that is
|
||||
// a *minimum*, not a target, for a control the hand reaches for mid-gesture.
|
||||
//
|
||||
// The hold is optional and only Close uses it. It is a real press-and-hold
|
||||
// rather than a second button because the polite and forceful versions of
|
||||
// "close this" are one intent at two levels of insistence — and a separate
|
||||
// always-kill button is one a hurried thumb presses meaning the safe one.
|
||||
// The ring filling is the whole feedback story: a hold you only learn about
|
||||
// on release cannot be abandoned halfway.
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string icon: ""
|
||||
property string label: ""
|
||||
property string holdLabel: ""
|
||||
// 0 disables the hold entirely, which is the default: most verbs have no
|
||||
// second level and should not appear to have one.
|
||||
property int holdMs: 0
|
||||
|
||||
signal tapped
|
||||
signal held
|
||||
|
||||
readonly property int diameter: 76
|
||||
|
||||
implicitWidth: diameter
|
||||
implicitHeight: diameter + 34
|
||||
|
||||
Rectangle {
|
||||
id: circle
|
||||
width: root.diameter
|
||||
height: root.diameter
|
||||
radius: width / 2
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: area.pressed
|
||||
? Appearance.colors.colLayer2
|
||||
: Appearance.colors.colLayer1
|
||||
border.width: 1
|
||||
// `colLayer1Active`, not `colLayer1Inactive` — the latter does not
|
||||
// exist in Appearance and resolves to undefined, which Qt reports as
|
||||
// "Unable to assign [undefined] to QColor" and then draws borderless.
|
||||
// SubconsciousEventPanel.qml has the same typo and has been warning
|
||||
// quietly ever since.
|
||||
border.color: Appearance.colors.colLayer1Active
|
||||
|
||||
scale: area.pressed ? 0.94 : 1.0
|
||||
Behavior on scale { NumberAnimation { duration: 90 } }
|
||||
Behavior on color { ColorAnimation { duration: 90 } }
|
||||
|
||||
MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
text: root.icon
|
||||
iconSize: 34
|
||||
color: Appearance.colors.colOnLayer1
|
||||
}
|
||||
|
||||
// The hold's progress, drawn as a ring that closes. Only appears once
|
||||
// a hold is actually running, so a tap never flashes it.
|
||||
Canvas {
|
||||
id: ring
|
||||
anchors.fill: parent
|
||||
visible: root.holdMs > 0 && hold.running
|
||||
property real progress: 0
|
||||
|
||||
onProgressChanged: requestPaint()
|
||||
onPaint: {
|
||||
const ctx = getContext("2d");
|
||||
ctx.reset();
|
||||
const r = width / 2 - 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(width / 2, height / 2, r, -Math.PI / 2,
|
||||
-Math.PI / 2 + Math.PI * 2 * ring.progress);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = Appearance.colors.colOnLayer1;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: circle.bottom
|
||||
anchors.topMargin: 8
|
||||
text: hold.running && root.holdLabel !== "" ? root.holdLabel : root.label
|
||||
color: "#e6ffffff"
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
}
|
||||
|
||||
// Drives the ring and, at the end, the forceful verb. Started on press and
|
||||
// stopped on release or on leaving the button, so sliding a thumb off is
|
||||
// an abort rather than a commit.
|
||||
NumberAnimation {
|
||||
id: hold
|
||||
target: ring
|
||||
property: "progress"
|
||||
from: 0
|
||||
to: 1
|
||||
duration: root.holdMs
|
||||
onFinished: {
|
||||
root.heldFired = true;
|
||||
root.held();
|
||||
}
|
||||
}
|
||||
|
||||
property bool heldFired: false
|
||||
|
||||
MouseArea {
|
||||
id: area
|
||||
anchors.fill: parent
|
||||
|
||||
onPressed: {
|
||||
root.heldFired = false;
|
||||
if (root.holdMs > 0)
|
||||
hold.start();
|
||||
}
|
||||
|
||||
onReleased: {
|
||||
if (root.holdMs > 0)
|
||||
hold.stop();
|
||||
// A hold that completed already fired the forceful verb; releasing
|
||||
// afterwards must not also fire the polite one.
|
||||
if (!root.heldFired && containsMouse)
|
||||
root.tapped();
|
||||
ring.progress = 0;
|
||||
}
|
||||
|
||||
onCanceled: {
|
||||
hold.stop();
|
||||
ring.progress = 0;
|
||||
}
|
||||
|
||||
hoverEnabled: true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
// The window action sheet — the hand's verbs on one window.
|
||||
//
|
||||
// TASK-55. Three fingers tapped a window; the compositor named which one, the
|
||||
// machine decided what that means, and this draws the answer. The split is
|
||||
// `gesture.rs`'s own: "the compositor recognises, the shell draws, and the
|
||||
// answer comes back as the ToCompositor intents that already work". This file
|
||||
// is the drawing half, and ViewtopControl is the answering half.
|
||||
//
|
||||
// ## Why a scrim and not a blur
|
||||
//
|
||||
// GaussianBlur fails on the phone's GLES path — the Home drawer hit exactly
|
||||
// this on 2026-08-05 and fell back to a flat translucent panel (`panelBg`).
|
||||
// Repeating a known-broken effect here would trade a working sheet for a
|
||||
// black rectangle on the one device that matters. The scrim reads as "the app
|
||||
// is behind this and paused" without asking the GPU for something it refuses.
|
||||
//
|
||||
// ## Why the buttons are this big
|
||||
//
|
||||
// The selection chip is the house precedent for a floating action surface and
|
||||
// it failed twice on device — "tiny, illegible, not working properly at all" —
|
||||
// at 44 px tall with 18-20 px icons. TASK-55 Q6 makes not inheriting that a
|
||||
// condition of this shipping. So: 76 px circles, 34 px glyphs, 28 px between
|
||||
// them, and the row sits above the vertical centre so a thumb reaching from
|
||||
// the bottom bezel does not cover the window being acted on.
|
||||
//
|
||||
// ## Close, and the floor under it
|
||||
//
|
||||
// A tap is `close` — the protocol asking, which a client may refuse or answer
|
||||
// with a save-your-work prompt. A press-and-hold is `kill`, which always
|
||||
// works and loses unsaved work. Two verbs on one button because they are the
|
||||
// same intent at two levels of insistence, and because a separate always-kill
|
||||
// button would get pressed by someone who meant the polite one.
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
Scope {
|
||||
id: scope
|
||||
|
||||
// The window this sheet is about. 0 is "none" — the sheet is never open
|
||||
// without a subject, which is what keeps the no-window case from becoming
|
||||
// a blank sheet nobody can explain.
|
||||
property int target: 0
|
||||
property bool sheetOpen: false
|
||||
|
||||
// How long a hold on Close means kill. Longer than a tap could ever be,
|
||||
// short enough that a stuck app does not feel like a negotiation.
|
||||
readonly property int killHoldMs: 1000
|
||||
|
||||
function openFor(id) {
|
||||
if (!id || id <= 0) {
|
||||
console.log("[window-sheet] refusing to open without a target");
|
||||
return;
|
||||
}
|
||||
scope.target = id;
|
||||
scope.sheetOpen = true;
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
scope.sheetOpen = false;
|
||||
scope.target = 0;
|
||||
}
|
||||
|
||||
// sessiond's `Action::WindowSheet` lands here: `qs -c souveraine ipc call
|
||||
// windowSheet open <id>`. The id arrives as a string because the executor
|
||||
// builds a command line; parsing is this side's job.
|
||||
IpcHandler {
|
||||
target: "windowSheet"
|
||||
|
||||
function open(id: string): void {
|
||||
scope.openFor(parseInt(id, 10));
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
scope.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal the hand can see. `close` is a request, and a client that says
|
||||
// no must not look like a button that did nothing — that silence is the
|
||||
// complaint this whole task came from.
|
||||
Connections {
|
||||
target: ViewtopControl
|
||||
function onRefused(intent, reason) {
|
||||
if (!scope.sheetOpen)
|
||||
return;
|
||||
refusal.text = intent + " refused: " + reason;
|
||||
refusal.opacity = 1;
|
||||
refusalFade.restart();
|
||||
}
|
||||
function onSucceeded(intent) {
|
||||
// The window is gone or moved; the sheet has nothing left to be
|
||||
// about. Kept open for `pose`-style verbs would mean a sheet
|
||||
// pointing at a window that is no longer where it was.
|
||||
if (intent === "close" || intent === "kill" || intent === "place")
|
||||
scope.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
required property var modelData
|
||||
screen: win.modelData
|
||||
|
||||
anchors { top: true; left: true; right: true; bottom: true }
|
||||
color: "transparent"
|
||||
visible: scope.sheetOpen
|
||||
WlrLayershell.namespace: "souveraine:windowsheet"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
// OnDemand rather than Exclusive: an exclusive grab on the polkit
|
||||
// surface stopped touch reaching the layer below it entirely
|
||||
// (2026-07-26), and this surface must not repeat that.
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// The scrim. Also the outside-tap target: dismissal by tapping away
|
||||
// is load-bearing on a phone, and it is the only exit that needs no
|
||||
// explanation.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "#99000000"
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: scope.dismiss()
|
||||
}
|
||||
|
||||
Behavior on opacity { NumberAnimation { duration: 120 } }
|
||||
}
|
||||
|
||||
// The row. Above centre so a thumb does not cover the window.
|
||||
RowLayout {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: parent.height * 0.38
|
||||
spacing: 28
|
||||
|
||||
SheetButton {
|
||||
icon: "open_in_full"
|
||||
label: "Resize"
|
||||
onTapped: {
|
||||
// Half height, full width, parked at the top — the
|
||||
// `place` path, and the one geometry that is always
|
||||
// legal on a phone. A drag-to-size handle is the real
|
||||
// answer and it needs a gesture of its own; this is the
|
||||
// verb reaching the window, which is what was missing.
|
||||
ViewtopControl.place(scope.target, 0, 0, win.width, win.height / 2);
|
||||
}
|
||||
}
|
||||
|
||||
SheetButton {
|
||||
icon: "splitscreen"
|
||||
label: "Split"
|
||||
// Split pins this window to one half and the other half
|
||||
// wants a chooser — open apps from the overview, or the
|
||||
// app list. That chooser is the missing half and it has no
|
||||
// verb yet, so this places the window and says so rather
|
||||
// than pretending the flow is finished.
|
||||
onTapped: {
|
||||
ViewtopControl.place(scope.target, 0, 0, win.width, win.height / 2);
|
||||
refusal.text = "pinned to the top half — chooser for the other half is not built yet";
|
||||
refusal.opacity = 1;
|
||||
refusalFade.restart();
|
||||
}
|
||||
}
|
||||
|
||||
SheetButton {
|
||||
icon: "close"
|
||||
label: "Close"
|
||||
holdLabel: "hold to force"
|
||||
holdMs: scope.killHoldMs
|
||||
onTapped: ViewtopControl.close(scope.target)
|
||||
onHeld: ViewtopControl.kill(scope.target)
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: refusal
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: parent.height * 0.38 + 150
|
||||
width: parent.width * 0.8
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
color: "#e6ffffff"
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
opacity: 0
|
||||
text: ""
|
||||
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
|
||||
Timer {
|
||||
id: refusalFade
|
||||
interval: 2600
|
||||
onTriggered: refusal.opacity = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
SheetButton 1.0 SheetButton.qml
|
||||
WindowSheet 1.0 WindowSheet.qml
|
||||
|
|
@ -29,6 +29,7 @@ import qs.modules.ii.wallpaperSelector
|
|||
import qs.modules.souveraine.dial
|
||||
import qs.modules.souveraine.navigation
|
||||
import qs.modules.souveraine.selection
|
||||
import qs.modules.souveraine.windowSheet
|
||||
|
||||
// The Souveraine panel family — one family for both modes.
|
||||
// Starts at exact panel parity with IllogicalImpulseFamily (our overridden
|
||||
|
|
@ -80,6 +81,10 @@ Scope {
|
|||
PanelLoader { component: Overview {} }
|
||||
PanelLoader { component: Polkit {} }
|
||||
PanelLoader { component: DialHost {} }
|
||||
// The three-finger tap's surface (TASK-55). Ungated: it is the only way
|
||||
// the hand can close a window under viewtop, and the dial's own "Kill
|
||||
// window" is a dead `hyprctl dispatch`.
|
||||
PanelLoader { component: WindowSheet {} }
|
||||
// Text-selection action menu (TASK-18). Gated on the opt-in config: the
|
||||
// service behind it watches every selection on the device, so it must not
|
||||
// load by default. See Config.options.selection.
|
||||
|
|
|
|||
174
surfaces/quickshell/services/ViewtopControl.qml
Normal file
174
surfaces/quickshell/services/ViewtopControl.qml
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// The shell's door into viewtop's control socket.
|
||||
//
|
||||
// The compositor serves one JSON object per line — `{"op":…}` in,
|
||||
// `{"ok":true,…}` or `{"ok":false,"code":…,"reason":…}` back — the same house
|
||||
// grammar sessiond speaks, so this is shaped like SessiondPolicy rather than
|
||||
// inventing a second idea of what talking to a daemon looks like.
|
||||
//
|
||||
// **This is the only place the shell addresses the scene.** Every window verb
|
||||
// the hand can reach — close, kill, place, pose, raise, focus — goes through
|
||||
// `scene()`. The alternative is what the dial does today: `hyprctl dispatch`
|
||||
// baked into a surface, which stopped existing under viewtop and took the
|
||||
// dial's "Kill window" with it. A verb spelled out inside a widget is a verb
|
||||
// that dies when the compositor changes.
|
||||
//
|
||||
// A short-lived connection per request, deliberately. viewtop's socket is
|
||||
// request/response and holds no session; there is no heartbeat to protect here
|
||||
// the way SessiondBridge's EOF is load-bearing, so nothing is gained by
|
||||
// keeping it open and a dropped long-lived socket would need reconnect logic
|
||||
// to be correct.
|
||||
//
|
||||
// Failures are LOUD. A window verb that silently did nothing is exactly the
|
||||
// bug this exists to close: `overview toggle` shelled out to a handler that
|
||||
// did not exist and the tap did nothing, silently, for weeks.
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// The last refusal, for a surface that wants to show one. `close` is a
|
||||
// request the client may refuse; the hand deserves to see that rather than
|
||||
// watch nothing happen.
|
||||
property string lastError: ""
|
||||
|
||||
signal refused(string intent, string reason)
|
||||
signal succeeded(string intent)
|
||||
|
||||
// Requests waiting on a connection. A queue rather than SessiondPolicy's
|
||||
// single slot: the sheet can fire two verbs in a row (kill after a close
|
||||
// that was refused), and dropping the second would be silent.
|
||||
property var _queue: []
|
||||
property var _inflight: null
|
||||
|
||||
function _send(msg) {
|
||||
root._queue.push(msg);
|
||||
if (sock.connected)
|
||||
root._drain();
|
||||
else
|
||||
sock.connected = true;
|
||||
}
|
||||
|
||||
function _drain() {
|
||||
if (root._inflight !== null || root._queue.length === 0)
|
||||
return;
|
||||
root._inflight = root._queue.shift();
|
||||
sock.write(JSON.stringify(root._inflight) + "\n");
|
||||
}
|
||||
|
||||
// Address the scene. `intent` is one of the compositor's own — the table it
|
||||
// returns from `{"op":"describe"}` is authoritative, and this deliberately
|
||||
// does not keep a second copy of it to validate against.
|
||||
function scene(intent, args) {
|
||||
const msg = { op: "scene", intent: intent };
|
||||
for (const k in args)
|
||||
msg[k] = args[k];
|
||||
root._send(msg);
|
||||
}
|
||||
|
||||
// Ask the client to close. It may refuse or prompt; that is the protocol,
|
||||
// not a bug, and `refused` carries it.
|
||||
function close(id) {
|
||||
root.scene("close", { id: id });
|
||||
}
|
||||
|
||||
// End it regardless. The floor under close(), for a client that is hung or
|
||||
// says no — "two apps and no way to turn them off" is what this answers.
|
||||
// Unsaved work is lost, so a surface offering this should mean it.
|
||||
function kill(id) {
|
||||
root.scene("kill", { id: id });
|
||||
}
|
||||
|
||||
// Position and size a window. The shell owns layout; the compositor owns
|
||||
// whether a placement is legal and will refuse one that is not.
|
||||
function place(id, x, y, width, height) {
|
||||
root.scene("place", {
|
||||
id: id,
|
||||
at: { x: x, y: y },
|
||||
size: { width: width, height: height }
|
||||
});
|
||||
}
|
||||
|
||||
// Give a window back to the layout.
|
||||
function unplace(id) {
|
||||
root.scene("unplace", { id: id });
|
||||
}
|
||||
|
||||
// Compose a window's visual geometry — the verb that makes a live app a
|
||||
// scaled, floating, still-touchable thing rather than a picture of one.
|
||||
// The compositor maps input back through the inverse, so a shrunken window
|
||||
// still receives touch where it is drawn.
|
||||
function pose(id, scale, rotation, anchorX, anchorY) {
|
||||
root.scene("pose", {
|
||||
id: id,
|
||||
scale: scale,
|
||||
rotation: rotation ?? 0.0,
|
||||
anchor: { x: anchorX ?? 0.5, y: anchorY ?? 0.5 }
|
||||
});
|
||||
}
|
||||
|
||||
function raise(id) {
|
||||
root.scene("raise", { id: id });
|
||||
}
|
||||
|
||||
function focus(id) {
|
||||
root.scene("focus", { id: id });
|
||||
}
|
||||
|
||||
Socket {
|
||||
id: sock
|
||||
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/viewtop.sock"
|
||||
|
||||
onConnectionStateChanged: {
|
||||
if (connected) {
|
||||
root._drain();
|
||||
} else if (root._inflight !== null || root._queue.length > 0) {
|
||||
const intent = (root._inflight && root._inflight.intent) || "?";
|
||||
root.lastError = "viewtop socket unavailable";
|
||||
console.error("[viewtop-control] could not reach the compositor at "
|
||||
+ sock.path + " — the scene was NOT changed");
|
||||
root._inflight = null;
|
||||
root._queue = [];
|
||||
root.refused(intent, root.lastError);
|
||||
}
|
||||
}
|
||||
|
||||
parser: SplitParser {
|
||||
splitMarker: "\n"
|
||||
onRead: message => {
|
||||
const sent = root._inflight;
|
||||
const intent = (sent && sent.intent) || "?";
|
||||
root._inflight = null;
|
||||
|
||||
let reply;
|
||||
try {
|
||||
reply = JSON.parse(message);
|
||||
} catch (e) {
|
||||
console.error("[viewtop-control] unparseable reply: " + message);
|
||||
root.lastError = "unparseable reply";
|
||||
root.refused(intent, root.lastError);
|
||||
root._drain();
|
||||
return;
|
||||
}
|
||||
|
||||
if (reply.ok !== true) {
|
||||
// `gone` is the ordinary one: the window closed between the
|
||||
// sheet opening and the button being pressed. Still a
|
||||
// refusal, still surfaced, because a sheet acting on a dead
|
||||
// id should say so rather than appear to work.
|
||||
const why = reply.reason || reply.code || "refused without a reason";
|
||||
console.log("[viewtop-control] " + intent + " refused: " + why);
|
||||
root.lastError = why;
|
||||
root.refused(intent, why);
|
||||
} else {
|
||||
root.lastError = "";
|
||||
root.succeeded(intent);
|
||||
}
|
||||
root._drain();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,7 @@ singleton Todo 1.0 Todo.qml
|
|||
singleton Translation 1.0 Translation.qml
|
||||
singleton TrayService 1.0 TrayService.qml
|
||||
singleton Updates 1.0 Updates.qml
|
||||
singleton ViewtopControl 1.0 ViewtopControl.qml
|
||||
singleton WallpaperAssets 1.0 WallpaperAssets.qml
|
||||
singleton WallpaperDownload 1.0 WallpaperDownload.qml
|
||||
singleton Wallpapers 1.0 Wallpapers.qml
|
||||
|
|
|
|||
|
|
@ -70,6 +70,11 @@ ApplicationWindow {
|
|||
icon: "wallpaper",
|
||||
component: "modules/settings/WallpaperConfig.qml"
|
||||
},
|
||||
{
|
||||
name: Translation.tr("Home screen"),
|
||||
icon: "apps",
|
||||
component: "modules/settings/OverviewConfig.qml"
|
||||
},
|
||||
{
|
||||
name: Translation.tr("Dock"),
|
||||
icon: "dock_to_bottom",
|
||||
|
|
|
|||
Loading…
Reference in a new issue