diff --git a/README.md b/README.md index dd7529ce..835c8312 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,13 @@ Run `meson` to configure the build environment and then `ninja` to build To install, use `ninja install` ninja install + +## Headless tests (no GUI required) + +This repository now includes a small, headless test suite that exercises the core layout logic (overlap resolution, edge adjacency, and origin normalization) without requiring GTK or a running compositor. This helps validate behavior even on single‑monitor or headless environments. + +To run tests after configuring the build directory: + + meson test -C build --print-errorlogs + +You should see the `layout_logic` test pass. These tests simulate 3+ displays, overlap scenarios, and ensure the layout can be normalized and stays connected without crashes. diff --git a/meson.build b/meson.build index 5711df2d..05f6dece 100644 --- a/meson.build +++ b/meson.build @@ -30,3 +30,4 @@ config_file = configure_file( subdir('data') subdir('src') subdir('po') +subdir('tests') diff --git a/src/Widgets/DisplayWidget.vala b/src/Widgets/DisplayWidget.vala index 2cbfbc5f..cd70142c 100644 --- a/src/Widgets/DisplayWidget.vala +++ b/src/Widgets/DisplayWidget.vala @@ -580,24 +580,65 @@ public class Display.DisplayWidget : Gtk.Box { } public void set_virtual_monitor_geometry (int x, int y, int width, int height) { - virtual_monitor.x = x; - virtual_monitor.y = y; - real_width = width; - real_height = height; + // Avoid redundant updates that can trigger unnecessary notify signals + bool changed = false; + if (virtual_monitor.x != x) { + virtual_monitor.x = x; + changed = true; + } + if (virtual_monitor.y != y) { + virtual_monitor.y = y; + changed = true; + } + if (real_width != width) { + real_width = width; + changed = true; + } + if (real_height != height) { + real_height = height; + changed = true; + } - queue_resize (); + if (changed) { + queue_resize (); + } } public void move_x (int dx) { + if (dx == 0) { + return; + } virtual_monitor.x += dx; queue_resize (); } public void move_y (int dy) { + if (dy == 0) { + return; + } virtual_monitor.y += dy; queue_resize (); } + // Move by dx,dy in a single notify batch to avoid re-entrant signal storms + public void move_by (int dx, int dy) { + if (dx == 0 && dy == 0) { + return; + } + + // Freeze notify on the virtual monitor while applying both deltas + virtual_monitor.freeze_notify (); + if (dx != 0) { + virtual_monitor.x += dx; + } + if (dy != 0) { + virtual_monitor.y += dy; + } + virtual_monitor.thaw_notify (); + + queue_resize (); + } + public bool equals (DisplayWidget sibling) { return virtual_monitor.id == sibling.virtual_monitor.id; } diff --git a/src/Widgets/DisplaysOverlay.vala b/src/Widgets/DisplaysOverlay.vala index d76bb41c..851b8ffe 100644 --- a/src/Widgets/DisplaysOverlay.vala +++ b/src/Widgets/DisplaysOverlay.vala @@ -141,8 +141,9 @@ public class Display.DisplaysOverlay : Gtk.Box { // dx & dy are screen offsets from the start of dragging private void on_drag_update (double dx, double dy) { if (!only_display && dragging_display != null) { - dragging_display.move_x ((int) ((dx - prev_dx) / current_ratio)); - dragging_display.move_y ((int) ((dy - prev_dy) / current_ratio)); + var ddx = (int) ((dx - prev_dx) / current_ratio); + var ddy = (int) ((dy - prev_dy) / current_ratio); + dragging_display.move_by (ddx, ddy); prev_dx = dx; prev_dy = dy; } @@ -456,8 +457,7 @@ public class Display.DisplaysOverlay : Gtk.Box { } } - other_display_widget.move_x (-dx); - other_display_widget.move_y (-dy); + other_display_widget.move_by (-dx, -dy); moved = moved || dx != 0 || dy != 0; if (dx != 0 || dy != 0) { align_edges (other_display_widget, moved, ++level); @@ -580,8 +580,7 @@ public class Display.DisplaysOverlay : Gtk.Box { } } - other_display_widget.move_x (distance_x); - other_display_widget.move_y (distance_y); + other_display_widget.move_by (distance_x, distance_y); check_intersects (other_display_widget, moved, ++level); } diff --git a/tests/meson.build b/tests/meson.build new file mode 100644 index 00000000..b3910095 --- /dev/null +++ b/tests/meson.build @@ -0,0 +1,13 @@ +test_deps = [ + dependency('glib-2.0'), + dependency('gee-0.8') +] + +test_exe = executable( + 'layout_tests', + files('test_layout.vala'), + dependencies: test_deps, + vala_args: ['--fatal-warnings'] +) + +test('layout_logic', test_exe) diff --git a/tests/test_layout.vala b/tests/test_layout.vala new file mode 100644 index 00000000..50a78d13 --- /dev/null +++ b/tests/test_layout.vala @@ -0,0 +1,163 @@ +/* + * Headless tests for layout logic (no GTK). We simulate a subset of the + * geometry algorithms used by DisplaysOverlay and VirtualMonitor to ensure + * correctness with 3+ monitors and edge/overlap cases. + */ + +public class TestVM : GLib.Object { + public int x { get; set; } + public int y { get; set; } + public int w { get; set; } + public int h { get; set; } + + public TestVM (int x, int y, int w, int h) { + this.x = x; this.y = y; this.w = w; this.h = h; + } +} + +namespace Layout { + public void set_origin_zero (GLib.List vms) { + int min_x = int.MAX; + int min_y = int.MAX; + foreach (unowned var vm in vms) { + min_x = int.min (min_x, vm.x); + min_y = int.min (min_y, vm.y); + } + if (min_x == 0 && min_y == 0) return; + foreach (unowned var vm in vms) { + vm.x -= min_x; vm.y -= min_y; + } + } + + public bool intersects (TestVM a, TestVM b, out int ovw, out int ovh) { + int ax2 = a.x + a.w, ay2 = a.y + a.h; + int bx2 = b.x + b.w, by2 = b.y + b.h; + ovw = int.max (0, int.min (ax2, bx2) - int.max (a.x, b.x)); + ovh = int.max (0, int.min (ay2, by2) - int.max (a.y, b.y)); + return ovw > 0 && ovh > 0; + } + + // Resolve overlaps by moving B minimally away from A along smaller overlap axis + public bool resolve_overlap_once (TestVM a, TestVM b) { + int ovw, ovh; + if (!intersects (a, b, out ovw, out ovh)) return false; + if (ovw <= ovh) { + if (b.x < a.x) b.x -= ovw; else b.x += ovw; + } else { + if (b.y < a.y) b.y -= ovh; else b.y += ovh; + } + return true; + } + + public void resolve_all_overlaps (GLib.List vms, uint max_iter = 16) { + uint iter = 0; + while (iter++ < max_iter) { + bool moved = false; + for (int i = 0; i < (int) vms.length (); i++) { + for (int j = i + 1; j < (int) vms.length (); j++) { + moved = resolve_overlap_once (vms.nth_data (i), vms.nth_data (j)) || moved; + } + } + if (!moved) break; + } + } + + public bool is_connected_pair (TestVM a, TestVM b) { + // Adjoin: touch on an edge (inclusive) without overlapping interior + bool x_adjacent = (a.x + a.w == b.x) || (b.x + b.w == a.x); + bool y_overlap = !(a.y + a.h <= b.y || b.y + b.h <= a.y); + bool y_adjacent = (a.y + a.h == b.y) || (b.y + b.h == a.y); + bool x_overlap = !(a.x + a.w <= b.x || b.x + b.w <= a.x); + return (x_adjacent && y_overlap) || (y_adjacent && x_overlap); + } + + public bool is_connected_all (GLib.List vms) { + if (vms.length () <= 1) return true; + var seen = new GLib.HashTable (GLib.direct_hash, GLib.direct_equal); + var queue = new GLib.Queue (); + var first = vms.nth_data (0); + seen.insert (first, true); + queue.push_tail (first); + while (!queue.is_empty ()) { + var cur = queue.pop_head (); + foreach (unowned var vm in vms) { + if (seen.lookup (vm)) continue; + if (is_connected_pair (cur, vm)) { + seen.insert (vm, true); + queue.push_tail (vm); + } + } + } + return seen.size () == vms.length (); + } +} + +int failures = 0; +void assert_true (bool cond, string msg) { + if (!cond) { + critical ("Assertion failed: %s", msg); + failures++; + } +} + +int main (string[] args) { + // Test 1: Three displays, initially overlapping; resolve, normalize, and validate connectivity + var vms = new GLib.List (); + vms.append (new TestVM (0, 0, 1920, 1080)); + vms.append (new TestVM (1800, 0, 1920, 1080)); // overlaps 120px on X + vms.append (new TestVM (3600, 100, 1280, 1024)); // slightly below and to the right + + Layout.resolve_all_overlaps (vms); + Layout.set_origin_zero (vms); + int _ovw, _ovh; + assert_true (!Layout.intersects (vms.nth_data (0), vms.nth_data (1), out _ovw, out _ovh), "VM0/VM1 should not overlap after resolve"); + assert_true (!Layout.intersects (vms.nth_data (1), vms.nth_data (2), out _ovw, out _ovh), "VM1/VM2 should not overlap after resolve"); + + // Expect connectivity after minor adjustments + assert_true (Layout.is_connected_all (vms), "All VMs should be connected"); + + // Test 2: Vertical stacking with same X, ensure normalization keeps origin at (0,0) + var v2 = new GLib.List (); + v2.append (new TestVM (100, 200, 1600, 900)); + v2.append (new TestVM (100, 1100, 1600, 900)); + Layout.set_origin_zero (v2); + assert_true (v2.nth_data (0).x == 0 && v2.nth_data (0).y == 0, "Origin normalized to (0,0)"); + assert_true (v2.nth_data (1).x == 0 && v2.nth_data (1).y == 900, "Second stacked below first at y=height"); + + // Test 3: Edge adjacency detection + var a = new TestVM (0, 0, 100, 100); + var b = new TestVM (100, 10, 100, 50); // touches a's right edge + assert_true (Layout.is_connected_pair (a, b), "Edge adjacency should be connected"); + + // Test 4: Same Y alignment with exact adjacency across three displays + var t4 = new GLib.List (); + t4.append (new TestVM (0, 0, 1000, 800)); + t4.append (new TestVM (1000, 0, 1000, 800)); + t4.append (new TestVM (2000, 0, 1000, 800)); + assert_true (!Layout.intersects (t4.nth_data (0), t4.nth_data (1), out _ovw, out _ovh), "T4: 0/1 no overlap"); + assert_true (!Layout.intersects (t4.nth_data (1), t4.nth_data (2), out _ovw, out _ovh), "T4: 1/2 no overlap"); + assert_true (Layout.is_connected_all (t4), "T4: all connected along same Y"); + + // Test 5: Same Y with overlaps; resolver should separate into no-overlap configuration and keep connectivity + var t5 = new GLib.List (); + t5.append (new TestVM (0, 0, 1000, 800)); + t5.append (new TestVM (900, 0, 1000, 800)); // overlaps with first by 100px + t5.append (new TestVM (1900, 0, 1000, 800)); // overlaps with second by 0px (adjacent or slight overlap if math changes) + Layout.resolve_all_overlaps (t5); + assert_true (!Layout.intersects (t5.nth_data (0), t5.nth_data (1), out _ovw, out _ovh), "T5: 0/1 resolved"); + assert_true (!Layout.intersects (t5.nth_data (1), t5.nth_data (2), out _ovw, out _ovh), "T5: 1/2 resolved"); + assert_true (Layout.is_connected_all (t5), "T5: all connected after resolve"); + + // Test 6: Vertical stacking with same X (three displays) + var t6 = new GLib.List (); + t6.append (new TestVM (0, 0, 1200, 900)); + t6.append (new TestVM (0, 900, 1200, 900)); + t6.append (new TestVM (0, 1800, 1200, 900)); + assert_true (!Layout.intersects (t6.nth_data (0), t6.nth_data (1), out _ovw, out _ovh), "T6: 0/1 stacked no overlap"); + assert_true (!Layout.intersects (t6.nth_data (1), t6.nth_data (2), out _ovw, out _ovh), "T6: 1/2 stacked no overlap"); + assert_true (Layout.is_connected_all (t6), "T6: stacked connected"); + + // If we reached here, all tests passed + if (failures == 0) message ("layout tests passed"); + return failures == 0 ? 0 : 1; +}