Bladeren bron

stability fixes

Milan Jurkulak 3 maanden geleden
bovenliggende
commit
5b02f64c79

+ 38 - 0
.claude/skills/mjdev-kotlin-first/SKILL.md

@@ -0,0 +1,38 @@
+---
+name: mjdev-kotlin-first
+description: Kotlin-first implementation rule for mjdev — solve every behavior in Kotlin/JVM (the shared desktop shell) whatever it costs; only reach into the C compositor (compositor/native/shim.c) when the behavior is genuinely a compositor concern (seat/input/focus/surface layering) or when nothing else can possibly work. Apply when deciding WHERE a feature or fix belongs, and when reviewing changes that touch native compositor code.
+---
+
+# mjdev Kotlin-first rule (non-negotiable)
+
+The desktop is **Kotlin-first**. Default to solving everything in the Kotlin/JVM shell — the
+shared Compose Desktop code — no matter how awkward or expensive it is there. The C compositor
+(`compositor/native/shim.c`, wlroots) is a last resort.
+
+## Decision order
+1. **Can it be done in Kotlin/JVM?** → Do it there. This is the answer in the vast majority of
+   cases (autohide, reveal hotspots, menu coupling, window geometry, timeouts, state, UX logic).
+   Accept extra effort, polling, or indirection rather than pushing logic into C.
+2. **Is it intrinsically a compositor concern?** Only these legitimately belong in the shim:
+   - keyboard/pointer **seat focus** of wlr surfaces (the shell cannot focus arbitrary surfaces),
+   - **input routing**, surface/**layer ordering**, output/mode handling,
+   - the compositor's own **IPC feed** (e.g. the pointer broadcast the shell subscribes to).
+3. **Is there truly no other way?** Only then add C — and keep it the minimal seat/surface
+   primitive, leaving the *policy* (when/whether) in Kotlin.
+
+## Why
+The Kotlin side is testable, hot-reloadable, portable across JVM (`runDesktop`) and nested, and
+where the project's whole architecture lives. Native compositor code is fragile, hard to debug,
+and couples behavior to one backend. Logic split into C tends to rot and surprise.
+
+## Corollary: JVM must work first
+A behavior must work in plain **JVM / `runDesktop`** first — that is the source of truth. The
+nested compositor path is for compositor-specific features or final integration, not where new
+behavior is born. If a fix only works nested, it is not done.
+
+## Review checklist for native diffs
+When a change touches `shim.c` (or any native code), challenge it:
+- Could this live in Kotlin instead? If yes, move it.
+- Does the C part stay a thin primitive, with the decision logic in the shell?
+- Example of a *correct* split: focus-follows-mouse sets wlr seat focus in C (unavoidable), but
+  dock/control-center **autohide is driven by pointer-leave in the Kotlin shell**, not in C.

+ 38 - 3
ai-todo.txt

@@ -8,12 +8,47 @@
 * update dependencies from report in reports folder, on every updated library check
   that builds run and or ommit/revert update if impossible
 
+- github action produce only one file, exe for windows, read only gh allowed in this step.
+
+- iso should be also included in release on github (limit is 2 GB of iso i hear)
+
+- when control center is shown and then user is going to a dock bottom bar, dock bottom bar
+  is below control center, focus jvm issue.
+
+- bottom bar is show in center of desktop, this should never happen.
+  your fixes little bit improve stability but this shows thats not enough.
+
+- memory widget have time to time unstable color, diferrent on another in some cases.
+
+- stabilize mouse focuses and events, this is probably done
+
 - handles that are on sliding windows 12dp please, there is 9dp i think yet.
 
 - in log, when building, there is a lot of warnings and errors, i want to have
   clean log  if possible.
 
-- iso se negeneruje do releases na githubu
+- control center, when shown should hide menu and bottom bar if it is visible.
+
+# --- from code review 2026-06-16 (background/palette/memory task) ---
+- RESOURCE MINIMIZATION (top priority): minimize computer resources of the desktop, any way.
+  main hog = GIF playback: custom GifDecoder decodes ALL frames upfront into ImageBitmaps and
+  ImageBitmapExt.writePixels writes them pixel-by-pixel via Canvas.drawRect (O(w*h) draw calls per
+  frame) + every frame kept in RAM. ideas to evaluate:
+  (a) render gifs via coil3 animated decoder (coil now HAS a gif decoder and already decodes gifs
+      for the palette) - only if visual behavior stays exactly as now;
+  (b) on-disk gif cache: pre-extract a gif into per-frame image files on disk (disk is cheap) and
+      stream frames from disk instead of holding all frames in RAM.
+  open to other ideas. measure CPU/RAM before+after.
+- palette samples ONE frame of an animated gif at switch time (a white flash frame -> washed-out
+  palette). sample a representative frame or average several. (review #3, try next)
+- palette iconsTintColor currently resolves equal to textColor; they SHOULD differ (accent vs
+  text). this is a todo hack. (review #4)
+- MimeTypeExt.Path.isVideo returns absolutePath.isGif (copy-paste); should be .isVideo. (review #6)
+- BackgroundImage rotation relies on LaunchedEffect(imagesSize) keyed on a non-observable
+  images.size and works only via the chained LaunchedEffect(currentBackground). make it robust
+  WITHOUT changing the current behavior (beware the desktopBackgrounds stateList landmine). (review #7)
+- ImageAny ignores colorFilter on the gif/video/async paths, so BackgroundImage's
+  ContrastColorFilter(1.5f) is dead there; apply it or drop the param. (review #9)
 
-# unimportant changes if possible:
-# control center, when shown should hide menu and bottom bar if it is visible
+todo later:
+- remote desktop or a connection aka VNC (any standardized protocol) to desktop by ip address, aka X11

+ 167 - 13
compositor/native/shim.c

@@ -95,6 +95,12 @@ struct mjc_server {
     /* throttle timestamp (msec) for the pointer callback */
     uint32_t last_pointer_ms;
 
