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
|
/// `LOCK-DPMS-LESSONS.md` §7 says it outright — "wake sources move the
|
||||||
/// panel, only sessiond moves the lock".
|
/// panel, only sessiond moves the lock".
|
||||||
Unblank,
|
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
|
/// A touch gesture bound the way every other control is: viewtop reports
|
||||||
/// what the fingers did, the machine decides what it means, and the
|
/// 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
|
/// The compositor has to do the *recognition* because contacts only exist
|
||||||
/// there, which is why this arrives already named rather than as raw
|
/// there, which is why this arrives already named rather than as raw
|
||||||
/// touches. What it must not do is decide what a name means.
|
/// 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
|
/// Request the session lock, because something wants the panel dark and
|
||||||
/// the session is not locked yet.
|
/// 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
|
/// chose is worse than one that fires nothing, and the binding table this
|
||||||
/// is a placeholder for is `DeviceStatePolicy`'s to hold — persisted and
|
/// is a placeholder for is `DeviceStatePolicy`'s to hold — persisted and
|
||||||
/// agent-writable, so a triple-tap can be re-pointed without a rebuild.
|
/// 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> {
|
pub fn touch_gesture(
|
||||||
let bound = fingers == 3 && matches!(gesture, TouchGesture::Tap);
|
&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(
|
self.record_decision(
|
||||||
"touch-gesture",
|
"touch-gesture",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"fingers": fingers,
|
"fingers": fingers,
|
||||||
"gesture": gesture.as_str(),
|
"gesture": gesture.as_str(),
|
||||||
|
"target": target,
|
||||||
"bound": bound,
|
"bound": bound,
|
||||||
}),
|
}),
|
||||||
"touch gesture recognised",
|
"touch gesture recognised",
|
||||||
|
|
@ -1728,14 +1748,20 @@ impl DeviceStateMachine {
|
||||||
if !bound {
|
if !bound {
|
||||||
return Vec::new();
|
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
|
// 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
|
// exist to prevent. A tap on a dark panel is a wake, and that is the
|
||||||
// power button's business, not this one's.
|
// power button's business, not this one's.
|
||||||
if !self.panel_on {
|
if !self.panel_on {
|
||||||
return Vec::new();
|
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> {
|
pub fn tick_at(&mut self, now: Instant) -> Vec<Action> {
|
||||||
|
|
@ -3205,10 +3231,19 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 (mut sm, _t0) = unlocked_and_lit();
|
||||||
let actions = sm.touch_gesture(3, TouchGesture::Tap);
|
let actions = sm.touch_gesture(3, TouchGesture::Tap, Some(7));
|
||||||
assert_eq!(actions, vec![Action::Overview]);
|
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]
|
#[test]
|
||||||
|
|
@ -3223,24 +3258,25 @@ mod tests {
|
||||||
TouchGesture::SwipeLeft,
|
TouchGesture::SwipeLeft,
|
||||||
TouchGesture::SwipeRight,
|
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] {
|
for fingers in [1u8, 2, 4, 5] {
|
||||||
assert!(
|
assert!(
|
||||||
sm.touch_gesture(fingers, TouchGesture::Tap).is_empty(),
|
sm.touch_gesture(fingers, TouchGesture::Tap, Some(1))
|
||||||
|
.is_empty(),
|
||||||
"{fingers} fingers"
|
"{fingers} fingers"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_overview_does_not_open_on_a_dark_panel() {
|
fn the_sheet_does_not_open_on_a_dark_panel() {
|
||||||
// The overview is content, and content on an unauthenticated glass is
|
// 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
|
// what the disclosure rules exist to prevent. A tap on a dark panel is
|
||||||
// a wake, and that is the power button's business.
|
// a wake, and that is the power button's business.
|
||||||
let (mut sm, _t0) = unlocked_and_lit();
|
let (mut sm, _t0) = unlocked_and_lit();
|
||||||
sm.set_panel(false);
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ pub const VERBS: &[VerbDoc] = &[
|
||||||
mutates: true,
|
mutates: true,
|
||||||
summary: "a recognised touch gesture; the machine decides what it means",
|
summary: "a recognised touch gesture; the machine decides what it means",
|
||||||
refuses: &[RefusalCode::InvalidArgument],
|
refuses: &[RefusalCode::InvalidArgument],
|
||||||
example: r#"{"op":"gesture","fingers":3,"gesture":"tap"}"#,
|
example: r#"{"op":"gesture","fingers":3,"gesture":"tap","target":1}"#,
|
||||||
},
|
},
|
||||||
VerbDoc {
|
VerbDoc {
|
||||||
op: "panel",
|
op: "panel",
|
||||||
|
|
@ -315,7 +315,20 @@ pub enum Request {
|
||||||
/// recognise them. The line §12 draws still holds — the compositor names
|
/// recognise them. The line §12 draws still holds — the compositor names
|
||||||
/// what the fingers did and the machine decides what it means. A compositor
|
/// what the fingers did and the machine decides what it means. A compositor
|
||||||
/// that both recognised and acted would be the eighth blind actor.
|
/// 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
|
/// 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"
|
/// 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
|
/// 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;
|
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 {
|
let (program, args, label): (&str, Vec<&str>, &str) = match action {
|
||||||
// sessiond guarantees this runs once per dim, which is what the old
|
// sessiond guarantees this runs once per dim, which is what the old
|
||||||
// hypridle `-s`/`-r` listener could not: a second save while already
|
// 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%-"],
|
vec!["set-volume", "@DEFAULT_AUDIO_SINK@", "5%-"],
|
||||||
"volume-down",
|
"volume-down",
|
||||||
),
|
),
|
||||||
// The shell owns the overview; the machine owns what opens it. Same
|
// The shell owns the sheet; the machine owns what opens it, and for
|
||||||
// shape as every other row here — a named tool with named arguments,
|
// which window. Same shape as every other row — a named tool with
|
||||||
// one place, in the trail.
|
// named arguments, one place, in the trail — except that the window is
|
||||||
Action::Overview => (
|
// an argument, because a sheet with no subject is the bug this replaced.
|
||||||
"qs",
|
Action::WindowSheet { target } => {
|
||||||
vec!["-c", "souveraine", "ipc", "call", "overview", "toggle"],
|
sheet_target = target.to_string();
|
||||||
"overview-toggle",
|
(
|
||||||
),
|
"qs",
|
||||||
|
vec![
|
||||||
|
"-c",
|
||||||
|
"souveraine",
|
||||||
|
"ipc",
|
||||||
|
"call",
|
||||||
|
"windowSheet",
|
||||||
|
"open",
|
||||||
|
&sheet_target,
|
||||||
|
],
|
||||||
|
"window-sheet-open",
|
||||||
|
)
|
||||||
|
}
|
||||||
Action::Blank => {
|
Action::Blank => {
|
||||||
// The invariant, enforced where it cannot be reasoned around: the
|
// The invariant, enforced where it cannot be reasoned around: the
|
||||||
// panel does not go dark unless logind says this session is
|
// panel does not go dark unless logind says this session is
|
||||||
|
|
@ -1256,10 +1273,14 @@ fn handle_request(
|
||||||
}
|
}
|
||||||
serde_json::json!({ "ok": true })
|
serde_json::json!({ "ok": true })
|
||||||
}
|
}
|
||||||
Request::Gesture { fingers, gesture } => {
|
Request::Gesture {
|
||||||
|
fingers,
|
||||||
|
gesture,
|
||||||
|
target,
|
||||||
|
} => {
|
||||||
let actions = {
|
let actions = {
|
||||||
let mut d = shared.lock();
|
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();
|
let bound = !actions.is_empty();
|
||||||
for action in actions {
|
for action in actions {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,11 @@ ApplicationWindow {
|
||||||
icon: "wallpaper",
|
icon: "wallpaper",
|
||||||
component: "modules/settings/WallpaperConfig.qml"
|
component: "modules/settings/WallpaperConfig.qml"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: Translation.tr("Home screen"),
|
||||||
|
icon: "apps",
|
||||||
|
component: "modules/settings/OverviewConfig.qml"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: Translation.tr("Dock"),
|
name: Translation.tr("Dock"),
|
||||||
icon: "dock_to_bottom",
|
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/SelectionAction.qml souveraine/modules/souveraine/selection/SelectionAction.qml
|
||||||
modules/souveraine/selection/SelectionSeparator.qml souveraine/modules/souveraine/selection/SelectionSeparator.qml
|
modules/souveraine/selection/SelectionSeparator.qml souveraine/modules/souveraine/selection/SelectionSeparator.qml
|
||||||
modules/souveraine/selection/qmldir souveraine/modules/souveraine/selection/qmldir
|
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/SubconsciousTicker.qml souveraine/modules/souveraine/subconscious/SubconsciousTicker.qml
|
||||||
modules/souveraine/subconscious/SubconsciousEventPanel.qml souveraine/modules/souveraine/subconscious/SubconsciousEventPanel.qml
|
modules/souveraine/subconscious/SubconsciousEventPanel.qml souveraine/modules/souveraine/subconscious/SubconsciousEventPanel.qml
|
||||||
modules/souveraine/subconscious/qmldir souveraine/modules/souveraine/subconscious/qmldir
|
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/functions/Session.qml souveraine/modules/common/functions/Session.qml
|
||||||
modules/common/widgets/ContentPage.qml souveraine/modules/common/widgets/ContentPage.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/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/common/widgets/StyledToolTip.qml souveraine/modules/common/widgets/StyledToolTip.qml
|
||||||
modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml
|
modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml
|
||||||
modules/settings/NetworkConfig.qml souveraine/modules/settings/NetworkConfig.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/SoundConfig.qml souveraine/modules/settings/SoundConfig.qml
|
||||||
modules/settings/LockConfig.qml souveraine/modules/settings/LockConfig.qml
|
modules/settings/LockConfig.qml souveraine/modules/settings/LockConfig.qml
|
||||||
modules/settings/WallpaperConfig.qml souveraine/modules/settings/WallpaperConfig.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/DockConfig.qml souveraine/modules/settings/DockConfig.qml
|
||||||
modules/settings/NavigationConfig.qml souveraine/modules/settings/NavigationConfig.qml
|
modules/settings/NavigationConfig.qml souveraine/modules/settings/NavigationConfig.qml
|
||||||
modules/settings/KeyboardConfig.qml souveraine/modules/settings/KeyboardConfig.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/AppInventory.qml souveraine/modules/ii/appInventory/AppInventory.qml
|
||||||
modules/ii/appInventory/AppInventoryScope.qml souveraine/modules/ii/appInventory/AppInventoryScope.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/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/screenCorners/ScreenCorners.qml souveraine/modules/ii/screenCorners/ScreenCorners.qml
|
||||||
modules/ii/onScreenKeyboard/OnScreenKeyboard.qml souveraine/modules/ii/onScreenKeyboard/OnScreenKeyboard.qml
|
modules/ii/onScreenKeyboard/OnScreenKeyboard.qml souveraine/modules/ii/onScreenKeyboard/OnScreenKeyboard.qml
|
||||||
modules/common/Persistent.qml souveraine/modules/common/Persistent.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 style: "cookie" // Options: "cookie", "digital"
|
||||||
property string styleLocked: "cookie" // Options: "cookie", "digital"
|
property string styleLocked: "cookie" // Options: "cookie", "digital"
|
||||||
property JsonObject cookie: JsonObject {
|
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 aiStyling: false
|
||||||
|
property bool aiPreset: false
|
||||||
property int sides: 14
|
property int sides: 14
|
||||||
property string dialNumberStyle: "full" // Options: "dots" , "numbers", "full" , "none"
|
property string dialNumberStyle: "full" // Options: "dots" , "numbers", "full" , "none"
|
||||||
property string hourHandStyle: "fill" // Options: "classic", "fill", "hollow", "hide"
|
property string hourHandStyle: "fill" // Options: "classic", "fill", "hollow", "hide"
|
||||||
|
|
@ -612,6 +622,15 @@ Singleton {
|
||||||
property bool orderRightLeft: false
|
property bool orderRightLeft: false
|
||||||
property bool orderBottomUp: false
|
property bool orderBottomUp: false
|
||||||
property bool centerIcons: true
|
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 {
|
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
|
// Pixel3Arch patch to ii's stock Overview.qml. History:
|
||||||
// search/overview automatically brings up the on-screen keyboard
|
|
||||||
// (GlobalStates.oskOpen), and closing it closes the keyboard too.
|
|
||||||
//
|
//
|
||||||
// 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.
|
// GlobalFocusGrab.addDismissable — see the onOskOpenChanged handler below.
|
||||||
// Reason: GlobalFocusGrab's HyprlandFocusGrab (hyprland_focus_grab_v1, a
|
// Reason: GlobalFocusGrab's HyprlandFocusGrab (hyprland_focus_grab_v1, a
|
||||||
// real Wayland protocol) clears whenever a tap lands outside its
|
// real Wayland protocol) clears whenever a tap lands outside its
|
||||||
|
|
@ -21,6 +27,7 @@ import qs
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.modules.common
|
import qs.modules.common
|
||||||
import qs.modules.common.widgets
|
import qs.modules.common.widgets
|
||||||
|
import qs.modules.common.functions as CF
|
||||||
import Qt.labs.synchronizer
|
import Qt.labs.synchronizer
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
|
@ -34,6 +41,12 @@ import qs.modules.souveraine.navigation
|
||||||
Scope {
|
Scope {
|
||||||
id: overviewScope
|
id: overviewScope
|
||||||
property bool dontAutoCancelSearch: false
|
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 {
|
PanelWindow {
|
||||||
id: panelWindow
|
id: panelWindow
|
||||||
|
|
@ -60,7 +73,13 @@ Scope {
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
mask: Region {
|
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 {
|
anchors {
|
||||||
|
|
@ -78,36 +97,72 @@ Scope {
|
||||||
overviewScope.dontAutoCancelSearch = false;
|
overviewScope.dontAutoCancelSearch = false;
|
||||||
GlobalFocusGrab.dismiss();
|
GlobalFocusGrab.dismiss();
|
||||||
GlobalStates.oskOpen = false;
|
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 {
|
} else {
|
||||||
if (!overviewScope.dontAutoCancelSearch) {
|
if (!overviewScope.dontAutoCancelSearch) {
|
||||||
searchWidget.cancelSearch();
|
searchWidget.cancelSearch();
|
||||||
}
|
}
|
||||||
// Search wants text input — bring up the on-screen
|
// Keyboard-less on open — 2026-08-05: the OSK no longer
|
||||||
// keyboard automatically (Pixel3Arch, 2026-07-07). See
|
// auto-raises with the drawer. The keyboard belongs to the
|
||||||
// overlays/quickshell-ii-patches/OnScreenKeyboard.qml.
|
// search pane: tapping it summons the OSK (see oskSummoner
|
||||||
// This also enters oskOpen=true, so the dismissable
|
// below), tapping anything else doesn't. The field still
|
||||||
// registration below (onOskOpenChanged) intentionally
|
// auto-focuses so the first tap has somewhere to go.
|
||||||
// does NOT add panelWindow here — it's added only once
|
GlobalStates.oskOpen = false;
|
||||||
// oskOpen settles, to avoid a double-add race.
|
// No OSK: the drawer is dismissable by outside tap, and it
|
||||||
GlobalStates.oskOpen = true;
|
// 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
|
// Registering as dismissable is gated on the OSK's state, not
|
||||||
// just overviewOpen — see the file-header comment for why. This
|
// just overviewOpen — see the file-header comment for why. The
|
||||||
// fires both when overview opens (right after oskOpen flips true
|
// drawer registers itself in onOverviewOpenChanged (the OSK no
|
||||||
// above) and whenever the OSK is toggled independently while
|
// longer flips on open); this handler only re-tracks when the OSK
|
||||||
// overview stays open (e.g. closing the keyboard without closing
|
// is toggled independently while the drawer stays open.
|
||||||
// search).
|
// 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 {
|
Connections {
|
||||||
target: GlobalStates
|
target: GlobalStates
|
||||||
function onOskOpenChanged() {
|
function onOskOpenChanged() {
|
||||||
if (!GlobalStates.overviewOpen) return;
|
if (!GlobalStates.overviewOpen) return;
|
||||||
if (GlobalStates.oskOpen) {
|
if (GlobalStates.oskOpen) {
|
||||||
GlobalFocusGrab.removeDismissable(panelWindow);
|
GlobalFocusGrab.removeDismissable(panelWindow);
|
||||||
} else {
|
overviewScope.panelDismissableRegistered = false;
|
||||||
|
} else if (!overviewScope.panelDismissableRegistered) {
|
||||||
GlobalFocusGrab.addDismissable(panelWindow);
|
GlobalFocusGrab.addDismissable(panelWindow);
|
||||||
|
overviewScope.panelDismissableRegistered = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -127,8 +182,8 @@ Scope {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tap-to-dismiss for the dead space INSIDE the overview page: the
|
// Tap-to-dismiss for the dead space INSIDE the overview page: the
|
||||||
// window's input mask only covers columnLayout, and the workspace
|
// window's input mask only covers the panel sheet (panelBg), and the
|
||||||
// grid's gaps (between workspaces, around the grid) consume nothing —
|
// grid's gaps (between tiles, around the grid) consume nothing —
|
||||||
// taps there used to be silently ignored, which read as the page
|
// taps there used to be silently ignored, which read as the page
|
||||||
// being stuck. Declared before (= stacked below) the column, sized to
|
// being stuck. Declared before (= stacked below) the column, sized to
|
||||||
// the overview grid only, so the search bar's padding stays inert and
|
// 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 {
|
Column {
|
||||||
id: columnLayout
|
id: columnLayout
|
||||||
// Both ways in. The panel, the mask and the loader were all moved
|
// Both ways in. The panel, the mask and the loader were all moved
|
||||||
|
|
@ -191,21 +273,79 @@ Scope {
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
active: (GlobalStates.overviewOpen || GlobalStates.missionControlOpen)
|
active: (GlobalStates.overviewOpen || GlobalStates.missionControlOpen)
|
||||||
&& (Config?.options.overview.enable ?? true)
|
&& (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
|
// WindowOverview, not OverviewWidget. The latter draws a grid
|
||||||
// of workspaces from HyprlandData; viewtop has neither, so it
|
// of workspaces from HyprlandData; viewtop has neither, so it
|
||||||
// rendered an empty frame and read as the overview being
|
// rendered an empty frame and read as the overview being
|
||||||
// broken. See WindowOverview.qml's header.
|
// broken. See WindowOverview.qml's header.
|
||||||
sourceComponent: WindowOverview {
|
WindowOverview {
|
||||||
width: overviewLoader.parent.width
|
width: overviewLoader.parent.width
|
||||||
height: panelWindow.height * 0.7
|
height: panelWindow.height * 0.78
|
||||||
visible: (panelWindow.searchingText == "")
|
visible: (panelWindow.searchingText == "")
|
||||||
onActivated: toplevel => {
|
onActivated: toplevel => {
|
||||||
toplevel?.activate();
|
toplevel?.activate();
|
||||||
GlobalStates.overviewOpen = false;
|
GlobalStates.overviewOpen = false;
|
||||||
|
GlobalStates.missionControlOpen = false;
|
||||||
}
|
}
|
||||||
onClosed: toplevel => toplevel?.close()
|
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
|
Overview 1.0 Overview.qml
|
||||||
OverviewWidget 1.0 OverviewWidget.qml
|
OverviewWidget 1.0 OverviewWidget.qml
|
||||||
OverviewWindow 1.0 OverviewWindow.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
|
LockConfig 1.0 LockConfig.qml
|
||||||
NavigationConfig 1.0 NavigationConfig.qml
|
NavigationConfig 1.0 NavigationConfig.qml
|
||||||
NetworkConfig 1.0 NetworkConfig.qml
|
NetworkConfig 1.0 NetworkConfig.qml
|
||||||
|
OverviewConfig 1.0 OverviewConfig.qml
|
||||||
QuickConfig 1.0 QuickConfig.qml
|
QuickConfig 1.0 QuickConfig.qml
|
||||||
ServicesConfig 1.0 ServicesConfig.qml
|
ServicesConfig 1.0 ServicesConfig.qml
|
||||||
SettingsHome 1.0 SettingsHome.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.dial
|
||||||
import qs.modules.souveraine.navigation
|
import qs.modules.souveraine.navigation
|
||||||
import qs.modules.souveraine.selection
|
import qs.modules.souveraine.selection
|
||||||
|
import qs.modules.souveraine.windowSheet
|
||||||
|
|
||||||
// The Souveraine panel family — one family for both modes.
|
// The Souveraine panel family — one family for both modes.
|
||||||
// Starts at exact panel parity with IllogicalImpulseFamily (our overridden
|
// Starts at exact panel parity with IllogicalImpulseFamily (our overridden
|
||||||
|
|
@ -80,6 +81,10 @@ Scope {
|
||||||
PanelLoader { component: Overview {} }
|
PanelLoader { component: Overview {} }
|
||||||
PanelLoader { component: Polkit {} }
|
PanelLoader { component: Polkit {} }
|
||||||
PanelLoader { component: DialHost {} }
|
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
|
// 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
|
// service behind it watches every selection on the device, so it must not
|
||||||
// load by default. See Config.options.selection.
|
// 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 Translation 1.0 Translation.qml
|
||||||
singleton TrayService 1.0 TrayService.qml
|
singleton TrayService 1.0 TrayService.qml
|
||||||
singleton Updates 1.0 Updates.qml
|
singleton Updates 1.0 Updates.qml
|
||||||
|
singleton ViewtopControl 1.0 ViewtopControl.qml
|
||||||
singleton WallpaperAssets 1.0 WallpaperAssets.qml
|
singleton WallpaperAssets 1.0 WallpaperAssets.qml
|
||||||
singleton WallpaperDownload 1.0 WallpaperDownload.qml
|
singleton WallpaperDownload 1.0 WallpaperDownload.qml
|
||||||
singleton Wallpapers 1.0 Wallpapers.qml
|
singleton Wallpapers 1.0 Wallpapers.qml
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,11 @@ ApplicationWindow {
|
||||||
icon: "wallpaper",
|
icon: "wallpaper",
|
||||||
component: "modules/settings/WallpaperConfig.qml"
|
component: "modules/settings/WallpaperConfig.qml"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: Translation.tr("Home screen"),
|
||||||
|
icon: "apps",
|
||||||
|
component: "modules/settings/OverviewConfig.qml"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: Translation.tr("Dock"),
|
name: Translation.tr("Dock"),
|
||||||
icon: "dock_to_bottom",
|
icon: "dock_to_bottom",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue