Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 100 additions & 22 deletions crates/shell-gtk/src/overview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use std::rc::Rc;

use gtk4 as gtk;
use gtk4::prelude::*;
use gtk4::subclass::prelude::ObjectSubclassIsExt;
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use tuna_shell_host::apps::AppEntry;

Expand Down Expand Up @@ -1171,17 +1172,18 @@ fn grid_hover(widget: &impl IsA<gtk::Widget>, target: String, reflow: Rc<GridRef
let motion = gtk::DropControllerMotion::new();
{
let hover = hover.clone();
motion.connect_enter(move |motion, x, _| {
motion.connect_enter(move |motion, _, _| {
hover.generation.set(hover.generation.get() + 1);
let generation = hover.generation.get();
let x = drop_enter_x(motion);
let width = motion.widget().map_or(0, |w| w.width());
if std::env::var_os("TUNA_GRID_TRACE").is_some() {
eprintln!(
"tuna-shell-gtk: grid hover entered x={x} width={}",
motion.widget().map_or(0, |w| w.width())
);
eprintln!("tuna-shell-gtk: grid hover entered x={x:?} width={width}");
}
let edge =
crate::logic::drop_edge(x, f64::from(motion.widget().map_or(0, |w| w.width())));
// Until the position is known, no edge: motion supplies it.
let edge = x.map_or(crate::logic::DropEdge::OnIcon, |x| {
crate::logic::drop_edge(x, f64::from(width))
});
hover.motion(edge);
if let Some(drop) = motion.drop() {
let hover = hover.clone();
Expand Down Expand Up @@ -1230,41 +1232,96 @@ fn grid_hover(widget: &impl IsA<gtk::Widget>, target: String, reflow: Rc<GridRef
/// flipped by the wheel, a swipe, PageUp/PageDown, the side arrows or
/// the indicators; arrows and indicators only with more than one page.
mod page_hint {
use std::cell::RefCell;

use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk4 as gtk;

/// Children at explicit positions, like GtkFixed, measured by the
/// size request alone. GtkFixed's layout manager measures its
/// children's extents and overrides any measure vfunc, so actors
/// sliding in from outside the clip widened the 10% hint across the
/// grid, where it caught the pointer and turned the page.
#[derive(Default)]
pub struct PageHint;
pub struct PageHint {
pub(super) children: RefCell<Vec<(gtk::Widget, f64, f64)>>,
}

#[glib::object_subclass]
impl ObjectSubclass for PageHint {
const NAME: &'static str = "TunaPageHint";
type Type = super::PageHint;
type ParentType = gtk::Fixed;
type ParentType = gtk::Widget;
}
impl ObjectImpl for PageHint {
fn dispose(&self) {
for (child, _, _) in self.children.take() {
child.unparent();
}
}
}
impl ObjectImpl for PageHint {}
impl WidgetImpl for PageHint {
fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) {
if orientation == gtk::Orientation::Horizontal {
// Animated actors begin outside the clip. Their FixedLayout
// extents must never enlarge the 10% navigation hint.
let width = self.obj().width_request().max(0);
(width, width, -1, -1)
} else {
self.parent_measure(orientation, for_size)
fn measure(&self, _: gtk::Orientation, _: i32) -> (i32, i32, i32, i32) {
// GTK raises both sizes to the size request.
(0, 0, -1, -1)
}

fn size_allocate(&self, _: i32, _: i32, _: i32) {
for (child, x, y) in self.children.borrow().iter() {
let (_, natural) = child.preferred_size();
let at = gtk::graphene::Point::new(*x as f32, *y as f32);
child.allocate(
natural.width(),
natural.height(),
-1,
Some(gtk::gsk::Transform::new().translate(&at)),
);
}
}
}
impl FixedImpl for PageHint {}
}

glib::wrapper! {
pub struct PageHint(ObjectSubclass<page_hint::PageHint>)
@extends gtk::Fixed, gtk::Widget,
@extends gtk::Widget,
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
}

impl PageHint {
fn put(&self, child: &impl IsA<gtk::Widget>, x: f64, y: f64) {
child.set_parent(self);
self.imp()
.children
.borrow_mut()
.push((child.clone().upcast(), x, y));
self.queue_allocate();
}

fn move_(&self, child: &impl IsA<gtk::Widget>, x: f64, y: f64) {
let child = child.upcast_ref::<gtk::Widget>();
if let Some(entry) = self
.imp()
.children
.borrow_mut()
.iter_mut()
.find(|(widget, _, _)| widget == child)
{
(entry.1, entry.2) = (x, y);
}
self.queue_allocate();
}

fn remove(&self, child: &impl IsA<gtk::Widget>) {
let child = child.upcast_ref::<gtk::Widget>();
self.imp()
.children
.borrow_mut()
.retain(|(widget, _, _)| widget != child);
child.unparent();
}
}

fn paged_grid(pages: Vec<gtk::FlowBox>, reflow: Rc<GridReflow>) -> gtk::Widget {
use libadwaita as adw;
let carousel = adw::Carousel::new();
Expand Down Expand Up @@ -1411,10 +1468,14 @@ fn paged_grid(pages: Vec<gtk::FlowBox>, reflow: Rc<GridReflow>) -> gtk::Widget {
let motion = gtk::DropControllerMotion::new();
{
let pager = pager.clone();
motion.connect_enter(move |m, x, _| {
motion.connect_enter(move |m, _, _| {
let width = m.widget().map_or(0, |w| w.width());
pager.begin(width);
pager.motion(x, width);
// A drag entering at an edge bumps it at once, as a motion
// there would; never at the (0, 0) GTK reports for enter.
if let Some(x) = drop_enter_x(m) {
pager.motion(x, width);
}
});
}
{
Expand Down Expand Up @@ -1709,6 +1770,23 @@ fn step_page(carousel: &libadwaita::Carousel, step: i32) {
carousel.scroll_to(&page, true);
}

/// The pointer's x in the widget of `motion` when a drag enters it.
/// GDK emits every drag-enter event at (0, 0) (`gdk_drop_emit_enter_event`),
/// so DropControllerMotion's enter coordinates are not the pointer's;
/// read the drop device's position, which Wayland's wl_data_device.enter
/// set. `None` while that position belongs to another surface.
fn drop_enter_x(motion: &gtk::DropControllerMotion) -> Option<f64> {
let widget = motion.widget()?;
let native = widget.native()?;
let (surface, x, y) = motion.drop()?.device().surface_at_position();
if surface? != native.surface()? {
return None;
}
let (dx, dy) = native.surface_transform();
let point = gtk::graphene::Point::new((x - dx) as f32, (y - dy) as f32);
Some(f64::from(native.compute_point(&widget, &point)?.x()))
}

/// Make `widget` a drag source of `text`, the widget itself as the
/// icon (GNOME drags the tile).
fn drag_source(widget: &impl IsA<gtk::Widget>, text: String) -> gtk::DragSource {
Expand Down
77 changes: 61 additions & 16 deletions scripts/tuna-gtk-shell-proof
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,32 @@ grid_edge() { grid_point "$1" "$2" 4; }
grid_shown() {
jq -e --arg name "$2" --argjson ox "$grid_origin_x" --argjson oy "$grid_origin_y" --argjson width "$WIDTH" --argjson height "$HEIGHT" 'any(.[]; .showing and (.role == "push button" or .role == "button") and .name == $name and .bounds[2] > 0 and .bounds[3] > 0 and .bounds[0] + $ox < $width and .bounds[0] + $ox + .bounds[2] > 0 and .bounds[1] + $oy < $height and .bounds[1] + $oy + .bounds[3] > 0)' "$1" >/dev/null
}
# A grid drag starts once GTK sees the held pointer pass its threshold,
# and pages and hints slide on GTK's clock: poll the shell's tree for
# the state instead of sleeping. a11y_until dumps into $1 until the jq
# filter $2 holds; a11y_settled until the jq expression $2 is non-empty
# and unchanged across two dumps in a row. Both give up after ${3:-10} s.
a11y_until() {
a11y_end=$(($(date +%s) + ${3:-10}))
until "$PY" "$ROOT/scripts/lib/tuna-a11y-dump.py" tuna-shell-gtk "$1" 20 >/dev/null &&
jq -e "$2" "$1" >/dev/null 2>&1; do
[ "$(date +%s)" -lt "$a11y_end" ] || return 1
sleep 0.1
done
}
a11y_settled() {
a11y_end=$(($(date +%s) + ${3:-10}))
a11y_last=""
while :; do
if "$PY" "$ROOT/scripts/lib/tuna-a11y-dump.py" tuna-shell-gtk "$1" 20 >/dev/null; then
a11y_now="$(jq -c "$2" "$1" 2>/dev/null)" || a11y_now=""
[ -n "$a11y_now" ] && [ "$a11y_now" = "$a11y_last" ] && return 0
a11y_last="$a11y_now"
fi
[ "$(date +%s)" -lt "$a11y_end" ] || return 1
sleep 0.1
done
}

# A local folder source must initialize hints too. Cancel the real drag
# before dropping and require normal arrows with no preview or stale timer.
Expand All @@ -1530,10 +1556,9 @@ for step in $(seq 1 6); do
xdotool mousemove --window "$WID" $((folder_source_x + step * 10)) "$folder_source_y"
sleep 0.05
done
sleep 0.25
"$PY" "$ROOT/scripts/lib/tuna-a11y-dump.py" tuna-shell-gtk "$ARTIFACTS/a11y-folder-page-peek.json" 20 || fail G-GRID-DRAG-CANCEL "folder preview dump failed"
a11y_until "$ARTIFACTS/a11y-folder-page-peek.json" 'any(.[]; .showing and .name == "Next page preview")' ||
fail G-GRID-DRAG-CANCEL "real local folder drag has no neighboring-page hint"
scrot "$ARTIFACTS/11h-folder-page-peek.png" >/dev/null
showing "$ARTIFACTS/a11y-folder-page-peek.json" "Next page preview" || fail G-GRID-DRAG-CANCEL "real local folder drag has no neighboring-page hint"
xdotool key Escape
xdotool mouseup 1
sleep 1
Expand Down Expand Up @@ -1582,11 +1607,17 @@ for step in $(seq 1 6); do
xdotool mousemove --window "$WID" $((page_source_x + step * 10)) "$page_source_y"
sleep 0.05
done
sleep 0.25
"$PY" "$ROOT/scripts/lib/tuna-a11y-dump.py" tuna-shell-gtk "$ARTIFACTS/a11y-grid-page-peek.json" 20 || fail G-GRID-PAGE-PEEK "preview dump failed"
# Wait for the drag's hint, then for its icons to finish sliding in.
a11y_settled "$ARTIFACTS/a11y-grid-page-peek.json" 'select(any(.[]; .showing and .name == "Next page preview")) | [.[] | select(.showing and .role == "image" and (.name | startswith("Tuna Page App"))) | .bounds] | select(length > 0)' ||
fail G-GRID-PAGE-PEEK "next page hint missing or never settled during drag"
scrot "$ARTIFACTS/11h-grid-page-peek.png" >/dev/null
showing "$ARTIFACTS/a11y-grid-page-peek.json" "Next page preview" || fail G-GRID-PAGE-PEEK "next page hint missing during drag"
jq -e 'any(.[]; .showing and .role == "image" and (.name | startswith("Tuna Page App")))' "$ARTIFACTS/a11y-grid-page-peek.json" >/dev/null || fail G-GRID-PAGE-PEEK "neighboring page icons did not enter the hint"
# GNOME's hint is a tenth of the grid; wider, it covers tiles and turns
# the page under a pointer that is only crossing them.
grid_layer_width="$(jq '.layers[] | select(.namespace == "tuna-shell-appgrid") | .rect[2]' "$CSTATE")"
jq -e --argjson grid "$grid_layer_width" 'all(.[] | select(.showing and (.name | endswith("page preview"))); .bounds[2] > 0 and .bounds[2] <= ($grid / 10 | floor))' "$ARTIFACTS/a11y-grid-page-peek.json" >/dev/null ||
fail G-GRID-PAGE-PEEK "the page hint is wider than a tenth of the grid"
peek_rect="$(jq -r --argjson ox "$grid_origin_x" --argjson oy "$grid_origin_y" '.[] | select(.showing and .name == "Next page preview") | .bounds | "\(.[0] + $ox) \(.[1] + $oy) \(.[2]) \(.[3])"' "$ARTIFACTS/a11y-grid-page-peek.json" | head -1)"
# shellcheck disable=SC2086
set -- $peek_rect
Expand Down Expand Up @@ -1768,8 +1799,9 @@ printf '[Desktop Entry]\nName=ZZZ Rebuild Probe\nExec=true\nType=Application\n'
sleep 1.5
xdotool key super+a
cstate '.overview_open and .overview_app_grid' "short page setup" || fail G-GRID-SHORT-PAGES "app grid missing"
sleep 1
"$PY" "$ROOT/scripts/lib/tuna-a11y-dump.py" tuna-shell-gtk "$ARTIFACTS/a11y-grid-short-before.json" 20 || fail G-GRID-SHORT-PAGES "setup dump failed"
# The probe in the tree proves the rebuild that read the saved pages.
a11y_settled "$ARTIFACTS/a11y-grid-short-before.json" 'select(any(.[]; .name == "ZZZ Rebuild Probe")) | [.[] | select(.showing and (.role == "push button" or .role == "button") and .name == "AAA Move Three")][0].bounds | select(. != null)' ||
fail G-GRID-SHORT-PAGES "setup grid never settled"
grid_origin
grid_shown "$ARTIFACTS/a11y-grid-short-before.json" "AAA Move Three" || fail G-GRID-SHORT-PAGES "source missing from first page"
if grid_shown "$ARTIFACTS/a11y-grid-short-before.json" "AAA Move Two"; then fail G-GRID-SHORT-PAGES "saved second page was flattened"; fi
Expand All @@ -1786,24 +1818,37 @@ for step in $(seq 1 6); do
xdotool mousemove --window "$WID" $((source_x + step * 10)) "$source_y"
sleep 0.05
done
sleep 0.25
# The drag starts once GTK sees the held pointer pass its threshold, which
# a loaded runner can deliver after these steps: bump the edge only then.
a11y_until "$ARTIFACTS/a11y-grid-short-drag.json" 'any(.[]; .showing and .name == "Next page preview")' ||
fail G-GRID-SHORT-PAGES "the drag did not start"
xdotool mousemove --window "$WID" $((WIDTH - 6)) 330
sleep 0.6
"$PY" "$ROOT/scripts/lib/tuna-a11y-dump.py" tuna-shell-gtk "$ARTIFACTS/a11y-grid-short-target.json" 20 || fail G-GRID-SHORT-PAGES "target page dump failed"
# The bump turns the page: aim once the carousel rests with the target in view.
a11y_settled "$ARTIFACTS/a11y-grid-short-target.json" "[.[] | select(.showing and (.role == \"push button\" or .role == \"button\") and .name == \"AAA Move Two\" and .bounds[2] > 0 and .bounds[0] + $grid_origin_x >= 0 and .bounds[0] + $grid_origin_x + .bounds[2] <= $WIDTH)][0].bounds | select(. != null)" ||
fail G-GRID-SHORT-PAGES "bumping the edge did not turn to the target page"
grid_coordinates="$(grid_point "$ARTIFACTS/a11y-grid-short-target.json" "AAA Move Two" 4)" || fail G-GRID-SHORT-PAGES "target edge bounds missing"
read -r target_x target_y <<EOF
$grid_coordinates
EOF
xdotool mousemove --window "$WID" "$target_x" "$target_y"
sleep 0.35
xdotool mouseup 1
sleep 1.5
"$PY" -c 'from gi.repository import Gio; import json; pages=Gio.Settings.new("org.gnome.shell").get_value("app-picker-layout").unpack(); print(json.dumps([sorted(page, key=lambda key: page[key]["position"]) for page in pages]))' >"$ARTIFACTS/grid-short-pages.json"
jq -e '.[0] == ["tuna-move-One.desktop"] and .[1][0:2] == ["tuna-move-Three.desktop", "tuna-move-Two.desktop"]' "$ARTIFACTS/grid-short-pages.json" >/dev/null || fail G-GRID-SHORT-PAGES "cross-page move did not leave the source short"
# Another installed entry forces a complete grid rebuild.
short_end=$(($(date +%s) + 10))
until "$PY" -c 'from gi.repository import Gio; import json; pages=Gio.Settings.new("org.gnome.shell").get_value("app-picker-layout").unpack(); print(json.dumps([sorted(page, key=lambda key: page[key]["position"]) for page in pages]))' >"$ARTIFACTS/grid-short-pages.json" &&
jq -e '.[0] == ["tuna-move-One.desktop"] and .[1][0:2] == ["tuna-move-Three.desktop", "tuna-move-Two.desktop"]' "$ARTIFACTS/grid-short-pages.json" >/dev/null; do
[ "$(date +%s)" -lt "$short_end" ] || fail G-GRID-SHORT-PAGES "cross-page move did not leave the source short"
sleep 0.2
done
# Another installed entry changes the catalog. The grid lists the catalog
# as the overview opens, so reopen it and wait for the entry: a rebuild
# that must keep the saved short page.
printf '[Desktop Entry]\nName=ZZZ Rebuild Probe Two\nExec=true\nType=Application\n' >"$XDG_DATA_HOME/applications/tuna-grid-rebuild-two.desktop"
sleep 1.5
"$PY" "$ROOT/scripts/lib/tuna-a11y-dump.py" tuna-shell-gtk "$ARTIFACTS/a11y-grid-short-rebuilt.json" 20 || fail G-GRID-SHORT-PAGES "rebuild dump failed"
xdotool key Escape
cstate '.overview_open | not' "short grid closed for the rebuild" || fail G-GRID-SHORT-PAGES "overview stayed open"
xdotool key super+a
cstate '.overview_open and .overview_app_grid' "short grid reopened" || fail G-GRID-SHORT-PAGES "app grid missing after reopening"
a11y_settled "$ARTIFACTS/a11y-grid-short-rebuilt.json" 'select(any(.[]; .name == "ZZZ Rebuild Probe Two")) | [.[] | select(.showing and (.role == "push button" or .role == "button") and .name == "AAA Move One")][0].bounds | select(. != null)' ||
fail G-GRID-SHORT-PAGES "the rebuilt grid never showed the new entry"
grid_shown "$ARTIFACTS/a11y-grid-short-rebuilt.json" "AAA Move One" || fail G-GRID-SHORT-PAGES "short first page lost its item"
if grid_shown "$ARTIFACTS/a11y-grid-short-rebuilt.json" "AAA Move Two" || grid_shown "$ARTIFACTS/a11y-grid-short-rebuilt.json" "AAA Move Three"; then fail G-GRID-SHORT-PAGES "rebuilding filled the short first page"; fi
pass G-GRID-SHORT-PAGES "a cross-page drag leaves its source short and the saved boundary survives a catalog rebuild"
Expand Down
Loading