+    /* injection feedback marker (Android "show touches" style): a small white square in
+     * the overlay layer that appears at the injected pointer and fades out after a timeout */
+    struct wlr_scene_rect *inject_marker;
+    struct wl_event_source *inject_fade_timer;
+    float inject_alpha;
+
     enum mjc_cursor_mode cursor_mode;
     struct mjc_view *grabbed_view;
     double grab_x, grab_y;
@@ -227,7 +233,7 @@ static void view_deactivate(struct mjc_view *view) {
     }
 }
 
-void mjc_view_focus(struct mjc_view *view) {
+static void view_focus_internal(struct mjc_view *view, bool raise) {
     if (view == NULL || !view->focusable || view->minimized) {
         return;
     }
@@ -239,7 +245,9 @@ void mjc_view_focus(struct mjc_view *view) {
     if (server->focused_view != NULL) {
         view_deactivate(server->focused_view);
     }
-    if (view->scene_tree != NULL) {
+    /* focus-follows-mouse must not restack windows (no autoraise); only an explicit
+     * focus (click / activate / unminimize) raises the view to the top */
+    if (raise && view->scene_tree != NULL) {
         wlr_scene_node_raise_to_top(&view->scene_tree->node);
     }
     if (view->is_xwayland) {
@@ -262,6 +270,10 @@ void mjc_view_focus(struct mjc_view *view) {
     emit_focus_change(server);
 }
 
+void mjc_view_focus(struct mjc_view *view) {
+    view_focus_internal(view, true);
+}
+
 static void clear_focus_if(struct mjc_server *server, struct mjc_view *view) {
     if (server->focused_view == view) {
         server->focused_view = NULL;
@@ -315,15 +327,13 @@ static void output_frame(struct wl_listener *listener, void *data) {
 
 static void output_request_state(struct wl_listener *listener, void *data) {
     struct mjc_output *output = wl_container_of(listener, output, request_state);
-    (void) data; /* ignore the host-requested size: keep the nested output locked
-                  * to its fixed 16:9 mode so the window cannot be resized
-                  * (moving and closing still work via the host decoration) */
-    struct wlr_output_state state;
-    wlr_output_state_init(&state);
-    wlr_output_state_set_custom_mode(&state,
-        output->server->output_w, output->server->output_h, 0);
-    wlr_output_commit_state(output->wlr_output, &state);
-    wlr_output_state_finish(&state);
+    const struct wlr_output_event_request_state *event = data;
+    /* honor host-driven resizes (the user resizing the nested x11/wayland window) so the
+     * desktop relayouts to the new size; commit the requested state and remember the new
+     * dimensions, which the shell reads back via the wl_output geometry */
+    wlr_output_commit_state(output->wlr_output, event->state);
+    output->server->output_w = output->wlr_output->width;
+    output->server->output_h = output->wlr_output->height;
 }
 
 static void output_destroy(struct wl_listener *listener, void *data) {
@@ -587,6 +597,11 @@ static void process_cursor_resize(struct mjc_server *server) {
     }
 }
 
+/* 1 = focus-follows-mouse (focusable view under the pointer gets focus). The focus-clear
+ * over the desktop is intentionally NOT done here: it made the dock reveal jam after a couple
+ * of cycles. The dock autohide must instead be driven by pointer-leave on the shell side. */
+#define MJC_FOCUS_FOLLOWS_MOUSE 1
+
 static void process_cursor_motion(struct mjc_server *server, uint32_t time) {
     if (server->cursor_mode == MJC_CURSOR_MOVE) {
         process_cursor_move(server);
@@ -599,14 +614,20 @@ static void process_cursor_motion(struct mjc_server *server, uint32_t time) {
     double sx, sy;
     struct wlr_seat *seat = server->seat;
     struct wlr_surface *surface = NULL;
-    desktop_view_at(server, server->cursor->x, server->cursor->y,
-        &surface, &sx, &sy);
+    struct mjc_view *view = desktop_view_at(server, server->cursor->x,
+        server->cursor->y, &surface, &sx, &sy);
     if (surface == NULL) {
         wlr_cursor_set_xcursor(server->cursor, server->cursor_mgr, "default");
     }
     if (surface != NULL) {
         wlr_seat_pointer_notify_enter(seat, surface, sx, sy);
         wlr_seat_pointer_notify_motion(seat, time, sx, sy);
+        /* focus-follows-mouse: focusable view under the pointer gets keyboard focus (no
+         * restack). Do NOT clear focus over non-focusable surfaces — that churns focus and
+         * jams the dock reveal; autohide is handled by pointer-leave on the shell side. */
+        if (MJC_FOCUS_FOLLOWS_MOUSE && view != NULL && view->focusable) {
+            view_focus_internal(view, false);
+        }
     } else {
         wlr_seat_pointer_clear_focus(seat);
     }
@@ -1341,6 +1362,13 @@ bool mjc_start(mjc_server *server, const mjc_callbacks *callbacks, void *userdat
         server->layers[i] = wlr_scene_tree_create(&server->scene->tree);
     }
 
+    /* injection feedback marker: a 16x16 white square in the overlay layer, hidden until
+     * input is injected, then shown at the pointer and faded out (Android show-touches style) */
+    const float marker_hidden[4] = {0.0f, 0.0f, 0.0f, 0.0f};
+    server->inject_marker = wlr_scene_rect_create(
+        server->layers[MJC_LAYER_OVERLAY], 16, 16, marker_hidden);
+    wlr_scene_node_set_enabled(&server->inject_marker->node, false);
+
     server->xdg_shell = wlr_xdg_shell_create(server->display, 3);
     server->new_xdg_toplevel.notify = server_new_xdg_toplevel;
     wl_signal_add(&server->xdg_shell->events.new_toplevel, &server->new_xdg_toplevel);
@@ -1750,3 +1778,129 @@ void mjc_view_set_geometry(mjc_view *view, int x, int y, int width, int height)
         }
     }
 }
+
+/* ------------------------------------------------------------------ */
+/* input injection (ipc-driven, for automated/headless testing)        */
+/* ------------------------------------------------------------------ */
+
+static uint32_t mjc_now_msec(void) {
+    struct timespec now;
+    clock_gettime(CLOCK_MONOTONIC, &now);
+    return (uint32_t) (now.tv_sec * 1000 + now.tv_nsec / 1000000);
+}
+
+#define MJC_INJECT_MARKER_SIZE 16
+#define MJC_INJECT_FADE_STEP 0.08f
+#define MJC_INJECT_FADE_INTERVAL_MS 40
+
+/* step the injection marker's alpha down; disables it once fully faded */
+static int inject_marker_fade(void *data) {
+    struct mjc_server *server = data;
+    if (server->inject_marker == NULL) {
+        return 0;
+    }
+    server->inject_alpha -= MJC_INJECT_FADE_STEP;
+    if (server->inject_alpha <= 0.0f) {
+        server->inject_alpha = 0.0f;
+        wlr_scene_node_set_enabled(&server->inject_marker->node, false);
+        return 0;
+    }
+    float a = server->inject_alpha;
+    const float color[4] = {a, a, a, a}; /* premultiplied white */
+    wlr_scene_rect_set_color(server->inject_marker, color);
+    if (server->inject_fade_timer != NULL) {
+        wl_event_source_timer_update(server->inject_fade_timer,
+            MJC_INJECT_FADE_INTERVAL_MS);
+    }
+    return 0;
+}
+
+/* show the injection marker at the current cursor position at full opacity and (re)arm
+ * the fade timer; called on every injected pointer event so it stays bright while moving */
+static void inject_marker_show(struct mjc_server *server) {
+    if (server->inject_marker == NULL) {
+        return;
+    }
+    wlr_scene_node_set_position(&server->inject_marker->node,
+        (int) server->cursor->x - MJC_INJECT_MARKER_SIZE / 2,
+        (int) server->cursor->y - MJC_INJECT_MARKER_SIZE / 2);
+    server->inject_alpha = 1.0f;
+    const float color[4] = {1.0f, 1.0f, 1.0f, 1.0f};
+    wlr_scene_rect_set_color(server->inject_marker, color);
+    wlr_scene_node_raise_to_top(&server->inject_marker->node);
+    wlr_scene_node_set_enabled(&server->inject_marker->node, true);
+    /* keep the marker persistently visible for now (behaves like a mouse cursor); the
+     * inactivity fadeout is disabled until we want it back */
+}
+
+void mjc_pointer_move(mjc_server *server, int x, int y) {
+    if (server == NULL) {
+        return;
+    }
+    /* warp to the closest valid layout point, then run the normal motion path so
+     * pointer focus, focus-follows-mouse and the ipc pointer broadcast all fire */
+    wlr_cursor_warp_closest(server->cursor, NULL, (double) x, (double) y);
+    process_cursor_motion(server, mjc_now_msec());
+    inject_marker_show(server);
+}
+
+void mjc_pointer_button(mjc_server *server, uint32_t button, bool pressed) {
+    if (server == NULL) {
+        return;
+    }
+    enum wl_pointer_button_state state = pressed
+        ? WL_POINTER_BUTTON_STATE_PRESSED
+        : WL_POINTER_BUTTON_STATE_RELEASED;
+    wlr_seat_pointer_notify_button(server->seat, mjc_now_msec(), button, state);
+    if (!pressed) {
+        server->cursor_mode = MJC_CURSOR_PASSTHROUGH;
+        server->grabbed_view = NULL;
+    } else {
+        double sx, sy;
+        struct wlr_surface *surface = NULL;
+        struct mjc_view *view = desktop_view_at(server,
+            server->cursor->x, server->cursor->y, &surface, &sx, &sy);
+        if (view != NULL && view->focusable) {
+            mjc_view_focus(view);
+        }
+    }
+    wlr_seat_pointer_notify_frame(server->seat);
+    inject_marker_show(server);
+}
+
+void mjc_key(mjc_server *server, uint32_t keycode, bool pressed) {
+    if (server == NULL) {
+        return;
+    }
+    struct wlr_keyboard *keyboard = wlr_seat_get_keyboard(server->seat);
+    if (keyboard == NULL) {
+        if (wl_list_empty(&server->keyboards)) {
+            return;
+        }
+        struct mjc_keyboard *kb =
+            wl_container_of(server->keyboards.next, kb, link);
+        keyboard = kb->wlr_keyboard;
+    }
+    enum wl_keyboard_key_state state = pressed
+        ? WL_KEYBOARD_KEY_STATE_PRESSED
+        : WL_KEYBOARD_KEY_STATE_RELEASED;
+    /* mirror keyboard_handle_key: offer the keysym to the shell global-key hook first
+     * (Super/Escape toggles), and only forward to the focused client when unhandled */
+    uint32_t xkb_keycode = keycode + 8;
+    const xkb_keysym_t *syms;
+    int nsyms = xkb_state_key_get_syms(keyboard->xkb_state, xkb_keycode, &syms);
+    uint32_t modifiers = wlr_keyboard_get_modifiers(keyboard);
+    bool handled = false;
+    if (pressed) {
+        for (int i = 0; i < nsyms; i++) {
+            if (server->cbs.key != NULL &&
+                    server->cbs.key(server->ud, syms[i], modifiers, true)) {
+                handled = true;
+            }
+        }
+    }
+    if (!handled) {
+        wlr_seat_set_keyboard(server->seat, keyboard);
+        wlr_seat_keyboard_notify_key(server->seat, mjc_now_msec(), keycode, state);
+    }
+}

+ 5 - 0
compositor/native/shim.h

@@ -113,6 +113,11 @@ void mjc_view_set_focusable(mjc_view *view, bool focusable);
 void mjc_view_set_position(mjc_view *view, int x, int y);
 void mjc_view_set_geometry(mjc_view *view, int x, int y, int width, int height);
 
+/* input injection (ipc-driven, for automated/headless testing) */
+void mjc_pointer_move(mjc_server *server, int x, int y);
+void mjc_pointer_button(mjc_server *server, uint32_t button, bool pressed);
+void mjc_key(mjc_server *server, uint32_t keycode, bool pressed);
+
 #ifdef __cplusplus
 }
 #endif

+ 64 - 0
compositor/scripts/bars-stress.sh

@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# Randomized stress test for the shell bars (dock / control-center / apps menu), driven entirely
+# through the mjdev compositor IPC socket — no real mouse needed. It injects pointer moves, clicks
+# and keys in randomized cycles mimicking real use:
+#   reveal dock, open menu (click menu icon), hover control center, click desktop centre,
+#   launch an app from the dock, close launched app windows.
+# It does NOT assert by itself — read the shell log afterwards (analyze section below) for
+# size flip-flops, position drift, show/hide floods and jams.
+#
+# Usage: bars-stress.sh [cycles]   (default 32). Requires a running nested/real mjdevc.
+set -u
+
+SOCK="${MJDEV_SOCK:-${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mjdev-compositor.sock}"
+CYCLES="${1:-32}"
+W=1280; H=720                       # nested output (MJDEVC_OUTPUT)
+ESC=1                               # evdev KEY_ESC
+
+[ -S "$SOCK" ] || { echo "no compositor socket at $SOCK"; exit 1; }
+
+# send one or more newline-terminated JSON commands on a single connection, hold it briefly so the
+# server processes the buffer before EOF (half-close race), return the response on stdout.
+ipc() { { printf '%s\n' "$@"; sleep 0.3; } | nc -U "$SOCK" 2>/dev/null; }
+
+rnd() { echo $(( RANDOM % ($2 - $1 + 1) + $1 )); }
+
+reveal_dock()  { ipc "{\"cmd\":\"pointer-move\",\"x\":$(rnd 100 $((W-100))),\"y\":$((H-2))}" >/dev/null; }
+move_off()     { ipc "{\"cmd\":\"pointer-move\",\"x\":$(rnd 300 900),\"y\":$(rnd 150 400)}" >/dev/null; }
+open_menu()    { ipc "{\"cmd\":\"click\",\"x\":40,\"y\":$((H-40))}" >/dev/null; }     # menu icon, dock far-left
+esc()          { ipc "{\"cmd\":\"key\",\"code\":$ESC}" >/dev/null; }
+hover_cc()     { ipc "{\"cmd\":\"pointer-move\",\"x\":$((W-2)),\"y\":$(rnd 50 $((H-50)))}" >/dev/null; }
+click_centre() { ipc "{\"cmd\":\"click\",\"x\":$((W/2)),\"y\":$((H/2))}" >/dev/null; }
+launch_app()   { ipc "{\"cmd\":\"pointer-move\",\"x\":$(rnd 120 900),\"y\":$((H-2))}" \
+                     "{\"cmd\":\"click\",\"x\":$(rnd 120 900),\"y\":$((H-40))}" >/dev/null; }
+
+close_app_windows() {
+  # close every listed (app) window by id so launches don't pile up
+  local ids
+  ids=$(ipc '{"cmd":"list-windows"}' | grep -oE '"id":[0-9]+' | grep -oE '[0-9]+')
+  for id in $ids; do ipc "{\"cmd\":\"close\",\"id\":$id}" >/dev/null; sleep 0.2; done
+}
+
+ACTIONS=(reveal_dock open_menu esc hover_cc click_centre launch_app move_off)
+
+echo "stress: $CYCLES cycles via $SOCK"
+for ((i=1; i<=CYCLES; i++)); do
+  # 2-3 random actions per cycle
+  n=$(rnd 2 3)
+  line="cycle $i:"
+  for ((k=0; k<n; k++)); do
+    a=${ACTIONS[$(rnd 0 $((${#ACTIONS[@]}-1)))]}
+    line="$line $a"
+    "$a"
+    sleep 0.25
+  done
+  echo "$line"
+  # periodically reap any apps we launched
+  if (( i % 6 == 0 )); then close_app_windows; fi
+done
+
+# settle, final cleanup, park pointer in the centre
+sleep 1
+close_app_windows
+ipc "{\"cmd\":\"pointer-move\",\"x\":$((W/2)),\"y\":$((H/2))}" >/dev/null
+echo "stress: done"

+ 68 - 0
compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/Ipc.kt

@@ -20,6 +20,7 @@ import kotlinx.serialization.json.JsonNull
 import kotlinx.serialization.json.JsonObject
 import kotlinx.serialization.json.buildJsonObject
 import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.int
 import kotlinx.serialization.json.jsonPrimitive
 import kotlinx.serialization.json.long
 import kotlinx.serialization.json.put
@@ -28,11 +29,15 @@ import mjdev.compositor.shim.MJC_EVENT_HANGUP
 import mjdev.compositor.shim.MJC_EVENT_READABLE
 import mjdev.compositor.shim.mjc_loop_add_fd
 import mjdev.compositor.shim.mjc_loop_remove_fd
+import mjdev.compositor.shim.mjc_key
+import mjdev.compositor.shim.mjc_pointer_button
+import mjdev.compositor.shim.mjc_pointer_move
 import mjdev.compositor.shim.mjc_view_focus
 import mjdev.compositor.shim.mjc_view_close
 import mjdev.compositor.shim.mjc_view_is_maximized
 import mjdev.compositor.shim.mjc_view_set_maximized
 import mjdev.compositor.shim.mjc_view_set_minimized
+import mjdev.compositor.shim.mjc_view_set_position
 import mjdev.compositor.shim.mjc_unix_accept
 import mjdev.compositor.shim.mjc_unix_listen
 import platform.posix.EAGAIN
@@ -43,6 +48,9 @@ import platform.posix.read
 import platform.posix.unlink
 import platform.posix.write
 
+/** evdev code for the left mouse button (linux input-event-codes.h BTN_LEFT) */
+private const val BTN_LEFT = 0x110
+
 /**
  * Line based JSON api on a unix socket.
  *
@@ -54,6 +62,11 @@ import platform.posix.write
  *   {"cmd":"minimize","id":3,"minimized":true}
  *   {"cmd":"maximize","id":3}
  *   {"cmd":"subscribe"}
+ *   {"cmd":"pointer-move","x":100,"y":200}
+ *   {"cmd":"button","button":272,"pressed":true}   // 272 = BTN_LEFT; omit button for left
+ *   {"cmd":"click","x":100,"y":200}                // optional x/y, then press+release
+ *   {"cmd":"key","code":1,"pressed":true}          // evdev code; omit pressed to tap
+ *   {"cmd":"move","id":1,"x":100,"y":100}          // move a window to absolute position
  *
  * Events (only for subscribed clients):
  *   {"event":"window-opened","window":{...}}
@@ -132,6 +145,10 @@ class IpcServer(private val c: Compositor) {
             when {
                 n > 0 -> client.buffer.append(chunk.decodeToString(0, n))
                 n == 0 -> {
+                    // process any complete line received right before the peer half-closed
+                    // (a `printf ... | nc -U` client sends data then EOF in one go), otherwise
+                    // the request is silently dropped
+                    processBuffer(client)
                     dropClient(fd)
                     return
                 }
@@ -226,6 +243,57 @@ class IpcServer(private val c: Compositor) {
                 ok { }
             }
 
+            // input injection for automated/headless testing (drives the seat directly)
+            "pointer-move" -> {
+                val x = request["x"]?.jsonPrimitive?.int ?: return error("missing x")
+                val y = request["y"]?.jsonPrimitive?.int ?: return error("missing y")
+                mjc_pointer_move(c.server, x, y)
+                ok { }
+            }
+
+            "button" -> {
+                val button = request["button"]?.jsonPrimitive?.int ?: BTN_LEFT
+                val pressed = request["pressed"]?.jsonPrimitive?.content?.toBoolean() ?: true
+                mjc_pointer_button(c.server, button.toUInt(), pressed)
+                ok { }
+            }
+
+            "click" -> {
+                val button = request["button"]?.jsonPrimitive?.int ?: BTN_LEFT
+                val x = request["x"]?.jsonPrimitive?.int
+                val y = request["y"]?.jsonPrimitive?.int
+                if (x != null && y != null) {
+                    mjc_pointer_move(c.server, x, y)
+                }
+                mjc_pointer_button(c.server, button.toUInt(), true)
+                mjc_pointer_button(c.server, button.toUInt(), false)
+                ok { }
+            }
+
+            "key" -> {
+                val code = request["code"]?.jsonPrimitive?.int ?: return error("missing code")
+                val pressed = request["pressed"]?.jsonPrimitive?.content?.toBoolean()
+                if (pressed == null) {
+                    // no explicit state -> tap (press + release)
+                    mjc_key(c.server, code.toUInt(), true)
+                    mjc_key(c.server, code.toUInt(), false)
+                } else {
+                    mjc_key(c.server, code.toUInt(), pressed)
+                }
+                ok { }
+            }
+
+            // move a window directly (deterministic; injected drag can't start an
+            // interactive move because the client's move request needs a real grab serial)
+            "move" -> {
+                val id = request["id"]?.jsonPrimitive?.long ?: return error("missing id")
+                val x = request["x"]?.jsonPrimitive?.int ?: return error("missing x")
+                val y = request["y"]?.jsonPrimitive?.int ?: return error("missing y")
+                val info = c.windows.byId(id) ?: return error("no window $id")
+                mjc_view_set_position(info.ptr, x, y)
+                ok { }
+            }
+
             else -> error("unknown cmd $cmd")
         }
     }

BIN
fix-it/20260615_150257.jpg


BIN
fix-it/Snímek obrazovky z 2026-06-15 20-54-18.png


BIN
fix-it/Snímek obrazovky z 2026-06-15 21-05-48.png


+ 2 - 1
shared/shared.gradle.kts

@@ -161,6 +161,7 @@ kotlin {
                 implementation(compose.animation)
                 implementation(compose.animationGraphics)
                 implementation(compose.components.resources)
+                implementation(compose.components.uiToolingPreview)
                 // lifecycles
                 implementation(libs.androidx.lifecycle.viewmodel)
                 implementation(libs.androidx.lifecycle.runtime.compose)
@@ -276,7 +277,7 @@ kotlin {
                 // reflection
                 implementation(kotlin("reflect"))
                 // preview
-                implementation(compose.components.uiToolingPreview)
+//                implementation(compose.components.uiToolingPreview)
                 // preview rendering in the IDE (org.jetbrains.compose.ui:ui-tooling)
                 implementation(compose.uiTooling)
                 // activity

+ 12 - 7
shared/src/commonMain/kotlin/org/mjdev/desktop/components/image/GifView.kt

@@ -1,11 +1,11 @@
 package org.mjdev.desktop.components.image
 
-import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.Image
 import androidx.compose.foundation.layout.BoxWithConstraints
 import androidx.compose.runtime.*
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.ImageBitmap
-import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.layout.ContentScale
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
@@ -31,11 +31,16 @@ fun GifView(
     onFail: (error: Throwable) -> Unit = { e -> Log.e("Failed to load GIF: ${state.src}", e) },
 ) = withDesktopContext {
     BoxWithConstraints {
-        Canvas(modifier = modifier) {
-            drawImage(
-                image = state.currentImage ?: ImageBitmap(1, 1),
-                srcSize = IntSize(state.currentImage?.width ?: 0, state.currentImage?.height ?: 0),
-                dstSize = IntSize(constraints.maxWidth, constraints.maxHeight),
+        // Render the decoded frame via the standard Image composable (the same path AsyncImage
+        // uses for bitmaps). The previous Canvas { drawImage(srcSize, dstSize) } stopped painting
+        // anything after the Compose bump — frames decoded fine but never appeared (gray bg).
+        val image = state.currentImage
+        if (image != null) {
+            Image(
+                bitmap = image,
+                contentDescription = null,
+                modifier = modifier,
+                contentScale = ContentScale.Crop,
             )
         }
         LaunchedEffect(state.currentFrame) {

+ 10 - 2
shared/src/commonMain/kotlin/org/mjdev/desktop/extensions/DoubleExt.kt

@@ -1,5 +1,7 @@
 package org.mjdev.desktop.extensions
 
+import kotlin.math.roundToLong
+
 object DoubleExt {
     fun Double.toMemorySizeReadable(): String {
         val bytes = toLong()
@@ -15,6 +17,12 @@ object DoubleExt {
         }
     }
 
-    // todo
-    fun String.format(dbl: Double): String = "not yet implemented"
+    // Minimal KMP-safe replacement for the JVM-only String.format: this object only ever uses the
+    // "%.1f" token (one decimal place), so substitute that with a hand-rounded one-decimal number.
+    fun String.format(dbl: Double): String {
+        val scaled = (dbl * 10.0).roundToLong()
+        val sign = if (scaled < 0) "-" else ""
+        val abs = if (scaled < 0) -scaled else scaled
+        return replace("%.1f", "$sign${abs / 10}.${abs % 10}")
+    }
 }

+ 15 - 11
shared/src/commonMain/kotlin/org/mjdev/desktop/managers/palette/Palette.kt

@@ -11,6 +11,7 @@ package org.mjdev.desktop.managers.palette
 import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.lerp
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
@@ -30,6 +31,7 @@ class Palette(
     val scope: CoroutineScope = context.scope,
     val initialColor: Color = Color.SuperDarkGray,
     val borderFactor: Float = 0.1f,
+    val borderBlend: Float = 0.5f,
     val textFactor: Float = 0.6f,
     val cutPercent: Int = 6,
 ) : IPalette {
@@ -63,17 +65,17 @@ class Palette(
         get() = backgroundColor.isLightColor
 
     override val borderColor
-        get() =
-            if (isLight) {
-                backgroundColor.darker(borderFactor)
-            } else {
-                backgroundColor.lighter(borderFactor)
-            }
+        // Blend the background toward the (already-contrasting) icon tint: the border stays part
+        // of the wallpaper's palette but is reliably visible on both light and dark backgrounds.
+        // The old darker/lighter(borderFactor=0.1) was too subtle to see on light wallpapers.
+        get() = lerp(backgroundColor, iconsTintColor, borderBlend)
 
     override val iconsTintColor
         get() =
-            if (textColor.isLightColor) {
-                textColor
+            if (isLight) {
+                // light background -> darken so the tint stays visible (the old branch lightened an
+                // already-light background to ~white, making widgets like MemoryChart invisible)
+                backgroundColor.darker(textFactor)
             } else {
                 backgroundColor.lighter(textFactor)
             }
@@ -125,11 +127,13 @@ class Palette(
                         rightBottomPart.topMostColor,
                     )
                 val background = colors.darkestColor
+                // Derive text from the NEW background (assigned below), not the stale state value,
+                // which made the derived colors lag one wallpaper behind.
                 val text =
-                    if (isLight) {
-                        backgroundColor.darker(textFactor)
+                    if (background.isLightColor) {
+                        background.darker(textFactor)
                     } else {
-                        backgroundColor.lighter(textFactor)
+                        background.lighter(textFactor)
                     }
                 backgroundColorState.value = background
                 textColorState.value = text

+ 4 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/controlcenter/ControlCenterWindow.kt

@@ -104,6 +104,10 @@ fun ControlCenterWindow(
                     controlCenterState.showOrFocus()
                 }
             }
+            // Intentionally NO pointer-leave autohide: the control center must stay open when the
+            // pointer moves off it (it vanished on every mouse-leave before, which felt broken).
+            // It closes on a click outside instead — the desktop's onLeftMouseClick hides it, and
+            // focus loss does too. Works in JVM and nested alike.
         },
     ) {
         ControlCenter(

+ 32 - 3
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/dockbar/DockBarWindow.kt

@@ -57,6 +57,9 @@ fun DockBarWindow(
     onAppClick: DesktopContextScope.(IApp) -> Unit = { app ->
         runAsync {
             app.start()
+            // Launching from the dock dismisses the apps menu, mirroring AppsMenuWindow's own
+            // onAppClick — the menu should not linger over a freshly started app.
+            menuState.hide()
         }
     },
     onAppContextMenuClick: (IApp) -> Unit = {},
@@ -111,6 +114,18 @@ fun DockBarWindow(
             height = size.height,
         )
     }
+    // bounds of the *expanded* dock — used to autohide on pointer-leave (decoupled from focus,
+    // per the docking UX spec). The mouseRange above is only the thin bottom reveal hotspot, so
+    // it cannot tell when the pointer has left the visible dock; this range can.
+    val leaveRange by rememberCalculated(containerSize) {
+        val expandedHeight = panelHeight(true)
+        MouseRange(
+            x = 0.dp,
+            y = containerSize.height - expandedHeight,
+            width = containerSize.width,
+            height = expandedHeight,
+        )
+    }
     ChromeWindow(
         name = "DockBar",
         visible = true,
@@ -147,8 +162,20 @@ fun DockBarWindow(
         onGlobalMouse = {
             onPointerEnter(mouseRange) {
                 runAsync {
-                    if (menuState.isNotVisible) {
-                        panelState.showOrFocus()
+                    // show() only on a real reveal (not showOrFocus): re-focusing an already-open
+                    // dock churns focus-follows-mouse and flip-flops the size, drifting geometry.
+                    if (menuState.isNotVisible && panelState.isNotVisible) {
+                        panelState.show()
+                    }
+                }
+            }
+            // autohide when the pointer leaves the expanded dock (and no menu is open, which
+            // must keep the dock alive). Driven by pointer position, not focus, so it no longer
+            // gets stuck open over the non-focusable desktop.
+            onPointerLeave(leaveRange) {
+                runAsync {
+                    if (panelState.isVisible && menuState.isNotVisible) {
+                        panelState.hide()
                     }
                 }
             }
@@ -169,7 +196,9 @@ fun DockBarWindow(
             onLanguageClick = onLanguageClick,
             onTooltip = onTooltip,
             onFocusChange = { focused ->
-                if (focused) {
+                // Only re-show on a real transition into focus while hidden; re-showing an already
+                // visible dock on every focus flicker (focus-follows-mouse) was a churn source.
+                if (focused && panelState.isNotVisible) {
                     runAsync {
                         panelState.show()
                         if (menuState.isVisible) {

+ 9 - 12
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/main/MainWindow.kt

@@ -143,16 +143,10 @@ fun MainWindow() =
             onTooltip = onTooltip,
             panelState = panelState,
             menuState = menuState,
-            onFocusChange = { focused ->
-                val menuIsVisible = appsMenuState.isVisible || menuState.isVisible
-                if (panelState.enabled) {
-                    if (!menuIsVisible && !focused) {
-                        runAsync {
-                            panelState.hide()
-                        }
-                    }
-                }
-            },
+            // Autohide is driven purely by pointer-leave (see DockBarWindow.onGlobalMouse), NOT by
+            // focus. Hiding on focus-loss flooded hide() under focus-follows-mouse (every pointer
+            // flicker over a non-focused window fired a hide) and flip-flopped the dock 16<->80.
+            onFocusChange = {},
         )
         AppsMenuWindow(
             menuState = menuState,
@@ -169,9 +163,12 @@ fun MainWindow() =
         ControlCenterWindow(
             onTooltip = onTooltip,
             controlCenterState = controlCenterState,
+            // Close on focus-loss = the "click outside" dismissal. Guard on isVisible so it does not
+            // fire a hide() on every focus flicker while already hidden (focus-follows-mouse would
+            // otherwise flood runAsync). Pointer-leave no longer hides it, so it stays open until a
+            // real click moves focus away (or a desktop click via onLeftMouseClick).
             onFocusChange = { focused ->
-//            Log.d("control center focus : $focused")
-                if (!focused) {
+                if (!focused && controlCenterState.isVisible) {
                     runAsync {
                         controlCenterState.hide()
                     }

+ 11 - 15
shared/src/desktopMain/kotlin/org/mjdev/desktop/helpers/mouseevents/MouseEventHandler.kt

@@ -32,22 +32,18 @@ class MouseEventHandler(
     fun onEvent(point: Point) {
         if (!isEnabled()) return
         val offset = DpOffset(point.x.dp, point.y.dp)
-        listeners
-            .filter { ev ->
-                ev.type == MouseEventType.ENTER
-            }.filter { ev ->
-                ev.isInRange(offset)
-            }.forEach { l ->
-                l.block(offset)
-            }
-        listeners
-            .filter { ev ->
-                ev.type == MouseEventType.LEAVE
-            }.filter { ev ->
-                !ev.isInRange(offset)
-            }.forEach { l ->
-                l.block(offset)
+        // Edge-triggered: fire only when the pointer crosses a range boundary, never on every
+        // in/out sample. This stops the high-frequency pointer feed from flooding the coroutine
+        // scope (which froze the bars) and from flickering the windows by re-firing show/hide.
+        listeners.forEach { listener ->
+            val inRange = listener.isInRange(offset)
+            val wasInRange = listener.lastInRange
+            listener.lastInRange = inRange
+            when (listener.type) {
+                MouseEventType.ENTER -> if (inRange && !wasInRange) listener.block(offset)
+                MouseEventType.LEAVE -> if (!inRange && wasInRange) listener.block(offset)
             }
+        }
     }
 
     fun onPointerEnter(

+ 7 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/helpers/mouseevents/MouseEventListener.kt

@@ -16,6 +16,13 @@ class MouseEventListener(
     val range: MouseRange,
     val block: (offset: DpOffset) -> Unit,
 ) {
+    /**
+     * Last known in-range state, used for edge-triggering. The pointer feed is high frequency
+     * (compositor streams every motion sample), so firing on every in/out sample floods the
+     * coroutine scope and flickers the UI. We instead fire only on the boundary crossing.
+     */
+    var lastInRange: Boolean = false
+
     fun isInRange(offset: DpOffset): Boolean {
         val inX = offset.x >= range.x && offset.x <= (range.x + range.width)
         val inY = offset.y >= range.y && offset.y <= (range.y + range.height)