diff --git a/backends/wayland/include/display_server_ports.hpp b/backends/wayland/include/display_server_ports.hpp index dd8d3a6..fe2bda6 100644 --- a/backends/wayland/include/display_server_ports.hpp +++ b/backends/wayland/include/display_server_ports.hpp @@ -23,10 +23,8 @@ class DisplayServerInputPort final : public InputPort { void ungrab_all_keys() override; void grab_button(WindowId window, uint8_t button, uint16_t mods) override; void ungrab_all_buttons(WindowId window) override; - void grab_button_any(WindowId window) override; void grab_pointer() override; void ungrab_pointer() override; - void allow_events(bool replay) override; void warp_pointer(WindowId window, Vec2i16 pos) override; void warp_pointer_abs(Vec2i16 pos) override; void flush() override; diff --git a/backends/wayland/src/ports/display_server_input_port.cpp b/backends/wayland/src/ports/display_server_input_port.cpp index 780637c..07539fb 100644 --- a/backends/wayland/src/ports/display_server_input_port.cpp +++ b/backends/wayland/src/ports/display_server_input_port.cpp @@ -24,7 +24,6 @@ void DisplayServerInputPort::send_intercepts() { void DisplayServerInputPort::grab_button(WindowId, uint8_t, uint16_t) {} void DisplayServerInputPort::ungrab_all_buttons(WindowId) {} -void DisplayServerInputPort::grab_button_any(WindowId) {} void DisplayServerInputPort::grab_pointer() { backend_.grab_pointer(); @@ -34,8 +33,6 @@ void DisplayServerInputPort::ungrab_pointer() { backend_.ungrab_pointer(); } -void DisplayServerInputPort::allow_events(bool) {} - void DisplayServerInputPort::warp_pointer(WindowId, Vec2i16 pos) { backend_.warp_pointer(pos.x(), pos.y()); } diff --git a/backends/x11/include/x11_backend.hpp b/backends/x11/include/x11_backend.hpp index be51e3a..d6e95d0 100644 --- a/backends/x11/include/x11_backend.hpp +++ b/backends/x11/include/x11_backend.hpp @@ -60,17 +60,6 @@ class X11Backend final : public Backend { void set_wm_state_normal(WindowId win); void reload_border_colors(); - // Focus arbiter: one pending focus request per tick, highest priority wins. - // Consumed at end of pump_events() after all X events and backend effects. - // kPointer — EnterNotify / button click (always wins) - // kEWMH — _NET_ACTIVE_WINDOW client message, new window at MapRequest - // kWorkspace — workspace switch, restore_visible_focus, reload - // kNone — no request this tick - enum FocusPriority { kFocusNone = 0, kFocusWorkspace = 1, kFocusEWMH = 2, kFocusPointer = 3 }; - WindowId pending_focus_win_ = NO_WINDOW; - FocusPriority pending_focus_priority_ = kFocusNone; - void request_focus(WindowId win, FocusPriority priority); - // Timestamp from the most recent user-input X event (button/key/motion/enter). // Used for xcb_set_input_focus to satisfy clients that reject timestamp=0. xcb_timestamp_t last_event_time_ = XCB_CURRENT_TIME; @@ -127,7 +116,6 @@ class X11Backend final : public Backend { void handle_enter_notify(xcb_enter_notify_event_t* ev); void apply_core_backend_effects(); void apply_xresources(Core& core); - void restore_visible_focus(); void update_focus(event::FocusChanged ev); // X11 state only, no emit // ewmh_on_* — synchronous EWMH/ICCCM reactions called from X event // handlers BEFORE core.dispatch runs, because WindowUnmapped needs the diff --git a/backends/x11/src/backend/adopt.cpp b/backends/x11/src/backend/adopt.cpp index 591e668..e89fe78 100644 --- a/backends/x11/src/backend/adopt.cpp +++ b/backends/x11/src/backend/adopt.cpp @@ -33,6 +33,7 @@ struct RestartState { struct WindowMetadata { std::string wm_instance; std::string wm_class; + std::string title; WindowType type = WindowType::Normal; bool wm_fixed_size = false; bool wm_no_decorations = false; @@ -73,20 +74,19 @@ RestartState load_restart_state() { return out; } -std::string read_window_title(XConnection& xconn, xcb_window_t win, - xcb_atom_t net_wm_name, xcb_atom_t utf8_string) { - auto title = xconn.get_text_property(win, net_wm_name, utf8_string); - if (title.empty()) - title = xconn.get_text_property(win, XCB_ATOM_WM_NAME, XCB_ATOM_STRING); - return title; -} - WindowMetadata read_window_metadata(XConnection& xconn, WindowId window) { WindowMetadata out; auto [instance, cls] = xconn.get_wm_class(window); out.wm_instance = std::move(instance); out.wm_class = std::move(cls); + static const auto named = xconn.intern_atoms({ "_NET_WM_NAME", "UTF8_STRING" }); + static xcb_atom_t net_wm_name = named.at("_NET_WM_NAME"); + static xcb_atom_t utf8_string = named.at("UTF8_STRING"); + out.title = xconn.get_text_property(window, net_wm_name, utf8_string); + if (out.title.empty()) + out.title = xconn.get_text_property(window, XCB_ATOM_WM_NAME, XCB_ATOM_STRING); + const auto& atoms = window_type_atoms(xconn); auto types = xconn.get_atom_list_property(window, atoms.net_wm_window_type); if (has_atom(types, atoms.modal)) out.type = WindowType::Modal; @@ -105,7 +105,8 @@ StartupSnapshot X11Backend::scan_existing_windows() { constexpr uint32_t kManagedEventMask = XCB_EVENT_MASK_STRUCTURE_NOTIFY | XCB_EVENT_MASK_ENTER_WINDOW | - XCB_EVENT_MASK_FOCUS_CHANGE; + XCB_EVENT_MASK_FOCUS_CHANGE | + XCB_EVENT_MASK_PROPERTY_CHANGE; StartupSnapshot result; auto& out = result.windows; @@ -119,8 +120,6 @@ StartupSnapshot X11Backend::scan_existing_windows() { "_NET_WM_WINDOW_TYPE_NOTIFICATION", "_NET_WM_WINDOW_TYPE_TOOLTIP", "_NET_WM_WINDOW_TYPE_DND", - "_NET_WM_NAME", - "UTF8_STRING", "WM_STATE", }); xcb_atom_t NET_WM_WINDOW_TYPE = atoms["_NET_WM_WINDOW_TYPE"]; @@ -129,8 +128,6 @@ StartupSnapshot X11Backend::scan_existing_windows() { xcb_atom_t NET_WM_WINDOW_TYPE_NOTIFICATION = atoms["_NET_WM_WINDOW_TYPE_NOTIFICATION"]; xcb_atom_t NET_WM_WINDOW_TYPE_TOOLTIP = atoms["_NET_WM_WINDOW_TYPE_TOOLTIP"]; xcb_atom_t NET_WM_WINDOW_TYPE_DND = atoms["_NET_WM_WINDOW_TYPE_DND"]; - xcb_atom_t NET_WM_NAME = atoms["_NET_WM_NAME"]; - xcb_atom_t UTF8_STRING = atoms["UTF8_STRING"]; xcb_atom_t WM_STATE = atoms["WM_STATE"]; auto children = xconn.query_tree_children(root_window); @@ -167,9 +164,8 @@ StartupSnapshot X11Backend::scan_existing_windows() { continue; } - auto [instance, cls] = xconn.get_wm_class(win); - auto title = read_window_title(xconn, win, NET_WM_NAME, UTF8_STRING); - bool identifiable = !instance.empty() || !cls.empty() || !title.empty(); + auto meta = read_window_metadata(xconn, win); + bool identifiable = !meta.wm_instance.empty() || !meta.wm_class.empty() || !meta.title.empty(); int wm_state = xconn.get_wm_state_value(win, WM_STATE); bool iconic = (wm_state == ICCCM_ICONIC_STATE); @@ -187,7 +183,6 @@ StartupSnapshot X11Backend::scan_existing_windows() { !unsupported_type && identifiable; - auto meta = read_window_metadata(xconn, win); ExistingWindowSnapshot snap{}; snap.window = win; snap.currently_viewable = (attrs.map_state == XCB_MAP_STATE_VIEWABLE); @@ -195,6 +190,7 @@ StartupSnapshot X11Backend::scan_existing_windows() { snap.from_restart = from_restart; snap.wm_instance = std::move(meta.wm_instance); snap.wm_class = std::move(meta.wm_class); + snap.title = std::move(meta.title); snap.type = meta.type; snap.hints.fixed_size = meta.wm_fixed_size; snap.hints.no_decorations = meta.wm_no_decorations; @@ -217,11 +213,35 @@ StartupSnapshot X11Backend::scan_existing_windows() { LOG_DEBUG( "scan_existing_windows: candidate %u map_state=%u class='%s' instance='%s' source=%s default_manage=%d", - win, attrs.map_state, cls.c_str(), instance.c_str(), + win, attrs.map_state, snap.wm_class.c_str(), snap.wm_instance.c_str(), from_restart ? "snapshot" : "scan", default_manage ? 1 : 0); out.push_back(std::move(snap)); } - LOG_INFO("scan_existing_windows: discovered %d candidate window(s)", (int)out.size()); + // Resolve native X input focus up to a top-level (direct child of root). + // Electron/VSCode give input focus to an unmanaged sub-surface; walk the + // parent chain until we land on a window that's a direct child of root. + xcb_window_t focus = xconn.get_input_focus(); + if (focus != XCB_WINDOW_NONE && focus != root_window) { + for (int depth = 0; depth < 8; depth++) { + bool is_toplevel = false; + for (auto c : children) { + if (c == focus) { + is_toplevel = true; break; + } + } + if (is_toplevel) { + result.focused_window = focus; + break; + } + auto parent = xconn.query_parent(focus); + if (!parent) + break; + focus = *parent; + } + } + + LOG_INFO("scan_existing_windows: discovered %d candidate window(s), focus=%u", + (int)out.size(), (unsigned)result.focused_window); return result; } diff --git a/backends/x11/src/backend/events.cpp b/backends/x11/src/backend/events.cpp index dd60ae9..1173d78 100644 --- a/backends/x11/src/backend/events.cpp +++ b/backends/x11/src/backend/events.cpp @@ -387,9 +387,8 @@ void X11Backend::handle_map_request(xcb_map_request_event_t* ev) { mapped_window && mapped_window->is_visible()) { (void)core.dispatch(command::atom::FocusWindow{ ev->window }); - request_focus(ev->window, kFocusEWMH); } else { - restore_visible_focus(); + core.focus(NO_WINDOW); } LOG_DEBUG("MapRequest(%d): parent %d", ev->window, ev->parent); @@ -491,10 +490,8 @@ void X11Backend::handle_unmap_notify(xcb_unmap_notify_event_t* ev) { (void)core.dispatch(command::atom::RemoveWindowFromAllWorkspaces{ ev->window }); ewmh_on_window_unmapped(event::WindowUnmapped{ ev->window, /*withdrawn=*/ true }); runtime.post_event(event::WindowUnmapped{ ev->window, /*withdrawn=*/ true }); - if (ws_visible) { + if (ws_visible) (void)core.dispatch(command::atom::ReconcileNow{}); - restore_visible_focus(); - } LOG_DEBUG("UnmapNotify(%d): borderless client withdrawal, unmanaging", ev->window); return; } @@ -510,10 +507,8 @@ void X11Backend::handle_unmap_notify(xcb_unmap_notify_event_t* ev) { (void)core.dispatch(command::atom::RemoveWindowFromAllWorkspaces{ ev->window }); ewmh_on_window_unmapped(event::WindowUnmapped{ ev->window, /*withdrawn=*/ true }); runtime.post_event(event::WindowUnmapped{ ev->window, /*withdrawn=*/ true }); - if (ws_visible) { + if (ws_visible) (void)core.dispatch(command::atom::ReconcileNow{}); - restore_visible_focus(); - } LOG_DEBUG("UnmapNotify(%d): client withdrawal, unmanaging", ev->window); } @@ -544,10 +539,8 @@ void X11Backend::handle_destroy_notify(xcb_destroy_notify_event_t* ev) { runtime.post_event(event::WindowUnmapped{ ev->window, /*withdrawn=*/ true }); ewmh_update_client_list(); - if (ws_visible) { + if (ws_visible) (void)core.dispatch(command::atom::ReconcileNow{}); - restore_visible_focus(); - } LOG_DEBUG("DestroyNotify(%d)", ev->window); } @@ -903,40 +896,15 @@ void X11Backend::handle_focus_event(xcb_focus_in_event_t* ev) { return; } + // Pure theft-protection (mirrors dwm's focusin(), dwm.c:814). Core owns + // the "who is focused" decision — this path ONLY re-asserts X focus when + // the X server disagrees with core. Never writes core state from FocusIn: + // that inversion is what the focus refactor deleted. if (ev->event == root_window) return; - - // dwm-style focusin: if a window stole focus from our selection via an - // indirect/synthetic route (NotifyWhileGrabbed, NotifyPointerRoot, etc.), - // reassert focus. Only act on NotifyNormal/NotifyWhileGrabbed and only - // when the event is not from a pointer crossing (detail != NotifyInferior). - // Do NOT reassert for NotifyPointer/NotifyVirtual — those are legitimate - // focus changes initiated by us or the user. auto sel = core.focused_window_state(); - if (sel && ev->event != sel->id && - (ev->detail != XCB_NOTIFY_DETAIL_POINTER && - ev->detail != XCB_NOTIFY_DETAIL_POINTER_ROOT && - ev->detail != XCB_NOTIFY_DETAIL_NONE) && - ev->mode == XCB_NOTIFY_MODE_WHILE_GRABBED) { + if (sel && ev->event != sel->id) xconn.focus_window(sel->id); - return; - } - - auto window = core.window_state_any(ev->event); - if (!window || !window->is_visible()) - return; - - // Sync internal focus state only — do NOT call xconn.focus_window() here. - // Calling xcb_set_input_focus in response to a FocusIn event creates a - // ping-pong loop: our set_input_focus → X sends FocusOut(A)+FocusIn(B) → - // we call set_input_focus again → FocusOut(B)+FocusIn(A) → ... This - // causes thousands of FocusIn/FocusOut events per second and makes the - // focused application (e.g. VSCode with multiple managed child windows) - // freeze: it keeps receiving FocusIn/FocusOut and cannot process input. - // The actual X focus has already been set by whichever path triggered this - // FocusIn (EnterNotify, button press, EWMH, keybinding). Here we only need - // to keep the WM's internal focused-window pointer in sync. - (void)core.dispatch(command::atom::FocusWindow{ ev->event }); } void X11Backend::handle_button_event(xcb_button_press_event_t* ev) { @@ -952,6 +920,12 @@ void X11Backend::handle_button_event(xcb_button_press_event_t* ev) { void X11Backend::handle_motion_notify(xcb_motion_notify_event_t* ev) { last_pointer_ = { ev->root_x, ev->root_y }; + // Follow pointer across monitors even when it's over empty root area + // (between windows, on bar strips, between monitors). EnterNotify alone + // isn't enough — it only fires on window boundaries, so crossing via + // root would leave focused_monitor stale until the pointer hits a window. + if (ev->event == root_window) + core.focus_monitor_at_point(ev->root_x, ev->root_y); runtime.post_event(event::MotionEv{ ev->event, { ev->root_x, ev->root_y }, ev->state }); } @@ -1007,21 +981,14 @@ void X11Backend::handle_enter_notify(xcb_enter_notify_event_t* ev) { last_event_time_ = ev->time; last_pointer_ = { ev->root_x, ev->root_y }; - // Use window_state_any so that windows on the second monitor's active - // workspace are found even when focused_monitor hasn't been updated yet - // (focused_monitor is only updated on button press / motion, not on enter). auto window = core.window_state_any(ev->event); if (!window || !window->is_visible()) return; - // Keep focused_monitor in sync so subsequent workspace/layout ops target - // the correct monitor without requiring a click first. - core.focus_monitor_at_point(ev->root_x, ev->root_y); - + // Route pointer-enter focus through the single source of truth. + // Focusing a window on another monitor updates focused_monitor_ as + // part of the focus intent, so no separate focus_monitor_at_point call. (void)core.dispatch(command::atom::FocusWindow{ ev->event }); - // Request focus at kPointer priority — applied after apply_core_backend_effects() - // so pointer always wins over stale workspace-switch FocusWindow effects. - request_focus(ev->event, kFocusPointer); } // TODO: temporary adapter. The cleaner end state is for X11Backend itself to diff --git a/backends/x11/src/backend/ewmh.cpp b/backends/x11/src/backend/ewmh.cpp index 34829d0..e58d058 100644 --- a/backends/x11/src/backend/ewmh.cpp +++ b/backends/x11/src/backend/ewmh.cpp @@ -49,16 +49,6 @@ void X11Backend::set_border_color(WindowId win, uint32_t pixel) { xw->set_border_color(pixel); } -void X11Backend::restore_visible_focus() { - if (auto focused = core.focused_window_state(); focused && focused->is_visible()) { - request_focus(focused->id, kFocusWorkspace); - } else { - // No visible focused window — fall back to root immediately (no arbiter needed). - xconn.focus_window(root_window); - core.emit_focus_changed(NO_WINDOW); - } -} - void X11Backend::ewmh_intern_atoms() { ewmh_atoms_.resolve(xconn.raw()); } @@ -448,7 +438,6 @@ bool X11Backend::handle(event::ClientMessageEv ev) { return true; (void)core.dispatch(command::atom::FocusWindow{ ev.window }); - request_focus(ev.window, kFocusEWMH); return true; } diff --git a/backends/x11/src/backend/loop.cpp b/backends/x11/src/backend/loop.cpp index 45b14da..d2a5d09 100644 --- a/backends/x11/src/backend/loop.cpp +++ b/backends/x11/src/backend/loop.cpp @@ -53,13 +53,6 @@ void apply_window_flush(const WindowFlush& flush, X11Window& xw) { } // namespace -void X11Backend::request_focus(WindowId win, FocusPriority priority) { - if (priority >= pending_focus_priority_) { - pending_focus_win_ = win; - pending_focus_priority_ = priority; - } -} - int X11Backend::event_fd() const { return xconn.fd(); } @@ -87,6 +80,15 @@ void X11Backend::apply_core_backend_effects() { apply_window_flush(*flush, *xw); } } + // Re-assert managed event mask on every map. After exec-restart + // adopt sets it once, but some clients replace their own event + // mask on unmap/re-map cycles, which silently drops our + // Enter/Focus/Structure subscriptions. + uint32_t mask = XCB_EVENT_MASK_STRUCTURE_NOTIFY + | XCB_EVENT_MASK_ENTER_WINDOW + | XCB_EVENT_MASK_FOCUS_CHANGE + | XCB_EVENT_MASK_PROPERTY_CHANGE; + xconn.change_window_attributes(e.window, XCB_CW_EVENT_MASK, &mask); xw->set_wm_state_normal(); xw->map(); xw->send_expose(); @@ -109,11 +111,10 @@ void X11Backend::apply_core_backend_effects() { } case BackendEffectKind::FocusWindow: if (e.window != NO_WINDOW) - request_focus(e.window, kFocusWorkspace); + focus_window(e.window); break; case BackendEffectKind::FocusRoot: - // Focus root only if no higher-priority request is pending. - if (root_window != NO_WINDOW && pending_focus_priority_ == kFocusNone) + if (root_window != NO_WINDOW) xconn.focus_window(root_window); break; case BackendEffectKind::UpdateWindow: @@ -150,9 +151,6 @@ void X11Backend::apply_core_backend_effects() { } void X11Backend::pump_events(std::size_t max_events_per_tick) { - pending_focus_win_ = NO_WINDOW; - pending_focus_priority_ = kFocusNone; - xcb_motion_notify_event_t* latest_motion = nullptr; std::vector pending_exposes; pending_exposes.reserve(32); @@ -221,17 +219,6 @@ void X11Backend::pump_events(std::size_t max_events_per_tick) { void X11Backend::render_frame() { apply_core_backend_effects(); - // Apply the highest-priority focus request accumulated this tick. - // Runs after drain_events + apply_core_backend_effects so pointer focus - // always wins over stale workspace-switch FocusWindow effects from the - // same tick. - if (pending_focus_win_ != NO_WINDOW) { - focus_window(pending_focus_win_); - core.emit_focus_changed(pending_focus_win_); - pending_focus_win_ = NO_WINDOW; - pending_focus_priority_ = kFocusNone; - } - auto visible_windows = core.visible_window_ids(); for (auto win : visible_windows) { if (auto flush = core.take_window_flush(win)) { @@ -249,14 +236,9 @@ void X11Backend::on_reload_applied() { // Re-raise bars: borderless/fullscreen windows don't go through MapNotify on reload, // so RaiseDocks would never fire without this explicit call. runtime.post_event(event::RaiseDocks{}); - // Apply focus immediately (not via arbiter) — reload happens between pump_events - // and drain_events, so the arbiter won't fire until the next tick. - if (auto focused = core.focused_window_state(); focused && focused->is_visible()) { - focus_window(focused->id); - core.emit_focus_changed(focused->id); - } else { - xconn.focus_window(root_window); - core.emit_focus_changed(NO_WINDOW); - } + // Re-assert focus through the single source of truth. Core::focus() + // always emits FocusChanged on a valid window, which repaints borders + // that reload_border_colors cleared. + core.focus(NO_WINDOW); xconn.flush(); } diff --git a/backends/x11/src/backend/x11_backend.cpp b/backends/x11/src/backend/x11_backend.cpp index 407d467..b9a14c8 100644 --- a/backends/x11/src/backend/x11_backend.cpp +++ b/backends/x11/src/backend/x11_backend.cpp @@ -66,7 +66,7 @@ void X11Backend::on_start(Core& core) { // Seed focused_monitor from the actual pointer position so that after an // exec-restart the WM doesn't assume monitor 0 when the cursor is elsewhere. - // Without this, adopt_existing_windows → SwitchWorkspace → sync_current_focus + // Without this, adopt_existing_windows → SwitchWorkspace → Core::focus // gives X focus to the game on monitor 0 even if the pointer is on monitor 1. auto ptr = xconn.query_pointer(); if (ptr.valid) diff --git a/backends/x11/src/ports/input_port.cpp b/backends/x11/src/ports/input_port.cpp index 0f5c73a..1fcf35b 100644 --- a/backends/x11/src/ports/input_port.cpp +++ b/backends/x11/src/ports/input_port.cpp @@ -66,19 +66,6 @@ class X11InputPort final : public backend::InputPort { xconn.ungrab_button(XCB_BUTTON_INDEX_ANY, (xcb_window_t)window, XCB_MOD_MASK_ANY); } - void grab_button_any(WindowId window) override { - constexpr uint32_t evmask = - XCB_EVENT_MASK_BUTTON_PRESS | XCB_EVENT_MASK_BUTTON_RELEASE; - xconn.grab_button_sync((xcb_window_t)window, evmask, - XCB_BUTTON_INDEX_ANY, XCB_MOD_MASK_ANY); - } - - void allow_events(bool replay) override { - uint8_t mode = replay ? XCB_ALLOW_REPLAY_POINTER : XCB_ALLOW_ASYNC_POINTER; - xconn.allow_events(mode); - xconn.flush(); - } - void grab_pointer() override { constexpr uint32_t mask = XCB_EVENT_MASK_BUTTON_RELEASE | XCB_EVENT_MASK_POINTER_MOTION; diff --git a/core/include/backend/backend.hpp b/core/include/backend/backend.hpp index 29fd5ac..1686a78 100644 --- a/core/include/backend/backend.hpp +++ b/core/include/backend/backend.hpp @@ -28,6 +28,8 @@ struct StartupSnapshot { std::vector windows; // monitor_idx -> active_ws_id from exec-restart state file; empty on first start. std::unordered_map monitor_active_ws; + // Native input focus observed at startup. NO_WINDOW if root/unknown. + WindowId focused_window = NO_WINDOW; }; struct ExistingWindowSnapshot { @@ -53,9 +55,11 @@ struct ExistingWindowSnapshot { Vec2i geo_pos; Vec2i geo_size; - // Metadata snapshot used by rules/policy. + // Metadata read directly from X at adopt time — not persisted across + // restarts. Core caches these; refreshed by backend PropertyNotify. std::string wm_instance; std::string wm_class; + std::string title; WindowType type = WindowType::Normal; command::WindowHints hints; }; diff --git a/core/include/backend/input_port.hpp b/core/include/backend/input_port.hpp index c994129..f25d626 100644 --- a/core/include/backend/input_port.hpp +++ b/core/include/backend/input_port.hpp @@ -59,17 +59,9 @@ class InputPort { // Implementation handles numlock/capslock variants. virtual void grab_button(WindowId window, uint8_t button, uint16_t mods) = 0; virtual void ungrab_all_buttons(WindowId window) = 0; - // Grab any button with any modifier on window using SYNC pointer mode. - // Used for click-to-focus on unfocused windows: WM receives the press, - // calls allow_events(replay=true) to pass it through to the client. - virtual void grab_button_any(WindowId window) = 0; - virtual void grab_pointer() = 0; - virtual void ungrab_pointer() = 0; - // Release the passive button grab after a ButtonPress. - // replay=true → XCB_ALLOW_REPLAY_POINTER: re-deliver the event to the window - // replay=false → XCB_ALLOW_ASYNC_POINTER: discard it (WM consumed the click) - virtual void allow_events(bool replay) = 0; + virtual void grab_pointer() = 0; + virtual void ungrab_pointer() = 0; virtual void warp_pointer(WindowId window, Vec2i16 pos) = 0; // Warp to absolute root-screen coordinates. virtual void warp_pointer_abs(Vec2i16 pos) = 0; diff --git a/core/include/config/bar_config.hpp b/core/include/config/bar_config.hpp index 1c44a18..2e85497 100644 --- a/core/include/config/bar_config.hpp +++ b/core/include/config/bar_config.hpp @@ -26,7 +26,8 @@ enum class BarSlotKind { Tags, Title, Tray, Lua }; struct BarSlot { BarSlotKind kind = BarSlotKind::Lua; LuaRegistryRef widget; // ref to Widget object when kind == Lua - int interval = 1; // 0 = every redraw; >0 = every N seconds + int interval = 1; // 0 = every redraw; >0 = every N seconds + bool has_update = false; // true when widget defines update() method // Runtime state (not part of config, mutated during redraw). mutable std::string cached_text; diff --git a/core/include/domain/core.hpp b/core/include/domain/core.hpp index effbe27..3e9ae36 100644 --- a/core/include/domain/core.hpp +++ b/core/include/domain/core.hpp @@ -130,8 +130,7 @@ class Core { WindowFlush& ensure_window_flush(WindowId win); void mark_window_dirty(WindowId win, uint8_t bits); void sync_workspace_visibility(); - void sync_current_focus(); - void reconcile(); // sync_workspace_visibility + arrange + sync_current_focus + void reconcile(); // sync_workspace_visibility + arrange + focus(NO_WINDOW) // Fire-and-forget event onto the unified queue. Use directly at // call sites — no per-event wrapper methods needed. @@ -149,6 +148,27 @@ class Core { // older queued events — prevents stale focus from overwriting state. void emit_focus_changed(WindowId window); + // ─── SINGLE SOURCE OF TRUTH ────────────────────────────────────── + // Intent chain: + // focused_monitor_id -> Monitor.active_ws -> Workspace::current + // Nothing else stores "who is focused". All reads are derived. All + // writes happen here. The intent entry points — FocusWindow atom, + // FocusMonitor, focus_monitor_at_point, SwitchWorkspace, + // Focus{Next,Prev}Window — are the ONLY writers. FocusChanged is + // emitted HERE, ONCE PER REAL TRANSITION, and nowhere else in the + // codebase. + // + // Mirrors dwm's focus(Client *c) in dwm.c:789. + // + // window == NO_WINDOW behaves like dwm's focus(NULL): pick the best + // visible window on the currently focused workspace, or fall back + // to root focus if none. + // + // If you are about to add a sixth writer, stop. Make it call this + // function instead. + // ───────────────────────────────────────────────────────────────── + void focus(WindowId window); + void register_layout(const std::string& name, LayoutFn fn) { layouts[name] = std::move(fn); } @@ -285,7 +305,7 @@ class Core { WorkspaceId active_workspace_on_monitor(MonitorId mon_idx) const { return wsman.active_workspace(mon_idx); } - const FocusState& focus_state() const { return wsman.get_focus_state(); } + FocusState focus_state() const { return wsman.get_focus_state(); } MonitorId focused_monitor_index() const { return wsman.get_focused_monitor(); } bool focus_monitor_at_point(int x, int y); WorkspaceId active_workspace_at_point(int x, int y) const { @@ -300,13 +320,7 @@ class Core { std::vector all_window_ids() const; WindowRef focused_window_state() const { - MonitorId mon = wsman.get_focused_monitor(); - WorkspaceId ws = wsman.active_workspace(mon); - WindowId w = wsman.last_focused_window(mon, ws); - if (w == NO_WINDOW) { - // Fall back to workspace cursor (covers fresh windows not yet recorded). - w = wsman.get_focus_state().window; - } + WindowId w = wsman.get_focus_state().window; if (w == NO_WINDOW) return nullptr; return wsman.find_window_in_all(w); diff --git a/core/include/domain/ws.hpp b/core/include/domain/ws.hpp index 77b66c3..12a8eb9 100644 --- a/core/include/domain/ws.hpp +++ b/core/include/domain/ws.hpp @@ -41,12 +41,8 @@ struct Workspace { std::shared_ptr focused(); std::shared_ptr focused() const; - // Finds a suitable focused window and advances the internal cursor. - // Use when you actually want to update which window is "current". - std::shared_ptr advance_focus(); - - void focus_next(); - void focus_prev(); + void focus_next(); + void focus_prev(); }; using WorkspaceState = Workspace; @@ -57,11 +53,6 @@ struct FocusState { WindowId window = NO_WINDOW; }; -// Per-monitor focus: remembers the last focused window per workspace. -struct MonitorFocusState { - std::unordered_map last_window_per_ws; -}; - class WorkspaceManager { private: WindowFactory window_factory_; @@ -81,12 +72,7 @@ class WorkspaceManager { std::unordered_map> window_index; std::unordered_map window_workspace; - FocusState focus_; // cache — always derived from focused_monitor_ + monitor_focus_ - MonitorId focused_monitor_{ 0 }; - std::vector monitor_focus_; - - void ensure_monitor_focus_size(); inline bool is_ws_valid(WorkspaceId id) const { return id >= 0 && id < (int)workspaces.size(); @@ -96,7 +82,6 @@ class WorkspaceManager { return id >= 0 && id < (int)monitors.size(); } - void sync_focus_state(); MonitorId monitor_index_by_name(const std::string& name) const; int index_of_ws_in_pool(const std::vector& pool, WorkspaceId ws_id) const; MonitorId monitor_of(WorkspaceId ws_id) const; @@ -196,11 +181,15 @@ class WorkspaceManager { const Workspace& workspace(WorkspaceId id) const; MonitorId get_focused_monitor() const { return focused_monitor_; } - const FocusState& get_focus_state() const { return focus_; } - // Returns the last focused window on mon_idx/ws_id, or NO_WINDOW. - // Validates that the window still exists before returning. - WindowId last_focused_window(MonitorId mon_idx, WorkspaceId ws_id) const; + // Derived view of the intent chain: focused_monitor_ -> active_ws -> + // Workspace::current. Cheap — no caching. Builds a fresh FocusState + // per call. Do NOT reintroduce a stored cache: that was the exact + // source of desync bugs we deleted in phase 2. + FocusState get_focus_state() const; + + // Convenience — equivalent to get_focus_state().window. + WindowId focused_window_id() const; // Explicitly set focused monitor (used by FocusMonitor command). void set_focused_monitor(MonitorId mon_idx); diff --git a/core/include/lua/lua_host.hpp b/core/include/lua/lua_host.hpp index 1d2e461..257c9de 100644 --- a/core/include/lua/lua_host.hpp +++ b/core/include/lua/lua_host.hpp @@ -157,6 +157,14 @@ class LuaHost : public IEventReceiver, public IHookReceiver { bool exec_file(const std::string& path); bool exec_string(const char* code, const char* name = "=prelude"); + // Evaluate `code` like a Lua REPL line and return a human-readable + // result string (expression value(s), captured print() output, or an + // error message). Tries `return ` first so bare expressions + // print their value; falls back to loading as a statement block. + // Intended for debug UIs only — no sandboxing, full access to the + // live Lua state. + std::string repl_eval(const std::string& code); + // Module table registry: a module calls set_module_table(name) in on_lua_init() // to publish its API table (top of stack). lua_module_preload then returns it. void set_module_table(const std::string& name); @@ -169,6 +177,7 @@ class LuaHost : public IEventReceiver, public IHookReceiver { bool call_ref(const LuaRegistryRef& ref, int nargs, int nresults, const char* context) const; bool call_ref_string(const LuaRegistryRef& ref, std::string& out, const char* context) const; bool call_ref_method_string(const LuaRegistryRef& obj_ref, const char* method, std::string& out, const char* context) const; + bool call_ref_method_void(const LuaRegistryRef& obj_ref, const char* method, const char* context) const; bool call_ref_with_int_fields(const LuaRegistryRef& ref, std::initializer_list> fields, const char* context) const; diff --git a/core/include/support/log.hpp b/core/include/support/log.hpp index d667c1c..de90998 100644 --- a/core/include/support/log.hpp +++ b/core/include/support/log.hpp @@ -1,13 +1,18 @@ #pragma once #include -#include +#include +#include #include #include +#include +#include #include #include +#include #include #include +#include #include @@ -36,42 +41,98 @@ std::string log_sprintf(const Fmt& f, Args&&... args) { // Defined inline so all TUs share the same pointer (C++17 inline variables). inline std::shared_ptr g_logger; -// Initialize the global spdlog logger. -// Must be called once at startup before any LOG_* macro is used. +namespace swm::log_detail { + +// Resolve the directory where sirenwm writes its state logs. +// Follows XDG Base Directory Spec: $XDG_STATE_HOME/sirenwm, falling back to +// $HOME/.local/state/sirenwm. Returns empty path if neither env var is set +// (unusual — daemons with no HOME — caller decides what to do). +inline std::filesystem::path resolve_log_dir() { + namespace fs = std::filesystem; + if (const char* xdg = std::getenv("XDG_STATE_HOME"); xdg && *xdg) + return fs::path(xdg) / "sirenwm"; + if (const char* home = std::getenv("HOME"); home && *home) + return fs::path(home) / ".local" / "state" / "sirenwm"; + return {}; +} + +} // namespace swm::log_detail + +// Initialize the global spdlog logger with a rotating file sink + stderr sink. +// `app_name` becomes the log file stem (e.g. "sirenwm" -> "sirenwm.log", +// rotated to "sirenwm.1.log" … "sirenwm.5.log"). Path resolution follows +// XDG_STATE_HOME; the directory is created on first call. // Subsequent calls are no-ops. -// Uses append mode (truncate=false) so exec-restart does not wipe the log. -// Flush on every debug message so nothing is lost on crash/kill. -inline void log_init(const std::string& log_path = "runtime.log", - spdlog::level::level_enum level = spdlog::level::debug) { +// 5 MB × 5 files ≈ 25 MB max retained per app, rotate_on_open=false so +// exec-restart appends to the current file rather than nuking history. +inline void log_init(const std::string& app_name = "sirenwm", + spdlog::level::level_enum level = spdlog::level::debug) { if (g_logger) return; - auto file_sink = std::make_shared( - log_path, /*truncate=*/ false); - auto stderr_sink = std::make_shared(); + namespace fs = std::filesystem; + constexpr std::size_t kMax = 5 * 1024 * 1024; // 5 MiB per file + constexpr std::size_t kN = 5; // rotated file count + + std::vector sinks; - auto logger = std::make_shared("swm", - spdlog::sinks_init_list{ file_sink, stderr_sink }); + auto log_dir = swm::log_detail::resolve_log_dir(); + if (!log_dir.empty()) { + std::error_code ec; + fs::create_directories(log_dir, ec); + if (!ec) { + auto file = (log_dir / (app_name + ".log")).string(); + sinks.push_back(std::make_shared( + file, kMax, kN, /*rotate_on_open=*/ false)); + } + } + sinks.push_back(std::make_shared()); + + auto logger = std::make_shared("swm", sinks.begin(), sinks.end()); logger->set_level(level); logger->set_pattern("[%H:%M:%S.%e] [%^%-5l%$] %v"); - logger->flush_on(spdlog::level::debug); + // flush_on(trace) fires flush() after every accepted record — nothing is + // lost if the process is killed between a log call and orderly shutdown. + logger->flush_on(spdlog::level::trace); spdlog::register_logger(logger); spdlog::set_default_logger(logger); g_logger = logger; } +// Test-only: initialize a logger that swallows output. Used by unit test +// harnesses so they neither spam stderr nor touch the user's $XDG_STATE_HOME. +inline void log_init_null(spdlog::level::level_enum level = spdlog::level::off) { + if (g_logger) + return; + auto sink = std::make_shared(); + auto logger = std::make_shared("swm", sink); + logger->set_level(level); + spdlog::register_logger(logger); + spdlog::set_default_logger(logger); + g_logger = logger; +} + // Printf-style logging macros — format strings use %s/%d/etc. // fmt::sprintf handles the formatting so existing call sites need no changes. #define _LOG_FMT(f_, ...) ::swm::log_detail::log_sprintf(f_, ##__VA_ARGS__) -#define LOG_DEBUG(format, ...) g_logger->debug(_LOG_FMT(format, ##__VA_ARGS__)) -#define LOG_INFO(format, ...) g_logger->info(_LOG_FMT(format, ##__VA_ARGS__)) -#define LOG_WARN(format, ...) g_logger->warn(_LOG_FMT(format, ##__VA_ARGS__)) -#define LOG_ERR(format, ...) g_logger->error(_LOG_FMT(format, ##__VA_ARGS__)) -#define LOG_CRIT(format, ...) g_logger->critical(_LOG_FMT(format, ##__VA_ARGS__)) +// Every LOG_* call flushes immediately — no records lost if the process is +// killed (SIGKILL, oom, crash) between the log call and orderly shutdown. +// The cost is a write()+fsync-free flush per line, which is acceptable here: +// sirenwm is a desktop process, not a high-throughput server. +#define _LOG_EMIT(fn_, format, ...) do { \ + g_logger->fn_(_LOG_FMT(format, ##__VA_ARGS__)); \ + g_logger->flush(); \ + } while (0) + +#define LOG_DEBUG(format, ...) _LOG_EMIT(debug, format, ##__VA_ARGS__) +#define LOG_INFO(format, ...) _LOG_EMIT(info, format, ##__VA_ARGS__) +#define LOG_WARN(format, ...) _LOG_EMIT(warn, format, ##__VA_ARGS__) +#define LOG_ERR(format, ...) _LOG_EMIT(error, format, ##__VA_ARGS__) +#define LOG_CRIT(format, ...) _LOG_EMIT(critical, format, ##__VA_ARGS__) #define LOG_FATAL(format, ...) do { \ g_logger->critical(_LOG_FMT(format, ##__VA_ARGS__)); \ spdlog::shutdown(); \ diff --git a/core/src/core/core_dispatch.cpp b/core/src/core/core_dispatch.cpp index c8e1b1c..a0a5cc7 100644 --- a/core/src/core/core_dispatch.cpp +++ b/core/src/core/core_dispatch.cpp @@ -102,13 +102,49 @@ void Core::sync_workspace_visibility() { } } -void Core::sync_current_focus() { - auto f = wsman.current().advance_focus(); - if (f && f->is_visible()) { - wsman.focus_window(f->id); - emit_backend_effect(BackendEffectKind::FocusWindow, f->id); - emit_focus_changed(f->id); +// ─── FOCUS INTENT — dwm-style focus() ───────────────────────────────────── +// All focus entry points route through here. If you add a new one, ALSO +// route it through here. Do not add a parallel path "because this one +// doesn't emit in my edge case" — instead fix the edge case here. +// ───────────────────────────────────────────────────────────────────────── +void Core::focus(WindowId window) { + // NO_WINDOW: pick the best visible candidate on the currently focused + // workspace (dwm: `for (c = selmon->stack; c && !ISVISIBLE(c); c = c->snext)`). + if (window == NO_WINDOW) { + auto& ws = wsman.current(); + if (auto cand = ws.focused(); cand&& cand->is_visible()) + window = cand->id; + } + + // Validate the target exists on the currently focused monitor's active + // workspace. If not, we fall through to root focus — mirrors dwm's + // focus(NULL) fallback when the chosen client is stale. + if (window != NO_WINDOW) { + auto w = wsman.find_window(window); + if (!w || !w->is_visible()) + window = NO_WINDOW; + } + + if (window != NO_WINDOW) { + if (!wsman.focus_window(window)) + window = NO_WINDOW; + } + + if (window != NO_WINDOW) { + // Mirrors dwm's focus(c) in dwm.c:789: always assert X focus and + // always redraw/emit. Consumers (border painter, EWMH) are + // idempotent — redundant FocusChanged on no-op is harmless, but + // SKIPPING it after reload/restart leaves borders unpainted. + emit_backend_effect(BackendEffectKind::FocusWindow, window); + emit_focus_changed(window); } else { + // Symmetric with the branch above: always emit FocusChanged, let + // consumers be idempotent. Skipping when "prev was NO_WINDOW" is + // unreliable — callers routinely mutate workspace state (e.g. + // SwitchWorkspace) before calling focus(), so `prev` read from + // wsman here reflects the *new* workspace, not the window we are + // defocusing. That left _NET_WM_STATE_FOCUSED stuck on windows + // after switching to an empty workspace. emit_backend_effect(BackendEffectKind::FocusRoot); emit_focus_changed(NO_WINDOW); } @@ -179,23 +215,12 @@ void Core::evaluate_workspace_fullscreen(WorkspaceId ws_id) { } bool Core::focus_monitor_at_point(int x, int y) { - bool changed = wsman.focus_monitor_at_point(x, y); - if (!changed) + // Intent entry point — route through Core::focus(). wsman updates + // focused_monitor_, then focus(NO_WINDOW) picks the best visible window + // on the new monitor's active workspace and emits the single transition. + if (!wsman.focus_monitor_at_point(x, y)) return false; - - // Clear X focus on the old monitor: send focus to root. - emit_backend_effect(BackendEffectKind::FocusRoot); - emit_focus_changed(NO_WINDOW); - - // Restore the last focused window on the new monitor, if any. - int mon = wsman.get_focused_monitor(); - int ws = wsman.active_workspace(mon); - WindowId win = wsman.last_focused_window(mon, ws); - if (win != NO_WINDOW && wsman.find_window_in_all(win)) { - wsman.focus_window(win); - emit_backend_effect(BackendEffectKind::FocusWindow, win); - emit_focus_changed(win); - } + focus(NO_WINDOW); return true; } @@ -220,7 +245,7 @@ void Core::init(std::vector initial_monitors) { void Core::reconcile() { sync_workspace_visibility(); arrange(); - sync_current_focus(); + focus(NO_WINDOW); } void Core::arrange() { @@ -329,10 +354,11 @@ bool Core::dispatch(const command::CommandComposite& cmd) { } bool Core::dispatch(const command::atom::FocusWindow& cmd) { - if (!wsman.focus_window(cmd.window)) + // Intent entry point — single source of truth. All focus state writes + // go through Core::focus(). Do NOT emit FocusChanged here. + if (!wsman.find_window_in_all(cmd.window)) return false; - emit_backend_effect(BackendEffectKind::FocusWindow, cmd.window); - emit_focus_changed(cmd.window); + focus(cmd.window); return true; } @@ -355,7 +381,7 @@ bool Core::dispatch(const command::atom::SwitchWorkspace& cmd) { // Only update X focus when switching workspace on the focused monitor. // Switching on a background monitor must not steal keyboard input. if (target_mon == wsman.get_focused_monitor()) - sync_current_focus(); + focus(NO_WINDOW); evaluate_workspace_fullscreen(cmd.workspace_id); post(event::WorkspaceSwitched{cmd.workspace_id}); @@ -389,13 +415,7 @@ bool Core::dispatch(const command::atom::MoveWindowToWorkspace& cmd) { bool moved_ws_visible = is_workspace_visible(cmd.workspace_id); bool focus_moved = moved_ws_visible && w->is_visible() && !w->suppress_focus_once; - if (focus_moved) { - wsman.focus_window(w->id); - emit_backend_effect(BackendEffectKind::FocusWindow, w->id); - emit_focus_changed(w->id); - } else { - sync_current_focus(); - } + focus(focus_moved ? w->id : NO_WINDOW); return true; } @@ -471,11 +491,10 @@ bool Core::dispatch(const command::atom::SetWindowFullscreen& cmd) { } arrange(); post(event::RaiseDocks{}); - if (ws_id >= 0 && is_workspace_visible(ws_id) && w->is_visible()) { - wsman.focus_window(cmd.window); - emit_backend_effect(BackendEffectKind::FocusWindow, cmd.window); - emit_focus_changed(cmd.window); - } + if (ws_id >= 0 && is_workspace_visible(ws_id) && w->is_visible()) + focus(cmd.window); + else if (ws_id >= 0) + evaluate_workspace_fullscreen(ws_id); return true; } @@ -677,50 +696,31 @@ FullscreenLikeDecision Core::evaluate_fullscreen_like_request(WindowId win, } bool Core::dispatch(const command::composite::FocusNextWindow&) { + // Intent: advance workspace cursor, then route through Core::focus. auto w = wsman.focus_next(); - if (!w || !w->is_visible()) { - emit_focus_changed(NO_WINDOW); - return true; - } - emit_backend_effect(BackendEffectKind::FocusWindow, w->id); - emit_focus_changed(w->id); + focus((w && w->is_visible()) ? w->id : NO_WINDOW); return true; } bool Core::dispatch(const command::composite::FocusPrevWindow&) { auto w = wsman.focus_prev(); - if (!w || !w->is_visible()) { - emit_focus_changed(NO_WINDOW); - return true; - } - emit_backend_effect(BackendEffectKind::FocusWindow, w->id); - emit_focus_changed(w->id); + focus((w && w->is_visible()) ? w->id : NO_WINDOW); return true; } bool Core::dispatch(const command::atom::FocusMonitor& cmd) { + // Intent entry point — route through Core::focus(). set_focused_monitor + // updates wsman state, then focus(NO_WINDOW) picks the best visible + // window on the new monitor's active workspace and emits a single + // transition if focus actually changes. int n = cmd.monitor_index; auto mon = monitor_state(n); if (!mon || mon->active_ws < 0) return false; - int old_mon = wsman.get_focused_monitor(); - if (n != old_mon) { - // Clear X focus on the old monitor. - emit_backend_effect(BackendEffectKind::FocusRoot); - emit_focus_changed(NO_WINDOW); - + if (n != wsman.get_focused_monitor()) { wsman.set_focused_monitor(n); - - // Restore last focused window on the new monitor. - WindowId win = wsman.last_focused_window(n, mon->active_ws); - if (win != NO_WINDOW && wsman.find_window_in_all(win)) { - wsman.focus_window(win); - emit_backend_effect(BackendEffectKind::FocusWindow, win); - emit_focus_changed(win); - } else { - sync_current_focus(); - } + focus(NO_WINDOW); } emit_warp_pointer(mon->center()); @@ -777,7 +777,9 @@ bool Core::dispatch(const command::atom::ApplyMonitorTopology& cmd) { // New monitors come in fresh with top/bottom insets = 0; no reset needed. wsman.assign_workspaces(settings.monitor_aliases, settings.monitor_compose); - post(event::DisplayTopologyChanged{}); + // DisplayTopologyChanged is posted by the caller (runtime hot-plug path), + // not here: initial topology apply at startup is not a "change" and must + // not trigger reactive rebuilds that duplicate what on_start() already does. reconcile(); return true; } diff --git a/core/src/core/workspace_manager.cpp b/core/src/core/workspace_manager.cpp index 6e8841e..d07e322 100644 --- a/core/src/core/workspace_manager.cpp +++ b/core/src/core/workspace_manager.cpp @@ -79,33 +79,6 @@ std::shared_ptr Workspace::focused() { return nullptr; } -std::shared_ptr Workspace::advance_focus() { - bool any_visible = false; - for (auto& w : windows) - if (w && w->is_visible()) { - any_visible = true; - break; - } - - if (current >= 0 && current < (int)windows.size()) { - auto cur = windows[current]; - if (cur && (!any_visible || cur->is_visible())) - return cur; - } - - for (int i = 0; i < (int)windows.size(); i++) { - auto& w = windows[i]; - if (!w) - continue; - if (any_visible && !w->is_visible()) - continue; - current = i; - return w; - } - - return nullptr; -} - std::shared_ptr Workspace::focused() const { bool any_visible = false; for (const auto& w : windows) @@ -253,21 +226,19 @@ void WorkspaceManager::sync_monitors_active_ws() { monitors[i].active_ws = active_ws_of_monitor(MonitorId{ i }); } -void WorkspaceManager::ensure_monitor_focus_size() { - if ((int)monitor_focus_.size() < (int)monitors.size()) - monitor_focus_.resize(monitors.size()); +FocusState WorkspaceManager::get_focus_state() const { + FocusState out; + out.monitor = focused_monitor_; + out.ws_id = active_ws_of_monitor(focused_monitor_); + if (is_ws_valid(out.ws_id)) { + auto w = workspaces[(size_t)out.ws_id].focused(); + out.window = w ? w->id : NO_WINDOW; + } + return out; } -void WorkspaceManager::sync_focus_state() { - ensure_monitor_focus_size(); - focus_.monitor = focused_monitor_; - focus_.ws_id = active_ws_of_monitor(focused_monitor_); - if (is_ws_valid(focus_.ws_id)) { - auto w = workspaces[focus_.ws_id].focused(); - focus_.window = w ? w->id : NO_WINDOW; - } else { - focus_.window = NO_WINDOW; - } +WindowId WorkspaceManager::focused_window_id() const { + return get_focus_state().window; } void WorkspaceManager::select_valid_focused_monitor() { @@ -288,25 +259,10 @@ void WorkspaceManager::select_valid_focused_monitor() { focused_monitor_ = MonitorId{ 0 }; } -WindowId WorkspaceManager::last_focused_window(MonitorId mon_idx, WorkspaceId ws_id) const { - if (mon_idx < 0 || mon_idx >= (int)monitor_focus_.size()) - return NO_WINDOW; - auto it = monitor_focus_[mon_idx].last_window_per_ws.find(ws_id); - if (it == monitor_focus_[mon_idx].last_window_per_ws.end()) - return NO_WINDOW; - WindowId win = it->second; - // Validate that the window still exists in this workspace. - auto wit = window_workspace.find(win); - if (wit == window_workspace.end() || wit->second != ws_id) - return NO_WINDOW; - return win; -} - void WorkspaceManager::set_focused_monitor(MonitorId mon_idx) { if (!is_mon_valid(mon_idx)) return; focused_monitor_ = mon_idx; - sync_focus_state(); } MonitorId WorkspaceManager::resolve_alias_monitor(const std::string& alias, @@ -391,19 +347,6 @@ void WorkspaceManager::index_window(WindowId win, const std::shared_ptrsecond == win) - it = mf.last_window_per_ws.erase(it); - else - ++it; - } - } - if (focus_.window == win) - focus_.window = NO_WINDOW; } void WorkspaceManager::rebuild_window_indexes() { @@ -497,7 +440,6 @@ void WorkspaceManager::update_workspace_defs(const std::vector& de workspaces.erase(workspaces.begin() + new_count, workspaces.end()); ws_owner.resize(new_count); - sync_focus_state(); } } @@ -573,7 +515,6 @@ void WorkspaceManager::assign_workspaces(const std::vector& aliase rebuild_pools_from_owner(seed_pools, preferred_active_ws); select_valid_focused_monitor(); sync_monitors_active_ws(); - sync_focus_state(); } void WorkspaceManager::set_monitors(std::vector mons) { @@ -590,11 +531,6 @@ void WorkspaceManager::set_monitors(std::vector mons) { if (focused_monitor_ >= 0 && focused_monitor_ < (int)old_monitors.size()) old_focused_name = old_monitors[focused_monitor_].name; - // Snapshot per-monitor focus keyed by monitor name for migration. - std::unordered_map old_mfocus; - for (int i = 0; i < (int)old_monitors.size() && i < (int)monitor_focus_.size(); i++) - old_mfocus[old_monitors[i].name] = monitor_focus_[i]; - std::unordered_map old_index; for (int i = 0; i < (int)old_monitors.size(); i++) old_index[old_monitors[i].name] = i; @@ -664,14 +600,6 @@ void WorkspaceManager::set_monitors(std::vector mons) { ws_owner = std::move(new_owner); rebuild_pools_from_owner(seed_pools, preferred_active_ws); - // Migrate per-monitor focus state by monitor name. - monitor_focus_.assign(monitors.size(), MonitorFocusState{}); - for (int i = 0; i < (int)monitors.size(); i++) { - auto it = old_mfocus.find(monitors[i].name); - if (it != old_mfocus.end()) - monitor_focus_[i] = std::move(it->second); - } - int focused_by_name = monitor_index_by_name(old_focused_name); if (focused_by_name >= 0) focused_monitor_ = focused_by_name; @@ -680,7 +608,6 @@ void WorkspaceManager::set_monitors(std::vector mons) { select_valid_focused_monitor(); sync_monitors_active_ws(); - sync_focus_state(); } void WorkspaceManager::adjust_monitor_inset(MonitorId mon_idx, MonitorEdge edge, int delta) { @@ -833,7 +760,6 @@ bool WorkspaceManager::switch_to(WorkspaceId ws_id, // Do NOT update focused_monitor_ here: switching a workspace on any monitor // must not steal focus from the monitor the user is currently on. sync_monitors_active_ws(); - sync_focus_state(); return true; } @@ -876,7 +802,6 @@ void WorkspaceManager::remove_window(WindowId win, WorkspaceId ws_id) { if (workspaces[ws_id].remove_window(win)) unindex_window(win); WS_ASSERT_CONSISTENT(); - sync_focus_state(); return; } int active = active_ws_of_monitor(focused_monitor_); @@ -884,7 +809,6 @@ void WorkspaceManager::remove_window(WindowId win, WorkspaceId ws_id) { if (workspaces[active].remove_window(win)) unindex_window(win); WS_ASSERT_CONSISTENT(); - sync_focus_state(); } std::shared_ptr WorkspaceManager::find_window(WindowId win, WorkspaceId ws_id) { @@ -943,14 +867,12 @@ void WorkspaceManager::remove_window_from_all(WindowId win) { workspaces[it->second].remove_window(win); unindex_window(win); WS_ASSERT_CONSISTENT(); - sync_focus_state(); return; } for (auto& ws : workspaces) ws.remove_window(win); unindex_window(win); WS_ASSERT_CONSISTENT(); - sync_focus_state(); } void WorkspaceManager::move_window_to(WorkspaceId ws_id, std::shared_ptr w) { @@ -969,7 +891,6 @@ void WorkspaceManager::move_window_to(WorkspaceId ws_id, std::shared_ptr= 0) monitor_active_local[mon] = li; - // Record last focused window per monitor/workspace. - ensure_monitor_focus_size(); - monitor_focus_[mon].last_window_per_ws[ws.id] = win; - // Do NOT update focused_monitor_ here — focus_window() syncs state - // but the active monitor is determined by user input, not window focus events. + // Focusing a window is an intent — the monitor that owns it + // becomes the active monitor. Required so EnterNotify on window + // at other monitor doesn't leave focused_monitor_ stale (the + // old two-step path emitted FocusChanged twice to compensate). + focused_monitor_ = MonitorId{ mon }; sync_monitors_active_ws(); - sync_focus_state(); } return true; } @@ -1005,13 +925,11 @@ bool WorkspaceManager::focus_window(WindowId win) { std::shared_ptr WorkspaceManager::focus_next() { current().focus_next(); - sync_focus_state(); return current().focused(); } std::shared_ptr WorkspaceManager::focus_prev() { current().focus_prev(); - sync_focus_state(); return current().focused(); } @@ -1023,7 +941,6 @@ bool WorkspaceManager::switch_local_index(MonitorId mon_idx, int local_idx) { return false; monitor_active_local[mon_idx] = local_idx; sync_monitors_active_ws(); - sync_focus_state(); return true; } @@ -1061,7 +978,6 @@ bool WorkspaceManager::focus_monitor_at_point(int x, int y) { return false; bool changed = (mon != focused_monitor_); focused_monitor_ = mon; - sync_focus_state(); return changed; } diff --git a/core/src/lua_host.cpp b/core/src/lua_host.cpp index 63c8494..04a6ce8 100644 --- a/core/src/lua_host.cpp +++ b/core/src/lua_host.cpp @@ -309,6 +309,99 @@ bool LuaHost::exec_string(const char* code, const char* name) { return true; } +namespace { + +// Captured print() output — thread_local since the Lua state is single- +// threaded and re-entrant calls should nest cleanly if one ever happens. +thread_local std::string* g_repl_capture = nullptr; + +int repl_print(lua_State* L) { + if (!g_repl_capture) return 0; + int n = lua_gettop(L); + // Mimic Lua's print: separate args with \t, end with \n. + for (int i = 1; i <= n; ++i) { + size_t len = 0; + // luaL_tolstring respects __tostring metamethods and leaves the + // string on the stack; we pop it below. + const char* str = luaL_tolstring(L, i, &len); + if (i > 1) g_repl_capture->push_back('\t'); + g_repl_capture->append(str, len); + lua_pop(L, 1); + } + g_repl_capture->push_back('\n'); + return 0; +} + +} // namespace + +std::string LuaHost::repl_eval(const std::string& code) { + auto* L = as_state(state_); + if (!L) return ""; + + LuaStackGuard guard(L); + + std::string out; + + // Save previous `print` so we can restore it if the chunk mutates it. + lua_getglobal(L, "print"); + int prev_print_idx = lua_gettop(L); + + // Install capture-print for the duration of the call. + std::string* prev_capture = g_repl_capture; + g_repl_capture = &out; + lua_pushcfunction(L, repl_print); + lua_setglobal(L, "print"); + + auto restore = [&]() { + lua_pushvalue(L, prev_print_idx); + lua_setglobal(L, "print"); + g_repl_capture = prev_capture; + }; + + // Try as expression (`return `) first so bare expressions print + // their value, fall back to loading the raw code as a statement block. + std::string wrapped = "return " + code; + int load_rc = luaL_loadbuffer(L, wrapped.data(), wrapped.size(), "=repl"); + if (load_rc != LUA_OK) { + lua_pop(L, 1); + load_rc = luaL_loadbuffer(L, code.data(), code.size(), "=repl"); + } + if (load_rc != LUA_OK) { + out.append(lua_tostring(L, -1)); + out.push_back('\n'); + lua_pop(L, 1); + restore(); + return out; + } + + // Stack right now: [..., prev_print, chunk]. After pcall: + // success → [..., prev_print, r1, r2, ...] + // error → [..., prev_print, errmsg] + int call_rc = lua_pcall(L, 0, LUA_MULTRET, 0); + int nresults = lua_gettop(L) - prev_print_idx; + + if (call_rc != LUA_OK) { + out.append(lua_tostring(L, -1)); + out.push_back('\n'); + restore(); + return out; + } + + for (int i = 0; i < nresults; ++i) { + int idx = prev_print_idx + 1 + i; + size_t len = 0; + const char* str = luaL_tolstring(L, idx, &len); + if (i > 0) out.push_back('\t'); + out.append(str, len); + lua_pop(L, 1); // pop the tolstring copy + } + if (nresults > 0) + out.push_back('\n'); + + restore(); + return out; +} + LuaRegistryRef LuaHost::ref_value(int index) const { LuaContext ctx = context(); LuaRegistryRef out; @@ -388,6 +481,22 @@ bool LuaHost::call_ref_method_string(const LuaRegistryRef& obj_ref, const char* return true; } +bool LuaHost::call_ref_method_void(const LuaRegistryRef& obj_ref, const char* method, + const char* context_text) const { + if (!push_ref(obj_ref)) + return false; + LuaContext ctx = context(); + // stack: [obj] + ctx.get_field(-1, method); // stack: [obj, fn] + if (!ctx.is_function(-1)) { + ctx.pop(2); + return false; + } + ctx.push_value(-2); // stack: [obj, fn, obj] (self) + ctx.remove(-3); // remove original obj copy → [fn, obj] + return pcall(1, 0, context_text); // fn(obj) → [] +} + bool LuaHost::call_ref_with_int_fields(const LuaRegistryRef& ref, std::initializer_list> fields, const char* context_text) const { diff --git a/core/src/runtime/runtime.cpp b/core/src/runtime/runtime.cpp index 7bdd5a9..a7c25a8 100644 --- a/core/src/runtime/runtime.cpp +++ b/core/src/runtime/runtime.cpp @@ -147,7 +147,7 @@ void adopt_existing_windows(Runtime& runtime, Core& core, Backend& backend) { .window = snap.window, .wm_instance = snap.wm_instance, .wm_class = snap.wm_class, - .title = {}, + .title = snap.title, .pid = 0, .type = snap.type, .hints = hints, @@ -628,6 +628,9 @@ void Runtime::apply_and_refresh_monitors() { void Runtime::dispatch_display_change() { apply_and_refresh_monitors(); + // Hot-plug path: notify modules so they can rebuild bar/tray layout. + // (Initial topology apply in start() intentionally does not emit this.) + post_event(event::DisplayTopologyChanged{}); } void Runtime::setup_sigchld_pipe() { diff --git a/libxcb/include/xcb/connection.hpp b/libxcb/include/xcb/connection.hpp index 3e58fd7..bfafc0d 100644 --- a/libxcb/include/xcb/connection.hpp +++ b/libxcb/include/xcb/connection.hpp @@ -90,6 +90,8 @@ class Connection { WindowAttributes get_window_attributes(xcb_window_t win) const; std::optional get_window_geometry(xcb_window_t win) const; std::vector query_tree_children(xcb_window_t parent) const; + std::optional query_parent(xcb_window_t win) const; + xcb_window_t get_input_focus() const; std::optional get_transient_for(xcb_window_t win) const; int get_wm_state_value(xcb_window_t win, xcb_atom_t wm_state_atom) const; SizeHints get_size_hints(xcb_window_t win) const; diff --git a/libxcb/src/connection.cpp b/libxcb/src/connection.cpp index c39a2da..6dc12b6 100644 --- a/libxcb/src/connection.cpp +++ b/libxcb/src/connection.cpp @@ -150,6 +150,22 @@ std::vector Connection::query_tree_children(xcb_window_t parent) c return {children, children + n}; } +std::optional Connection::query_parent(xcb_window_t win) const { + auto r = reply(xcb_query_tree_reply(conn_, + xcb_query_tree(conn_, win), nullptr)); + if (!r || r->parent == XCB_WINDOW_NONE || r->parent == r->root) + return std::nullopt; + return r->parent; +} + +xcb_window_t Connection::get_input_focus() const { + auto r = reply(xcb_get_input_focus_reply(conn_, + xcb_get_input_focus(conn_), nullptr)); + if (!r) + return XCB_WINDOW_NONE; + return r->focus; +} + std::optional Connection::get_transient_for(xcb_window_t win) const { auto r = reply(xcb_get_property_reply(conn_, xcb_get_property(conn_, 0, win, XCB_ATOM_WM_TRANSIENT_FOR, diff --git a/lua/swm/widget.lua b/lua/swm/widget.lua index 7c8ddcd..36ce350 100644 --- a/lua/swm/widget.lua +++ b/lua/swm/widget.lua @@ -1,8 +1,13 @@ -- swm.widget — base class for bar widgets. -- -- Lua widget: --- local w = Widget:new({ interval = 1 }) --- function w:render() return "text" end +-- local w = Widget:new({ interval = 5 }) +-- function w:update() self.cpu = sys.cpu() end -- mutates state by `interval` seconds +-- function w:render() return string.format("%.0f%%", self.cpu or 0) end -- cheap, pure +-- +-- update() is the expensive path (IO, syscalls). render() runs on every bar +-- repaint and must stay cheap. Widgets without an update() fall back to +-- calling render() on the interval schedule (legacy compatibility). -- -- Built-in (C++) widget: -- local tags = Widget.builtin("tags") @@ -10,9 +15,12 @@ local Base = require("swm.base") local Widget = Base:extend() -Widget.interval = 0 -- 0 = every redraw +Widget.interval = 0 -- 0 = reactive (update+render on every repaint) + +-- Virtual — override to refresh widget state from expensive sources. +function Widget:update() end --- Virtual — override to produce status text. +-- Virtual — override to produce the text drawn on the bar. function Widget:render() return "" end -- Factory for built-in (C++) widgets. diff --git a/lua/swm/widgets/battery.lua b/lua/swm/widgets/battery.lua index c816b26..8d08ed5 100644 --- a/lua/swm/widgets/battery.lua +++ b/lua/swm/widgets/battery.lua @@ -5,9 +5,13 @@ local sys = require("sysinfo") local w = Widget:new({ interval = 30 }) +function w:update() + self.bat = sys.battery() +end + function w:render() - local b = sys.battery() - if not b.present then return "" end + local b = self.bat + if not b or not b.present then return "" end local icon if b.status == "Charging" or b.status == "Full" then diff --git a/lua/swm/widgets/brightness.lua b/lua/swm/widgets/brightness.lua index c78517a..641066a 100644 --- a/lua/swm/widgets/brightness.lua +++ b/lua/swm/widgets/brightness.lua @@ -5,9 +5,13 @@ local sys = require("sysinfo") local w = Widget:new({ interval = 5 }) +function w:update() + self.bri = sys.brightness() +end + function w:render() - local b = sys.brightness() - if not b.present then return "" end + local b = self.bri + if not b or not b.present then return "" end return string.format(" [BRI %d%%] ", b.percent) end diff --git a/lua/swm/widgets/clock.lua b/lua/swm/widgets/clock.lua index 7158e7e..cd1903f 100644 --- a/lua/swm/widgets/clock.lua +++ b/lua/swm/widgets/clock.lua @@ -3,8 +3,12 @@ local Widget = require("swm.widget") local w = Widget:new({ interval = 1 }) +function w:update() + self.text = os.date(" [%d-%m-%Y %H:%M:%S %Z] ") +end + function w:render() - return os.date(" [%d-%m-%Y %H:%M:%S %Z] ") + return self.text or "" end return w diff --git a/lua/swm/widgets/kbd.lua b/lua/swm/widgets/kbd.lua index 42e7eab..25b4c0e 100644 --- a/lua/swm/widgets/kbd.lua +++ b/lua/swm/widgets/kbd.lua @@ -1,11 +1,15 @@ --- swm.widgets.kbd — keyboard layout indicator +-- swm.widgets.kbd — keyboard layout indicator (reactive: refreshed on every repaint) local Widget = require("swm.widget") local sys = require("sysinfo") local w = Widget:new() +function w:update() + self.layout = sys.kbd_layout() or "??" +end + function w:render() - return string.format(" [%s] ", string.upper(sys.kbd_layout() or "??")) + return string.format(" [%s] ", string.upper(self.layout or "??")) end return w diff --git a/lua/swm/widgets/netdisk.lua b/lua/swm/widgets/netdisk.lua index ed4260b..c67df82 100644 --- a/lua/swm/widgets/netdisk.lua +++ b/lua/swm/widgets/netdisk.lua @@ -4,17 +4,21 @@ local sys = require("sysinfo") local w = Widget:new({ interval = 10 }) +function w:update() + self.ip = sys.net_ip() + self.disks = sys.disks() +end + function w:render() - local ip = sys.net_ip() - local disks = sys.disks() + if not self.disks then return "" end local parts = {} local skip = { ["/boot/efi"] = true } - for _, d in ipairs(disks) do + for _, d in ipairs(self.disks) do if not skip[d.mountpoint] then table.insert(parts, string.format("%s %.0f%%", d.mountpoint, d.percent)) end end - return string.format(" [IP %s][%s] ", ip, table.concat(parts, "][")) + return string.format(" [IP %s][%s] ", self.ip or "?", table.concat(parts, "][")) end return w diff --git a/lua/swm/widgets/sysinfo.lua b/lua/swm/widgets/sysinfo.lua index da2bc5b..cd8a32f 100644 --- a/lua/swm/widgets/sysinfo.lua +++ b/lua/swm/widgets/sysinfo.lua @@ -4,11 +4,15 @@ local sys = require("sysinfo") local w = Widget:new({ interval = 2 }) +function w:update() + self.cpu = sys.cpu() + self.mem = sys.mem() +end + function w:render() - local cpu = sys.cpu() - local mem = sys.mem() + if not self.cpu or not self.mem then return "" end return string.format(" [CPU %.2f%%][MEM %.2f/%.2f GB (%.2f%%)] ", - cpu, mem.used, mem.total, mem.percent) + self.cpu, self.mem.used, self.mem.total, self.mem.percent) end return w diff --git a/lua/swm/widgets/volume.lua b/lua/swm/widgets/volume.lua index 99b6877..6b952d2 100644 --- a/lua/swm/widgets/volume.lua +++ b/lua/swm/widgets/volume.lua @@ -15,15 +15,21 @@ local function format_vol(label, info) return string.format("%s %d%%", label, info.percent) end +function w:update() + if not audio then return end + self.out = audio.output() + self.in_ = audio.input() +end + function w:render() if not audio then return "" end local parts = {} - local s = format_vol("OUT", audio.output()) + local s = format_vol("OUT", self.out) if s then parts[#parts + 1] = s end - s = format_vol("IN", audio.input()) + s = format_vol("IN", self.in_) if s then parts[#parts + 1] = s end if #parts == 0 then return "" end diff --git a/lua/swm/widgets/weather.lua b/lua/swm/widgets/weather.lua index 09ca44f..e328d9f 100644 --- a/lua/swm/widgets/weather.lua +++ b/lua/swm/widgets/weather.lua @@ -7,11 +7,10 @@ local w = Widget:new({ interval = 900 }) -- 15 minutes w.city = "" -- empty = auto-detect by IP w.format = "%c%t" -- wttr.in format string: %c=icon %t=temperature -local cached = "" - -local function fetch() - local loc = w.city ~= "" and w.city or "" - local url = string.format("wttr.in/%s?format=%s", loc, w.format:gsub(" ", "+")) +local function fetch(self) + local loc = self.city ~= "" and self.city or "" + local fmt = (self.format:gsub(" ", "+")) + local url = string.format("wttr.in/%s?format=%s", loc, fmt) local f = io.popen('curl -sf --max-time 5 "' .. url .. '" 2>/dev/null', "r") if not f then return nil end local out = f:read("*a") @@ -20,13 +19,16 @@ local function fetch() return out:gsub("%s+$", "") end -function w:render() - local result = fetch() +function w:update() + local result = fetch(self) if result then - cached = result + self.cached = result end - if #cached == 0 then return "" end - return " [" .. cached .. "] " +end + +function w:render() + if not self.cached or #self.cached == 0 then return "" end + return " [" .. self.cached .. "] " end return w diff --git a/modules/bar/bar_module.cpp b/modules/bar/bar_module.cpp index cef1dff..f34c67d 100644 --- a/modules/bar/bar_module.cpp +++ b/modules/bar/bar_module.cpp @@ -147,38 +147,79 @@ int BarModule::tag_at(WindowId window, int click_x) const { return -1; } -static void refresh_slot(LuaHost& lua, const BarSlot& slot) { +// Call widget:update() if defined. Expensive; invoked on load and at every +// slot.interval seconds thereafter. +static void update_slot(LuaHost& lua, const BarSlot& slot) { + if (slot.kind != BarSlotKind::Lua || !slot.has_update) return; + lua.call_ref_method_void(slot.widget, "update", "bar.widget"); +} + +// Call widget:render() and store the result into slot.cached_text. Cheap; runs +// on every repaint. +static void render_slot(LuaHost& lua, const BarSlot& slot) { + if (slot.kind != BarSlotKind::Lua) return; + lua.call_ref_method_string(slot.widget, "render", slot.cached_text, "bar.widget"); +} + +// Scheduled tick: advance counter and call update() only when interval elapsed. +// render() is NOT called here — it runs in redraw() every tick. +static void tick_slot(LuaHost& lua, const BarSlot& slot) { if (slot.kind != BarSlotKind::Lua || slot.interval <= 0) return; slot.ticks++; if (slot.ticks < slot.interval) return; slot.ticks = 0; - lua.call_ref_method_string(slot.widget, "render", slot.cached_text, "bar.widget"); + update_slot(lua, slot); } void BarModule::refresh_widgets() { - if (runtime_state() != RuntimeState::Running) return; - auto& lua = this->lua; + RuntimeState rs = runtime_state(); + if (rs != RuntimeState::Running && rs != RuntimeState::Starting) return; + auto& lua = this->lua; for (const auto& b : all_bars_) { - for (const auto& s : b.cfg.left) refresh_slot(lua, s); - for (const auto& s : b.cfg.center) refresh_slot(lua, s); - for (const auto& s : b.cfg.right) refresh_slot(lua, s); + for (const auto& s : b.cfg.left) tick_slot(lua, s); + for (const auto& s : b.cfg.center) tick_slot(lua, s); + for (const auto& s : b.cfg.right) tick_slot(lua, s); } } -static void update_reactive_slot(LuaHost& lua, const BarSlot& slot) { - if (slot.kind == BarSlotKind::Lua && slot.interval == 0) - lua.call_ref_method_string(slot.widget, "render", slot.cached_text, "bar.widget"); +// Called for every slot on every redraw: reactive slots (interval == 0) also +// get update() here, since they have no scheduled tick. +static void redraw_slot(LuaHost& lua, const BarSlot& slot) { + if (slot.kind != BarSlotKind::Lua) return; + if (slot.interval == 0) + update_slot(lua, slot); + render_slot(lua, slot); +} + +// Initial load: force update() on all scheduled slots once so cached_text is +// populated from the very first paint. Reactive slots handle themselves in +// redraw_slot(). Called from on_start()/on_reload() — must run even when the +// runtime is still in Starting state, so no runtime-state guard here. +void BarModule::prime_widgets() { + auto& lua = this->lua; + for (const auto& b : all_bars_) { + for (const auto& s : b.cfg.left) + if (s.interval > 0) update_slot(lua, s); + for (const auto& s : b.cfg.center) + if (s.interval > 0) update_slot(lua, s); + for (const auto& s : b.cfg.right) + if (s.interval > 0) update_slot(lua, s); + } } void BarModule::redraw() { if (!state_provider) return; - if (runtime_state() == RuntimeState::Running) { + // Allow slot updates/renders during Starting too — on_start() paints the + // bar before the runtime transitions to Running. Only skip once we're + // tearing down (Stopping/Stopped), when Lua state may be gone. + RuntimeState rs = runtime_state(); + if (rs == RuntimeState::Running || rs == RuntimeState::Starting) { auto& lua = this->lua; for (const auto& b : all_bars_) { - for (const auto& s : b.cfg.left) update_reactive_slot(lua, s); - for (const auto& s : b.cfg.center) update_reactive_slot(lua, s); - for (const auto& s : b.cfg.right) update_reactive_slot(lua, s); + for (const auto& s : b.cfg.left) redraw_slot(lua, s); + for (const auto& s : b.cfg.center) redraw_slot(lua, s); + for (const auto& s : b.cfg.right) redraw_slot(lua, s); } } diff --git a/modules/bar/bar_module.hpp b/modules/bar/bar_module.hpp index 756cbb0..0ebfbee 100644 --- a/modules/bar/bar_module.hpp +++ b/modules/bar/bar_module.hpp @@ -34,7 +34,7 @@ class BarModule : public Module { void on_reload() override; using Module::on; - void on(event::RuntimeStarted) override { refresh_widgets(); redraw(); } + void on(event::RuntimeStarted) override { prime_widgets(); redraw(); } void on(event::WindowMapped) override { redraw(); } void on(event::WindowUnmapped) override; void on(event::FocusChanged) override { redraw(); } @@ -96,6 +96,7 @@ class BarModule : public Module { int tag_at(WindowId window, int click_x) const; void rebuild_bars(); void refresh_widgets(); + void prime_widgets(); void redraw(); void raise_all(); void stop_runtime(); diff --git a/modules/bar/bar_module_core.cpp b/modules/bar/bar_module_core.cpp index ce7611b..2b71e89 100644 --- a/modules/bar/bar_module_core.cpp +++ b/modules/bar/bar_module_core.cpp @@ -59,6 +59,10 @@ static bool parse_slot(LuaContext& lua, LuaHost& host, int val_idx, return false; } + lua.get_field(val_idx, "update"); + bool has_update = lua.is_function(-1); + lua.pop(); + auto widget_ref = host.ref_value(val_idx); if (!widget_ref.valid()) { if (err_out) @@ -74,10 +78,11 @@ static bool parse_slot(LuaContext& lua, LuaHost& host, int val_idx, if (interval < 0) interval = 0; - out.kind = BarSlotKind::Lua; - out.widget = std::move(widget_ref); - out.interval = interval; - out.ticks = interval; // first refresh_slot() renders immediately + out.kind = BarSlotKind::Lua; + out.widget = std::move(widget_ref); + out.interval = interval; + out.has_update = has_update; + out.ticks = 0; return true; } @@ -463,10 +468,10 @@ void BarModule::on_init() { } if (mon_ws >= 0) { - WindowId w = core.ws().last_focused_window(safe_mon_idx, mon_ws); - if (w == NO_WINDOW && safe_mon_idx == core.focused_monitor_index()) - if (auto focused = core.focused_window_state()) - w = focused->id; + WindowId w = NO_WINDOW; + if (auto ws_state = core.workspace_state(mon_ws)) + if (auto fw = ws_state->focused()) + w = fw->id; if (w != NO_WINDOW) { if (auto ws = core.window_state_any(w)) s.title = ws->title; @@ -591,19 +596,18 @@ void BarModule::on(event::ButtonEv ev) { } void BarModule::on_reload() { + // Absorb the new config and theme, but do NOT rebuild bar/tray windows + // here: process_pending_reload() calls dispatch_display_change() right + // after reload() returns, which posts DisplayTopologyChanged — that + // handler is the single place that performs the physical rebuild. Doing + // it twice (once here, once in the event handler) is what caused visible + // flicker on siren.reload(). bar_set_cfg_ = bar_set_setting_.get(); const ThemeConfig& th = core.current_settings().theme; apply_theme_to_monitor_cfg(bar_set_cfg_.default_cfg, th); for (auto& [alias, mcfg] : bar_set_cfg_.per_monitor) apply_theme_to_monitor_cfg(mcfg, th); - - rebuild_bars(); - (void)core.dispatch(command::atom::ReconcileNow{}); - rebuild_trays(); - rebalance_tray_icons(); - raise_all(); - redraw(); } void BarModule::rebuild_trays() { @@ -669,6 +673,7 @@ void BarModule::on(event::DisplayTopologyChanged) { rebuild_trays(); rebalance_tray_icons(); raise_all(); + prime_widgets(); redraw(); } @@ -741,6 +746,7 @@ void BarModule::on_start() { } } + prime_widgets(); redraw(); LOG_INFO("Bar: initialized, %d bar window(s)", (int)all_bars_.size()); } diff --git a/modules/debug_ui/debug_ui_module.cpp b/modules/debug_ui/debug_ui_module.cpp index e455716..63a2d4b 100644 --- a/modules/debug_ui/debug_ui_module.cpp +++ b/modules/debug_ui/debug_ui_module.cpp @@ -11,6 +11,8 @@ #include #include +#include +#include #include #include #include @@ -95,6 +97,7 @@ class DebugUIModule : public Module { void panel_windows(); void panel_focus(); void panel_events(); + void panel_repl(); void log_event(const char* type, std::string detail = {}); @@ -112,6 +115,11 @@ class DebugUIModule : public Module { std::deque event_log_; static constexpr size_t kMaxEvents = 300; + + // REPL state. + std::array repl_input_{}; + std::string repl_output_; + bool repl_focus_input_ = false; }; // --------------------------------------------------------------------------- @@ -337,6 +345,9 @@ void DebugUIModule::render_frame() { if (ImGui::BeginTabItem("Events")) { panel_events(); ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("REPL")) { + panel_repl(); ImGui::EndTabItem(); + } ImGui::EndTabBar(); } @@ -549,6 +560,54 @@ void DebugUIModule::panel_focus() { } } +void DebugUIModule::panel_repl() { + // Output pane: scroll-locked, multiline, read-only. + float footer_h = ImGui::GetFrameHeightWithSpacing() * 4.5f; + ImVec2 out_size(-FLT_MIN, -footer_h); + if (ImGui::BeginChild("repl_output", out_size, true, + ImGuiWindowFlags_HorizontalScrollbar)) { + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(repl_output_.c_str(), + repl_output_.c_str() + repl_output_.size()); + ImGui::PopTextWrapPos(); + if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 1.0f) + ImGui::SetScrollHereY(1.0f); + } + ImGui::EndChild(); + + ImGui::Separator(); + + ImGuiInputTextFlags flags = ImGuiInputTextFlags_AllowTabInput + | ImGuiInputTextFlags_CtrlEnterForNewLine; + if (repl_focus_input_) { + ImGui::SetKeyboardFocusHere(); + repl_focus_input_ = false; + } + ImVec2 in_size(-FLT_MIN, ImGui::GetFrameHeightWithSpacing() * 3.0f); + ImGui::InputTextMultiline("##repl_input", repl_input_.data(), repl_input_.size(), + in_size, flags); + + bool exec = ImGui::Button("Exec"); + ImGui::SameLine(); + if (ImGui::Button("Clear")) + repl_output_.clear(); + ImGui::SameLine(); + ImGui::TextDisabled("(Shift+Enter in field, then Exec)"); + + if (exec) { + std::string code(repl_input_.data()); + if (!code.empty()) { + repl_output_.append(">>> "); + repl_output_.append(code); + if (repl_output_.empty() || repl_output_.back() != '\n') + repl_output_.push_back('\n'); + repl_output_.append(lua.repl_eval(code)); + repl_input_[0] = '\0'; + repl_focus_input_ = true; + } + } +} + void DebugUIModule::panel_events() { ImGui::Text("Events: %d / %d", (int)event_log_.size(), (int)kMaxEvents); diff --git a/modules/keybindings/keybindings.cpp b/modules/keybindings/keybindings.cpp index c626ec0..f2eba34 100644 --- a/modules/keybindings/keybindings.cpp +++ b/modules/keybindings/keybindings.cpp @@ -571,12 +571,6 @@ void KeybindingsModule::on(event::ButtonEv ev) { uint16_t mods = ev.state & ~backend::MOD_STRIP_MASK; auto& in = backend.ports().input; - // Click-to-focus grabs (grab_button_any, GrabModeSync) freeze the pointer - // until AllowEvents fires; replay so the client still receives the click. - // Modifier-bound WM actions come through async grabs — no replay needed. - if (!ev.release && mods == 0 && ev.window != focused_window_) - in.allow_events(true); - for (auto& mb : mouse_bindings_.get()) { if (mb.button != ev.button || mb.mods != mods) continue; @@ -692,28 +686,10 @@ void KeybindingsModule::on(event::MotionEv ev) { (void)core.dispatch(command::atom::SetWindowSize{ drag.window, { nw, nh } }); } -void KeybindingsModule::on(event::FocusChanged ev) { - auto& in = backend.ports().input; - // Restore click-to-focus grab on the previously focused window. - if (focused_window_ != NO_WINDOW && focused_window_ != ev.window) - in.grab_button_any(focused_window_); - focused_window_ = ev.window; - // Remove click-to-focus grab from the newly focused window — - // WM action grabs (mod+button) remain in place. - if (ev.window != NO_WINDOW) { - in.ungrab_all_buttons(ev.window); - install_mouse_grabs_for_window(in, ev.window, mouse_bindings_.get()); - } - in.flush(); -} - void KeybindingsModule::on(event::WindowMapped ev) { auto& in = backend.ports().input; in.ungrab_all_buttons(ev.window); install_mouse_grabs_for_window(in, ev.window, mouse_bindings_.get()); - // Newly mapped windows are unfocused — add click-to-focus grab. - if (ev.window != focused_window_) - in.grab_button_any(ev.window); in.flush(); } diff --git a/modules/keybindings/keybindings.hpp b/modules/keybindings/keybindings.hpp index a98c8ef..52e12b2 100644 --- a/modules/keybindings/keybindings.hpp +++ b/modules/keybindings/keybindings.hpp @@ -34,7 +34,6 @@ class KeybindingsModule : public Module { void on(event::KeyPressEv) override; void on(event::ButtonEv) override; void on(event::MotionEv) override; - void on(event::FocusChanged) override; void on(event::WindowMapped) override; void on(event::WindowUnmapped) override; void on(event::DestroyNotify) override; @@ -65,6 +64,5 @@ class KeybindingsModule : public Module { TypedSetting mod_mask_; TypedSetting> mouse_bindings_; - WindowId focused_window_ = NO_WINDOW; DragState drag; }; diff --git a/src/main.cpp b/src/main.cpp index 6cb1824..9f14c0b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -199,11 +199,17 @@ bool ensure_embedded_display_server(const std::string& exec_path, bool from_exec } // namespace int main(int argc, char** argv) { - const char* home = std::getenv("HOME"); - std::string log_path = "runtime.log"; - if (home) - log_path = std::string(home) + "/runtime.log"; - log_init(log_path); + // Pick the log file name before parsing CLI so early failures still land + // in the right file. The display-server child is launched with the same + // argv prefix + "--display-server", so a flat scan is sufficient. + std::string app_name = "sirenwm"; + for (int i = 1; i < argc; ++i) { + if (argv[i] && std::string_view(argv[i]) == "--display-server") { + app_name = "sirenwm-display"; + break; + } + } + log_init(app_name); std::string exec_path = resolve_exec_path(argc, argv); diff --git a/tests/core/test_backend_effects.cpp b/tests/core/test_backend_effects.cpp index be09ff7..e1cb97b 100644 --- a/tests/core/test_backend_effects.cpp +++ b/tests/core/test_backend_effects.cpp @@ -129,7 +129,7 @@ TEST(BackendEffects, FocusRootWhenSwitchingToEmptyWorkspace) { h.map_window(0x1000, 0); drain(h.core); - // Switch to ws 1 (empty) — sync_current_focus will emit FocusRoot. + // Switch to ws 1 (empty) — Core::focus(NO_WINDOW) emits FocusRoot. h.core.dispatch(command::atom::SwitchWorkspace{ 1, std::nullopt }); auto fx = drain(h.core); EXPECT_TRUE(has_effect(fx, BackendEffectKind::FocusRoot)); diff --git a/tests/core/test_hotplug.cpp b/tests/core/test_hotplug.cpp index b4580b8..c716623 100644 --- a/tests/core/test_hotplug.cpp +++ b/tests/core/test_hotplug.cpp @@ -213,10 +213,14 @@ TEST(Hotplug, MoveWindowToSameMonitorIsNoop) { } // --------------------------------------------------------------------------- -// DisplayTopologyChanged domain event emitted +// DisplayTopologyChanged: NOT emitted by ApplyMonitorTopology dispatch itself. +// Emission is the caller's responsibility (Runtime::dispatch_display_change() +// posts it on hot-plug; Runtime::start() intentionally does not, since the +// initial topology apply is not a "change" and must not trigger reactive +// rebuilds in modules that already read topology synchronously in on_start()). // --------------------------------------------------------------------------- -TEST(Hotplug, TopologyChangeEmitsDomainEvent) { +TEST(Hotplug, ApplyTopologyDoesNotEmitDomainEvent) { CoreHarness h; h.start(); h.take_core_events(); // drain any startup events @@ -226,13 +230,7 @@ TEST(Hotplug, TopologyChangeEmitsDomainEvent) { make_monitor(1, 1920, 0, 1920, 1080, "secondary"), }); - auto evts = h.take_core_events(); - bool found = false; - for (const auto& ev : evts) { - if (std::holds_alternative(ev)) { - found = true; - break; - } - } - EXPECT_TRUE(found); + auto evts = h.take_core_events(); + for (const auto& ev : evts) + EXPECT_FALSE(std::holds_alternative(ev)); } diff --git a/tests/core/test_main.cpp b/tests/core/test_main.cpp index cd9607c..a101310 100644 --- a/tests/core/test_main.cpp +++ b/tests/core/test_main.cpp @@ -2,7 +2,7 @@ #include int main(int argc, char** argv) { - log_init("/dev/null", spdlog::level::off); + log_init_null(); ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); } diff --git a/tests/fake_backend.hpp b/tests/fake_backend.hpp index ff49816..faf52f6 100644 --- a/tests/fake_backend.hpp +++ b/tests/fake_backend.hpp @@ -29,10 +29,8 @@ class FakeInputPort : public backend::InputPort { void ungrab_all_keys() override { log.push_back({ Record::UngrabAll }); } void grab_button(WindowId, uint8_t, uint16_t) override {} void ungrab_all_buttons(WindowId) override {} - void grab_button_any(WindowId) override {} void grab_pointer() override {} void ungrab_pointer() override {} - void allow_events(bool) override {} void warp_pointer(WindowId w, Vec2i16 pos) override { log.push_back({ Record::WarpAbs, w, pos.x(), pos.y() }); } diff --git a/tests/integration/run_tests_wayland.sh b/tests/integration/run_tests_wayland.sh index 064c7af..2e43d0d 100755 --- a/tests/integration/run_tests_wayland.sh +++ b/tests/integration/run_tests_wayland.sh @@ -30,6 +30,10 @@ DISPLAY_SERVER_LOG="$LOG_DIR/display-server.log" DISPLAY_SERVER_STDOUT="$LOG_DIR/display-server.stdout" TEST_HOME="$LOG_DIR/home" TEST_CONFIG="$TEST_HOME/.config/sirenwm/init.lua" +# Structured log written by sirenwm itself (FSM transitions, module events…). +# feat(log) routes it under $XDG_STATE_HOME/sirenwm/ — we override HOME for the +# child, so the default fallback path resolves here. +RUNTIME_LOG="$TEST_HOME/.local/state/sirenwm/sirenwm.log" XDG_RUNTIME="$LOG_DIR/xdg-runtime" BUILD_DIR="$LOG_DIR/build" HOST_X_DISPLAY="" @@ -63,7 +67,7 @@ dump_logs() { info "--- display-server stdout (tail 20) ---" tail -n 20 "$DISPLAY_SERVER_STDOUT" 2>/dev/null || true info "--- runtime.log (tail 40) ---" - tail -n 40 "$TEST_HOME/runtime.log" 2>/dev/null || true + tail -n 40 "$RUNTIME_LOG" 2>/dev/null || true info "--- X server log ---" tail -n 20 "$XSERVER_LOG" 2>/dev/null || true } @@ -645,7 +649,7 @@ check_global "wl_data_device_manager" # In current display-server mode it may be intentionally absent. if grep -q "zwlr_layer_shell_v1" "$WAYLAND_INFO_OUT" 2>/dev/null; then pass "compositor advertises zwlr_layer_shell_v1" -elif grep -q 'Bar: initialized' "$TEST_HOME/runtime.log" 2>/dev/null; then +elif grep -q 'Bar: initialized' "$RUNTIME_LOG" 2>/dev/null; then skip "compositor advertises zwlr_layer_shell_v1" \ "protocol not exported in display-server mode (bar initialized)" else @@ -656,7 +660,7 @@ fi # =========================================================================== # Test 11: FSM reaches Running state # =========================================================================== -if grep -q 'FSM: Starting → Running' "$TEST_HOME/runtime.log" 2>/dev/null; then +if grep -q 'FSM: Starting → Running' "$RUNTIME_LOG" 2>/dev/null; then pass "FSM reached Running state" else fail "FSM reached Running state" "transition not found in runtime.log" @@ -666,7 +670,7 @@ fi # Test 12: monitor layout applied # =========================================================================== if grep -qE "DisplayServerMonitorPort: applied|monitors: found [1-9][0-9]* monitor\\(s\\):" \ - "$TEST_HOME/runtime.log" 2>/dev/null; then + "$RUNTIME_LOG" 2>/dev/null; then pass "monitor layout applied" else fail "monitor layout applied" "monitor topology apply log not found in runtime.log" @@ -675,7 +679,7 @@ fi # =========================================================================== # Test 13: bar initialized # =========================================================================== -if grep -q 'Bar: initialized' "$TEST_HOME/runtime.log" 2>/dev/null; then +if grep -q 'Bar: initialized' "$RUNTIME_LOG" 2>/dev/null; then pass "bar module initialized" else fail "bar module initialized" "not found in runtime.log" @@ -696,7 +700,7 @@ fi # Test 15: no crashes or assertions in logs # =========================================================================== if grep -qE 'Assertion.*failed|SIGSEGV|SIGABRT|core dump' \ - "$SIRENWM_LOG" "$TEST_HOME/runtime.log" 2>/dev/null; then + "$SIRENWM_LOG" "$RUNTIME_LOG" 2>/dev/null; then fail "no crashes in logs" "assertion/signal found" else pass "no crashes in logs" @@ -706,7 +710,7 @@ fi # Test 16: no config errors at startup # =========================================================================== if grep -qE '\[error\].*RuntimeStore|FSM: aborting|failed to load config' \ - "$TEST_HOME/runtime.log" 2>/dev/null; then + "$RUNTIME_LOG" 2>/dev/null; then fail "no config errors at startup" "config error found in runtime.log" else pass "no config errors at startup" @@ -773,7 +777,7 @@ if [[ -z "$XDG_CLIENT" ]]; then else # Single window: connect, configure, map, destroy XDG_OUT="$LOG_DIR/xdg_client.txt" - LOG_BEFORE=$(wc -l < "$TEST_HOME/runtime.log" 2>/dev/null || echo 0) + LOG_BEFORE=$(wc -l < "$RUNTIME_LOG" 2>/dev/null || echo 0) XDG_EXIT=0 if wl_timeout_run 10 "$XDG_CLIENT" "test-window-1" 800 >"$XDG_OUT" 2>&1; then @@ -796,7 +800,7 @@ else fi if grep -qE 'WaylandBackend: surface [0-9]+ mapped|DisplayServerBackend: surface [0-9]+ mapped|WindowMapped' \ - "$TEST_HOME/runtime.log" 2>/dev/null; then + "$RUNTIME_LOG" 2>/dev/null; then pass "xdg-toplevel: surface mapped logged" else fail "xdg-toplevel: surface mapped logged" "not found in runtime.log" @@ -806,7 +810,7 @@ else assert_pid_alive "$DISPLAY_SERVER_PID" "xdg-toplevel: display-server alive after window" "display-server crashed" if grep -qE 'WaylandBackend: surface [0-9]+ destroyed|DisplayServerBackend: surface [0-9]+ destroyed' \ - "$TEST_HOME/runtime.log" 2>/dev/null; then + "$RUNTIME_LOG" 2>/dev/null; then pass "xdg-toplevel: surface destroyed cleanly" else fail "xdg-toplevel: surface destroyed cleanly" "destroy log not found" @@ -872,7 +876,7 @@ assert_pid_alive "$DISPLAY_SERVER_PID" "SIGHUP: display-server alive after recon # =========================================================================== # Test 30: reload logged in runtime.log # =========================================================================== -if grep -qE 'reload|Reload|SIGHUP' "$TEST_HOME/runtime.log" 2>/dev/null; then +if grep -qE 'reload|Reload|SIGHUP' "$RUNTIME_LOG" 2>/dev/null; then pass "SIGHUP: reload logged in runtime.log" else fail "SIGHUP: reload logged in runtime.log" "not found" @@ -965,7 +969,7 @@ fi # =========================================================================== # Test 37: FSM: Running → Stopped logged # =========================================================================== -if grep -qE 'FSM:.*Stopp|FSM:.*Running.*Stopp' "$TEST_HOME/runtime.log" 2>/dev/null; then +if grep -qE 'FSM:.*Stopp|FSM:.*Running.*Stopp' "$RUNTIME_LOG" 2>/dev/null; then pass "FSM: Running → Stopped transition logged" else fail "FSM: Running → Stopped transition logged" "not found in runtime.log" diff --git a/tests/integration/suites/08_mouse.sh b/tests/integration/suites/08_mouse.sh index 52877a9..41201ea 100755 --- a/tests/integration/suites/08_mouse.sh +++ b/tests/integration/suites/08_mouse.sh @@ -45,9 +45,7 @@ X2=$(geom_x "$G2"); Y2=$(geom_y "$G2"); W2W=$(geom_w "$G2"); H2=$(geom_h "$G2") CX1=$(( X1 + W1W / 2 )); CY1=$(( Y1 + H1 / 2 )) CX2=$(( X2 + W2W / 2 )); CY2=$(( Y2 + H2 / 2 )) -# NOTE: click-to-focus uses passive button grabs (grab_button_any) which -# XTEST synthetic events cannot trigger. Skipping click-to-focus tests. -# These are covered by the unit test harness instead. +# Focus follows the pointer (no click-to-focus grab). # --- EnterNotify: move pointer into W1 (no click) --- # Park at neutral position first diff --git a/tests/modules/test_main.cpp b/tests/modules/test_main.cpp index cd9607c..a101310 100644 --- a/tests/modules/test_main.cpp +++ b/tests/modules/test_main.cpp @@ -2,7 +2,7 @@ #include int main(int argc, char** argv) { - log_init("/dev/null", spdlog::level::off); + log_init_null(); ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); } diff --git a/tests/runtime/test_main.cpp b/tests/runtime/test_main.cpp index 3913f05..92d2eee 100644 --- a/tests/runtime/test_main.cpp +++ b/tests/runtime/test_main.cpp @@ -3,7 +3,7 @@ #include int main(int argc, char** argv) { - log_init("/dev/null", spdlog::level::off); + log_init_null(); ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); }