feat: mirada standalone — compositor Wayland + WM sobre Llimphi (build magro)
Stack de display extraído del monorepo: compositor teselante (Cuerpo smithay + Cerebro WM agnóstico), greeter PAM, portal XDG, CLI de control. Llimphi se consume por git desde su repo publicado; las hojas compartidas (format, auth-core, rimay-localize, wawa-config, app-bus) y el widget menubar van vendorizados. Sin el asistente IA (pluma-llm) ni la barra web wasm — el compositor no los necesita. cargo check --workspace pasa (18 crates, 0 warn). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delta semántico · 1282
02_ruway/llimphi/widgets/menubar/src/lib.rs
- nueva def DEFAULT_HEIGHT pub
- nueva struct MenuBarSpec pub
- nueva type MsgFromMenu
- nueva type MsgFromStr
- nueva fn approx_title_width
- nueva fn dropdown_items
- nueva fn menubar_command_at pub
- nueva fn menubar_nav pub
- nueva fn menubar_overlay pub
- nueva fn menubar_overlay_animated pub
- nueva fn menubar_overlay_core
- nueva fn menubar_view pub
- nueva fn overlay_none_si_no_hay_abierto
- nueva fn overlay_some_si_hay_abierto
- nueva mod tests
- nueva fn title_palette
- nueva fn title_palette_active
- nueva fn title_style
- nueva fn titles_row
02_ruway/mirada/mirada-app-llimphi/src/main.rs
- nueva def APPS
- nueva struct Mirada
- nueva struct Model
- nueva type Model
- nueva enum Msg
- nueva type Msg
- nueva def POLL_MS
- nueva def SCREEN_H
- nueva def SCREEN_W
- nueva fn act
- nueva fn app_menu
- nueva fn canvas_view
- nueva fn connect_body
- nueva fn dispatch
- nueva fn feed
- nueva fn handle_key
- nueva fn handle_menu_command
- nueva fn init
- nueva fn initial_size
- nueva fn load_user_rules
- nueva fn main
- nueva fn menubar_spec
- nueva fn mode_name
- nueva fn on_key
- nueva fn open_window
- nueva fn poll_ctl
- nueva fn reload_keymap
- nueva fn serve_ctl
- nueva fn tick
- nueva fn title
- nueva fn top_bar
- nueva fn update
- nueva fn view
- nueva fn view_overlay
- nueva fn viewport_of
02_ruway/mirada/mirada-bar-core/src/lib.rs
- nueva struct Task pub
- nueva fn active pub
- nueva fn escape_text pub
- nueva fn escape_text_escapes_html
- nueva fn new pub
- nueva fn render_html pub
- nueva fn render_html_emits_active_class
- nueva fn render_html_escapes_label_and_sanitizes_id
- nueva fn sanitize_attr pub
- nueva fn sanitize_attr_strips_unsafe
- nueva fn task_builder_defaults_inactive
- nueva mod tests
02_ruway/mirada/mirada-body/examples/headless.rs
- nueva fn main
02_ruway/mirada/mirada-body/src/lib.rs
- nueva enum BodyOp pub
- nueva struct BodyState pub
- nueva struct Surface pub
- nueva fn a_floating_change_alone_triggers_a_configure
- nueva fn add_output pub
- nueva fn an_identical_place_produces_no_ops
- nueva fn apply pub
- nueva fn body_with_two
- nueva fn clicked pub
- nueva fn close_and_kill_map_to_client_ops
- nueva fn close_surface pub
- nueva fn closing_a_surface_clears_its_focus
- nueva fn closing_an_unknown_surface_yields_nothing
- nueva fn dropping_a_surface_from_the_list_hides_it
- nueva fn focused pub
- nueva fn grab_keys_cursor_and_shutdown_pass_through
- nueva fn keybind pub
- nueva fn moving_focus_emits_a_single_focus_op
- nueva fn new
- nueva fn new pub
- nueva fn open_surface pub
- nueva fn opening_a_surface_yields_a_window_opened_event
- nueva fn outputs pub
- nueva fn outputs_are_tracked
- nueva fn placement
- nueva fn placement_for_an_unknown_surface_is_ignored
- nueva fn placing_surfaces_configures_and_focuses_them
- nueva fn pointer_enter pub
- nueva fn remove_output pub
- nueva fn reserve_output pub
- nueva fn resize_output pub
- nueva fn retitle_surface pub
- nueva fn retitling_updates_the_surface
- nueva fn surface pub
- nueva fn surface_count pub
- nueva mod tests
- nueva fn visible pub
- nueva fn visible_iterates_only_shown_surfaces
- nueva fn window_dragged pub
02_ruway/mirada/mirada-brain/examples/headless-ctl.rs
- nueva fn main
- nueva fn print_state
02_ruway/mirada/mirada-brain/examples/keymap-default.rs
- nueva fn main
02_ruway/mirada/mirada-brain/src/action.rs
- nueva enum DesktopAction pub
- nueva enum Direction pub
- nueva type Err
- nueva def WORKSPACE_COUNT pub
- nueva fn default_keymap pub
- nueva fn directional_actions_round_trip_in_every_direction
- nueva fn every_default_action_round_trips_through_its_text_form
- nueva fn every_layout_mode_round_trips
- nueva fn fmt
- nueva fn focus_window_round_trips_with_its_id
- nueva fn from_slug
- nueva fn from_str
- nueva fn keymap_covers_every_virtual_workspace
- nueva fn keymap_has_no_duplicate_bindings
- nueva def layout_from_slug
- nueva def layout_slug
- nueva fn out_of_range_or_unknown_actions_are_rejected
- nueva fn parse_workspace
- nueva fn slug
- nueva fn spawn_round_trips_keeping_the_whole_command
- nueva fn spawn_without_a_command_is_rejected
- nueva mod tests
- nueva fn workspace_actions_are_one_based_in_text
02_ruway/mirada/mirada-brain/src/config.rs
- nueva def CONFIG_TEMPLATE
- nueva struct Config pub
- nueva def DEFAULT_DROPTERM_CMD
- nueva def DROPTERM_APP_ID pub
- nueva struct MenuEntry pub
- nueva struct OutputOverride pub
- nueva def TRANSFORM_SLUGS pub
- nueva struct ZoneCfg pub
- nueva fn an_unknown_layout_slug_is_rejected
- nueva fn decorations pub
- nueva fn decorations_derive_from_the_config_and_clamp_width
- nueva fn default
- nueva fn default_decorations_match_the_protocol_default
- nueva fn default_path pub
- nueva fn deserialize pub
- nueva fn deserialize pub
- nueva fn dropterm_height_is_clamped
- nueva fn dropterm_height_pct pub
- nueva fn float_step pub
- nueva fn from_ron pub
- nueva fn is_valid_transform_slug pub
- nueva fn layout_params pub
- nueva fn layout_params_clamp_out_of_range_values
- nueva mod layout_slug_serde
- nueva fn load pub
- nueva fn load_or_default pub
- nueva fn master_step pub
- nueva fn menu_flat_entries_parse_without_submenu
- nueva fn menu_nested_submenus_parse
- nueva fn omitted_fields_fall_back_to_defaults
- nueva fn output_direction_default_es_horizontal
- nueva fn output_direction_desconocido_es_rechazado
- nueva fn output_direction_parsea_vertical_y_horizontal
- nueva fn output_disposition pub
- nueva fn output_order_for pub
- nueva fn output_order_for_cae_a_cero_sin_override
- nueva fn output_order_for_lee_el_override
- nueva fn output_override_aplica_su_wallpaper_solo_al_monitor_nombrado
- nueva fn output_override_con_path_vacio_no_pisa_al_global
- nueva fn output_override_rechaza_fit_desconocido
- nueva fn output_scale_120_default_es_100_pct_si_no_hay_override
- nueva fn output_scale_120_for pub
- nueva fn output_scale_120_lee_el_override
- nueva fn output_transform_default_es_normal
- nueva fn output_transform_desconocido_es_rechazado
- nueva fn output_transform_for pub
- nueva fn output_transform_lee_el_override
- nueva fn parse_disposition
- nueva fn parsed_wallpaper_fit
- nueva fn serialize pub
- nueva fn serialize pub
- nueva mod tests
- nueva fn the_layout_mode_parses_from_its_slug_string
- nueva fn the_template_parses_to_the_defaults
- nueva fn wallpaper_fit_default_es_stretch
- nueva fn wallpaper_fit_for pub
- nueva fn wallpaper_fit_parsea_de_su_slug
- nueva mod wallpaper_fit_slug_serde
- nueva fn wallpaper_path_for pub
- nueva fn watch pub
- nueva fn zones_parsean_con_nombre_opcional
02_ruway/mirada/mirada-brain/src/ctl.rs
- nueva struct CtlConn pub
- nueva enum CtlReply pub
- nueva enum CtlRequest pub
- nueva struct CtlServer pub
- nueva struct WindowLine pub
- nueva fn a_request_round_trips_over_the_socket
- nueva fn bind pub
- nueva fn binding_twice_on_a_live_socket_is_refused
- nueva fn default_socket_path pub
- nueva fn default_socket_path_lives_under_a_runtime_dir
- nueva fn drop
- nueva fn list_windows_carries_the_window_lines
- nueva fn poll pub
- nueva fn read_request pub
- nueva fn reply pub
- nueva fn send_request pub
- nueva fn temp_socket
- nueva mod tests
02_ruway/mirada/mirada-brain/src/desktop.rs
- nueva struct Desktop pub
- nueva def MIN
- nueva struct Output pub
- nueva struct WindowInfo pub
- nueva fn a_client_fullscreen_request_is_honoured
- nueva fn a_fullscreen_request_for_an_unknown_window_does_nothing
- nueva fn a_rule_can_open_a_window_floating
- nueva fn a_rule_sends_a_new_window_to_its_workspace
- nueva fn a_scratchpad_window_follows_you_across_workspaces
- nueva fn a_spawn_keybind_becomes_a_spawn_command
- nueva fn active_index pub
- nueva fn active_workspace pub
- nueva fn an_unknown_keybind_does_nothing
- nueva fn apply pub
- nueva fn centered_float_rect
- nueva fn clicked_focuses_even_with_focus_follows_mouse_off
- nueva fn clicked_jumps_to_the_workspace_holding_the_window
- nueva fn close_focused_keybind_asks_to_close_the_focused_window
- nueva fn closing_a_stashed_window_drops_it_from_the_scratchpad
- nueva fn closing_a_window_removes_it_everywhere
- nueva fn config pub
- nueva fn config_sets_the_dropterm_command_and_height
- nueva fn cycle_layout_walks_every_mode_and_returns
- nueva fn decorations pub
- nueva fn default
- nueva fn desktop_with_screen
- nueva fn desktop_with_two_outputs
- nueva fn dragging_a_tiled_window_swaps_with_the_window_under_the_pointer
- nueva fn dragging_an_unknown_window_does_nothing
- nueva fn dragging_floats_a_window_at_the_given_rect
- nueva fn dropdown_rect
- nueva fn dropterm_lazy_spawns_when_absent
- nueva fn dropterm_opens_floating_top_anchored_and_focused
- nueva fn dropterm_toggles_hide_then_show_keeping_focus
- nueva fn each_output_shows_a_distinct_workspace
- nueva fn focus_and_send_to_output_dir_cross_monitors
- nueva fn focus_dir_moves_focus_spatially_in_columns
- nueva fn focus_follows_mouse_can_be_disabled_by_config
- nueva fn focus_in_direction
- nueva fn focus_keybind_cycles_within_the_active_workspace
- nueva fn focus_output_next_moves_the_focus_between_outputs
- nueva fn focus_window_addresses_a_specific_window
- nueva fn focus_window_jumps_to_the_workspace_that_holds_it
- nueva fn focused_output pub
- nueva fn focused_window pub
- nueva fn grab_keys pub
- nueva fn grab_keys_lists_the_whole_keymap
- nueva fn grow_and_shrink_master_adjust_the_ratio
- nueva fn inc_and_dec_master_adjust_nmaster
- nueva fn keyboard_focus_is_unique_across_outputs
- nueva fn keymap pub
- nueva fn master_ratio_stays_within_bounds
- nueva fn master_step_from_config_drives_grow_master
- nueva fn monocle_keybind_hides_all_but_the_focused_window
- nueva fn move_dir_nudges_a_floating_window
- nueva fn move_dir_swaps_the_focused_tile_with_its_neighbor
- nueva fn move_in_direction
- nueva fn move_to_workspace_sends_the_window_and_follows_it
- nueva fn nearest_in_direction
- nueva fn nearest_in_direction_picks_the_window_in_front
- nueva fn new pub
- nueva fn nudge_master
- nueva fn nudge_master_count
- nueva fn on_event pub
- nueva fn open
- nueva fn opening_a_window_places_it
- nueva fn output_in_direction
- nueva fn outputs pub
- nueva fn outputs_lay_side_by_side
- nueva fn places
- nueva fn pointer_focuses_a_window_in_the_active_workspace
- nueva fn promote_to_master_brings_the_focused_window_to_the_front
- nueva fn quit_emits_a_shutdown
- nueva fn reflow_outputs
- nueva fn relayout
- nueva fn relayout_places_windows_on_every_output
- nueva fn reload_config pub
- nueva fn reload_config_applies_focus_follows_mouse_live
- nueva fn reload_config_applies_the_dropterm_command_live
- nueva fn reload_config_preserves_open_windows_and_focus
- nueva fn reload_config_reseeds_params_and_re_sends_decorations
- nueva fn removing_an_output_keeps_its_windows_in_their_workspace
- nueva fn reservar_franja_desplaza_y_encoge_el_teselado
- nueva fn resize_float
- nueva fn resize_float_grows_and_shrinks_the_focused_floating_window
- nueva fn resizing_an_output_retiles_without_losing_the_workspace
- nueva fn retitling_updates_the_registry_without_relayout
- nueva fn screen pub
- nueva fn send_to_scratchpad_hides_the_focused_window
- nueva fn send_to_workspace_moves_the_focused_window
- nueva fn set_config pub
- nueva fn set_config_seeds_the_layout_params_of_every_workspace
- nueva fn set_keymap pub
- nueva fn set_keymap_swaps_the_bindings_and_regrabs
- nueva fn set_rules pub
- nueva fn show_workspace
- nueva fn swap_master_exchanges_only_the_focused_and_the_master
- nueva fn switching_to_a_workspace_shown_on_another_output_swaps_them
- nueva fn switching_workspace_changes_what_is_placed
- nueva mod tests
- nueva fn toggle_float_marks_the_focused_window_and_floats_it_last
- nueva fn toggle_fullscreen_covers_the_screen_and_hides_the_rest
- nueva fn toggle_scratchpad_shows_then_hides_the_stashed_window
- nueva fn toggle_tiling_floats_all_then_restores
- nueva fn window_dragged_ignores_a_floating_window
- nueva fn window_info pub
- nueva fn window_lines pub
- nueva fn window_lines_cover_every_window_with_its_workspace
- nueva fn window_lines_show_a_stashed_window_as_workspace_zero
- nueva fn with_keymap pub
- nueva fn without_a_screen_nothing_is_placed
- nueva fn work_rect pub
- nueva fn workspace_loads pub
02_ruway/mirada/mirada-brain/src/keymap.rs
- nueva def KEYMAP_HEADER
- nueva struct Keymap pub
- nueva enum KeymapError pub
- nueva struct KeymapFile
- nueva type KeymapWatch pub
- nueva fn a_custom_keymap_parses_from_ron
- nueva fn an_unknown_action_names_the_offending_binding
- nueva fn bindings pub
- nueva fn default
- nueva fn default_path pub
- nueva fn documented_ron pub
- nueva fn fmt
- nueva fn from
- nueva fn from_pairs pub
- nueva fn from_ron pub
- nueva fn grab_list pub
- nueva fn grab_list_is_exactly_the_set_of_bound_combos
- nueva fn is_empty pub
- nueva fn len pub
- nueva fn load pub
- nueva fn load_or_init pub
- nueva fn lookup pub
- nueva fn lookup_resolves_a_default_binding
- nueva fn malformed_ron_is_rejected
- nueva fn save pub
- nueva mod tests
- nueva fn the_default_keymap_round_trips_through_ron
- nueva fn the_saved_file_carries_the_documentation_header
- nueva fn to_ron pub
- nueva fn watch pub
02_ruway/mirada/mirada-brain/src/lib.rs
02_ruway/mirada/mirada-brain/src/rules.rs
- nueva def RULES_TEMPLATE
- nueva struct Rule pub
- nueva struct RuleOutcome pub
- nueva struct Rules pub
- nueva fn contains_ci
- nueva fn default_path pub
- nueva fn from_ron pub
- nueva fn is_empty pub
- nueva fn len pub
- nueva fn load pub
- nueva fn load_or_default pub
- nueva fn matches
- nueva fn new pub
- nueva fn no_match_yields_the_default_outcome
- nueva fn resolve pub
- nueva fn resolve_matches_app_id_case_insensitively_by_substring
- nueva fn resolve_matches_by_title
- nueva fn resolve_sends_a_match_to_its_workspace
- nueva fn rules_parse_from_ron_with_omitted_fields
- nueva mod tests
- nueva fn the_first_matching_rule_wins
- nueva fn the_template_parses_to_an_empty_rule_set
- nueva fn watch pub
02_ruway/mirada/mirada-brain/src/watch.rs
- nueva struct FileWatch pub
- nueva fn changed pub
- nueva fn detects_a_write_to_the_watched_file
- nueva fn new pub
- nueva mod tests
02_ruway/mirada/mirada-compositor/src/drm_backend.rs
- nueva def BTN_LEFT
- nueva def BTN_RIGHT
- nueva def CLEAR_COLOR
- nueva def CURSOR_COLOR
- nueva def CURSOR_SIZE
- nueva type Compositor
- nueva struct DrmState
- nueva def HUD_BG
- nueva def HUD_DURATION
- nueva def HUD_PAD
- nueva def HUD_TEXT_COLOR
- nueva def HUD_TEXT_PX
- nueva def HUD_TOP
- nueva def MENU_BG
- nueva def MENU_TEXT_COLOR
- nueva def MENU_TEXT_PX
- nueva def MIN_WINDOW
- nueva struct OutputCtx
- nueva def PRIMARY
- nueva def TITLE_COLOR
- nueva def TITLE_PX
- nueva fn armar_output_ctx
- nueva fn border_rects
- nueva fn clamp_to_outputs
- nueva fn detect_connector_changes
- nueva fn drag_update
- nueva fn emit_cursor
- nueva fn emit_hud
- nueva fn emit_menu
- nueva fn emit_reveal_band
- nueva fn emit_wallpaper
- nueva fn emit_windows
- nueva fn emit_zone_overlay
- nueva fn handle_input
- nueva fn load_wallpaper
- nueva fn output_at_point
- nueva fn output_index_by_crtc
- nueva fn output_work_rect
- nueva fn paste_rgba_into_bgra
- nueva fn pause_session
- nueva fn pointer_motion
- nueva fn redisponer_outputs
- nueva fn refresh_window_borders
- nueva fn render
- nueva fn render_output
- nueva fn resume_session
- nueva fn rgba_f32
- nueva fn run pub
- nueva fn send_frames_to_clients
- nueva fn tick
- nueva fn title_buffer
- nueva fn titlebar_at
- nueva fn window_at
- nueva fn work_rect
- nueva fn zone_at
- nueva fn zone_rect
02_ruway/mirada/mirada-compositor/src/main.rs
- nueva struct App
- nueva enum BodyMode
- nueva enum Brain
- nueva struct ClientState
- nueva struct ConfigWatches
- nueva def DOCK
- nueva struct DragGrab
- nueva enum DragMode
- nueva type KeyboardFocus
- nueva struct ManagedWindow
- nueva type PointerFocus
- nueva def SHELL_DOCK_DEFAULT
- nueva def SHELL_REVEAL_BAND
- nueva type SelectionUserData
- nueva struct Setup
- nueva enum ShellAnchor
- nueva struct ShellDock
- nueva def THEME_ENV
- nueva type TouchFocus
- nueva fn anchor_horizontalidad
- nueva fn anchor_parse_y_default
- nueva fn announce_dmabuf
- nueva fn announce_output
- nueva fn apply_commands
- nueva fn apply_user
- nueva fn autohide_bottom_revela_en_el_borde_y_oculta_al_salir
- nueva fn autohide_next_hidden
- nueva fn autohide_top_usa_el_borde_superior
- nueva fn autostart_path
- nueva fn banda_de_revelado_pegada_al_borde
- nueva fn brain_feed
- nueva fn brain_poll
- nueva fn buffer_destroyed
- nueva fn build_app
- nueva fn client_compositor_state
- nueva fn combo_string
- nueva fn commit
- nueva fn complete_greeter_handoff
- nueva fn compositor_state
- nueva fn config_font_path
- nueva fn config_menu
- nueva fn config_output_disposition
- nueva fn config_output_order_for
- nueva fn config_output_scale_120_for
- nueva fn config_output_transform_for
- nueva fn config_wallpaper_fit_for
- nueva fn config_wallpaper_path_for
- nueva fn config_zone_presets
- nueva fn config_zones
- nueva fn cursor_hotspot
- nueva fn cursor_image
- nueva fn data_control_state
- nueva fn data_device_state
- nueva fn disconnected
- nueva fn dmabuf_imported
- nueva fn dmabuf_state
- nueva fn dock_shell
- nueva mod drm_backend
- nueva fn embedded_brain
- nueva fn es_horizontal
- nueva fn exclusive_layer_surface
- nueva fn exec_op
- nueva def exec_session
- nueva fn focus_changed
- nueva fn franja_del_shell_por_borde
- nueva fn fullscreen_request
- nueva fn grab
- nueva fn greeter_bin
- nueva fn initialized
- nueva fn insets_reservan_la_zona_del_borde_correcto
- nueva def is_escape_hatch
- nueva fn is_shell_app_id
- nueva fn keyboard_focusable_layer_under
- nueva fn layer_destroyed
- nueva fn layer_render_elements
- nueva fn layer_under
- nueva fn load_user_config
- nueva fn load_user_rules
- nueva fn main
- nueva mod menu
- nueva fn menu_node_from_entry
- nueva fn named_key
- nueva fn new_decoration
- nueva fn new_layer_surface
- nueva fn new_popup
- nueva fn new_toplevel
- nueva fn output_changed
- nueva fn parse
- nueva fn poll
- nueva fn recompute_reservations
- nueva fn reconcile_layer_keyboard
- nueva fn register_toplevel
- nueva fn reload_config_from
- nueva fn reload_keymap_from
- nueva fn reload_rules_from
- nueva fn render_loc
- nueva fn reposition_request
- nueva fn request_mode
- nueva fn run_winit
- nueva fn scale_to_smithay pub
- nueva fn seat_state
- nueva fn send
- nueva fn send_frames_surface_tree
- nueva fn serve_ctl
- nueva fn setup_user_session_env
- nueva fn shell_dock
- nueva fn shell_insets
- nueva fn shell_reveal_band
- nueva fn shell_state
- nueva fn shell_strip
- nueva fn shm_state
- nueva fn spawn_autostart
- nueva fn spawn_command
- nueva fn spawn_greeter
- nueva fn spawn_user
- nueva fn surface_px_size
- nueva mod tests
- nueva mod text
- nueva fn title_changed
- nueva fn titlebar_for
- nueva fn toplevel_destroyed
- nueva fn transform_from_slug pub
- nueva fn unfullscreen_request
- nueva fn unset_mode
- nueva fn update_shell_autohide
- nueva fn vt_switch_cubre_fn_y_keysym_dedicado
- nueva def vt_target
- nueva fn xdg_shell_state
02_ruway/mirada/mirada-compositor/src/menu.rs
- nueva enum ClickResult pub
- nueva struct Col
- nueva def ITEM_H pub
- nueva def MENU_W pub
- nueva struct MenuColView pub
- nueva struct MenuNode pub
- nueva struct MenuRowView pub
- nueva def PAD pub
- nueva struct RootMenu pub
- nueva fn arranca_con_una_sola_columna
- nueva fn cascada_de_dos_niveles
- nueva fn click pub
- nueva fn click_en_hoja_anidada_lanza
- nueva fn click_en_hoja_lanza_su_comando
- nueva fn click_en_submenu_lo_abre_y_sigue
- nueva fn click_fuera_cierra
- nueva fn column_entries
- nueva fn columns
- nueva fn height_for pub
- nueva fn hit
- nueva fn hover_sobre_hoja_cierra_las_columnas_profundas
- nueva fn hover_sobre_submenu_abre_columna_hija
- nueva fn is_submenu pub
- nueva fn leaf pub
- nueva fn menu
- nueva fn open pub
- nueva fn open_acota_la_primera_columna_a_la_salida
- nueva fn render pub
- nueva fn render_marca_submenus_y_resaltado
- nueva fn row_y
- nueva fn submenu pub
- nueva mod tests
- nueva fn tree
- nueva fn update_hover pub
02_ruway/mirada/mirada-compositor/src/text.rs
- nueva def FONT_CANDIDATES
- nueva struct Rasterized pub
- nueva struct TextRenderer pub
- nueva fn color_lands_in_bgra_order_premultiplied
- nueva fn empty_text_rasterizes_to_nothing
- nueva fn font_or_skip
- nueva fn from_path pub
- nueva fn rasterize pub
- nueva fn rasterizes_text_to_a_nonempty_opaque_buffer
- nueva fn system pub
- nueva mod tests
02_ruway/mirada/mirada-ctl/src/main.rs
- nueva fn main
- nueva fn print_actions
- nueva fn print_help
- nueva fn print_windows
- nueva fn request
- nueva fn run
02_ruway/mirada/mirada-greeter/src/main.rs
- nueva type DynAuth
- nueva enum Field
- nueva def GREETER_APP_ID
- nueva struct Greeter
- nueva struct Model
- nueva type Model
- nueva enum Msg
- nueva type Msg
- nueva enum Status
- nueva fn app_id
- nueva fn app_menu
- nueva fn apply_edit_menu_action
- nueva fn emit_ticket
- nueva fn focused_input
- nueva fn handle_menu_command
- nueva fn init
- nueva fn main
- nueva fn menubar_spec
- nueva fn on_key
- nueva fn pick_authenticator
- nueva mod rain
- nueva fn rain_bright
- nueva fn row
- nueva mod sessions
- nueva fn spacer
- nueva mod state
- nueva fn title
- nueva fn toggle
- nueva fn update
- nueva fn view
- nueva fn view_overlay
02_ruway/mirada/mirada-greeter/src/rain.rs
02_ruway/mirada/mirada-greeter/src/sessions.rs
- nueva enum Kind pub
- nueva struct Session pub
- nueva fn collect_dir
- nueva fn discover pub
- nueva fn discover_siempre_trae_mirada
- nueva fn ignora_ocultas
- nueva fn ignora_otras_secciones
- nueva fn is_mirada_session
- nueva fn limpia_field_codes
- nueva fn parse_entry
- nueva fn parsea_entrada_minima
- nueva fn sin_exec_no_es_sesion
- nueva fn strip_field_codes
- nueva fn tag pub
- nueva mod tests
- nueva fn xdg_data_roots
02_ruway/mirada/mirada-greeter/src/state.rs
- nueva struct GreeterState pub
- nueva enum RainColor pub
- nueva fn apply_env
- nueva fn candidate_paths
- nueva fn default
- nueva fn load pub
- nueva fn merge_text
- nueva fn parse
- nueva fn parsea_claves
- nueva fn round_trip
- nueva fn save pub
- nueva fn tag
- nueva mod tests
- nueva fn to_text
- nueva fn truthy
02_ruway/mirada/mirada-launcher/src/main.rs
- nueva struct DesktopApp
- nueva def MAX_SHOWN
- nueva fn application_dirs
- nueva fn collect_desktop_files
- nueva fn launch
- nueva fn main
- nueva fn parse_desktop
- nueva fn run_ui
- nueva fn scan_apps
- nueva fn strip_field_codes
02_ruway/mirada/mirada-layout/src/geometry.rs
- nueva struct Rect pub
- nueva fn area pub
- nueva fn contains pub
- nueva fn contains_checks_bounds
- nueva fn inset pub
- nueva fn inset_clamps_to_zero
- nueva fn inset_shrinks_by_gap_on_every_side
- nueva fn is_visible pub
- nueva fn new pub
- nueva fn split pub
- nueva fn split_loses_no_pixels
- nueva mod tests
02_ruway/mirada/mirada-layout/src/layout.rs
- nueva def ALL pub
- nueva enum LayoutMode pub
- nueva struct LayoutParams pub
- nueva def SCREEN
- nueva enum WallpaperFit pub
- nueva struct ZoneFrac pub
- nueva fn centered_master
- nueva fn centered_master_centers_the_master_and_covers_the_screen
- nueva fn columns
- nueva fn columns_partition_the_width_exactly
- nueva fn default
- nueva fn default
- nueva fn empty_count_yields_no_rects
- nueva fn from_slug pub
- nueva fn gap_shrinks_every_window
- nueva fn grid
- nueva fn grid_tiles_cover_the_screen_without_overlap
- nueva fn layout_is_deterministic
- nueva fn layout_mode_next_cycles_through_every_mode
- nueva fn master_stack
- nueva fn master_stack_master_takes_its_ratio
- nueva fn master_stack_single_window_fills_screen
- nueva fn monocle_gives_every_window_the_full_screen
- nueva fn next pub
- nueva fn nmaster_above_window_count_makes_every_window_a_master
- nueva fn nmaster_keeps_n_windows_in_the_master_column
- nueva fn params
- nueva fn rows
- nueva fn rows_partition_the_height_exactly
- nueva fn slug pub
- nueva fn smart_gaps_drop_the_margin_for_a_single_window
- nueva fn spiral
- nueva fn spiral_tiles_cover_the_screen_without_overlap
- nueva mod tests
- nueva fn tile pub
- nueva fn tile_count_matches_window_count
- nueva fn to_rect pub
- nueva fn wallpaper_aspecto_igual_no_distorsiona_ni_recorta
- nueva fn wallpaper_center_pega_la_imagen_a_su_tamano
- nueva fn wallpaper_dimensiones_degeneradas_no_panican
- nueva fn wallpaper_dst_rect pub
- nueva fn wallpaper_fill_cubre_y_recorta_los_bordes
- nueva fn wallpaper_fit_respeta_aspecto_y_no_sobresale
- nueva fn wallpaper_stretch_cubre_toda_la_salida
- nueva fn wallpaper_tile_devuelve_el_tamano_nativo
- nueva fn zone_frac_escala_a_pixeles
- nueva fn zone_frac_fuera_de_rango_se_acota_a_la_pantalla
02_ruway/mirada/mirada-layout/src/lib.rs
02_ruway/mirada/mirada-layout/src/outputs.rs
- nueva enum Disposicion pub
- nueva def ESCALA_100 pub
- nueva struct Salida pub
- nueva fn con_escala pub
- nueva fn disponer pub
- nueva fn disponer_logico pub
- nueva fn disponer_logico_continua_el_plano_entre_dpis_distintas
- nueva fn disponer_logico_sin_escala_coincide_con_disponer
- nueva fn envolvente pub
- nueva fn envolvente_horizontal_suma_ancho_y_toma_alto_maximo
- nueva fn envolvente_vertical_suma_alto_y_toma_ancho_maximo
- nueva fn escala_100_equivale_a_los_fisicos
- nueva fn escala_no_positiva_se_trata_como_100
- nueva fn horizontal_encadena_origenes_por_ancho
- nueva fn logico pub
- nueva fn logico_divide_los_fisicos_por_la_escala
- nueva fn new pub
- nueva fn primario_unico_queda_en_origen
- nueva fn sin_outputs_da_vec_vacio
- nueva fn tamano_no_positivo_no_avanza_el_cursor
- nueva mod tests
- nueva fn vertical_apila_por_alto
02_ruway/mirada/mirada-layout/src/workspace.rs
- nueva type WindowId pub
- nueva struct Workspace pub
- nueva fn a_floating_window_keeps_its_rect_and_goes_last
- nueva fn add pub
- nueva fn add_focuses_the_new_window
- nueva fn adding_an_existing_window_just_focuses_it
- nueva fn empty_workspace_lays_out_nothing
- nueva fn floating_rect pub
- nueva fn focus_cycles_both_ways
- nueva fn focus_next pub
- nueva fn focus_prev pub
- nueva fn focus_window pub
- nueva fn focused pub
- nueva fn fullscreen pub
- nueva fn is_empty pub
- nueva fn is_floating pub
- nueva fn layout pub
- nueva fn layout_pairs_each_window_with_a_rect
- nueva fn len pub
- nueva fn move_focused_backward pub
- nueva fn move_focused_forward pub
- nueva fn move_focused_reorders_tiling
- nueva fn new pub
- nueva fn params pub
- nueva fn promote_brings_the_focused_window_to_the_front
- nueva fn promote_focused pub
- nueva fn remove pub
- nueva fn remove_before_focus_shifts_it
- nueva fn remove_keeps_focus_valid
- nueva fn remove_last_window_empties_workspace
- nueva fn removing_a_window_clears_its_floating_state
- nueva fn set_floating pub
- nueva fn set_fullscreen pub
- nueva fn set_master_count pub
- nueva fn set_master_ratio pub
- nueva fn set_mode pub
- nueva fn set_params pub
- nueva fn swap pub
- nueva fn swap_exchanges_two_windows_and_focuses_the_first
- nueva mod tests
- nueva fn windows pub
- nueva fn ws
02_ruway/mirada/mirada-link/src/lib.rs
- nueva type BodyLink pub
- nueva type BrainLink pub
- nueva struct Link pub
- nueva fn body_event_reaches_the_brain
- nueva fn brain_command_reaches_the_body
- nueva fn connect pub
- nueva fn connect_and_listen_round_trip_over_a_path
- nueva fn connected_pair pub
- nueva fn drain pub
- nueva fn drain_collects_everything_pending
- nueva fn drop
- nueva fn dropping_one_end_closes_the_other
- nueva fn from_stream pub
- nueva fn listen pub
- nueva fn many_messages_keep_their_order
- nueva fn place
- nueva fn recv pub
- nueva fn send pub
- nueva fn sending_into_a_closed_link_errors
- nueva mod tests
- nueva fn try_recv pub
02_ruway/mirada/mirada-portal/src/main.rs
- nueva def APPEARANCE_NS
- nueva def BUS_NAME
- nueva def OBJ_PATH
- nueva struct SettingsPortal
- nueva fn accent_value
- nueva fn appearance_map
- nueva fn appearance_map_has_three_keys
- nueva fn emit_appearance_changed
- nueva fn init_tracing
- nueva fn into_owned
- nueva fn lookup
- nueva fn lookup_color_scheme_ok
- nueva fn lookup_unknown_key_errs
- nueva fn lookup_unknown_namespace_errs
- nueva fn main
- nueva fn namespace_empty_matches_all
- nueva fn namespace_exact_match
- nueva fn namespace_requested
- nueva fn namespace_wildcard_prefix
- nueva fn read
- nueva fn read_all
- nueva fn read_facts
- nueva fn read_one
- nueva fn run
- nueva fn spawn_theme_watcher
- nueva mod tests
- nueva fn theme_config_path
- nueva mod theme_facts
- nueva fn version
- nueva fn wait_for_term
02_ruway/mirada/mirada-portal/src/theme_facts.rs
- nueva def FALLBACK pub
- nueva struct ThemeFacts pub
- nueva fn accent_rgb pub
- nueva fn accent_rgb_in_range
- nueva fn approx
- nueva fn color_scheme pub
- nueva fn contrast pub
- nueva fn facts_for pub
- nueva fn high_contrast_only_for_high_contrast_theme
- nueva fn hsl_grays
- nueva fn hsl_primaries
- nueva fn hsl_to_rgb
- nueva fn known_themes_map_color_scheme
- nueva fn rgb_eq
- nueva mod tests
- nueva fn unknown_theme_falls_back
02_ruway/mirada/mirada-protocol/src/lib.rs
- nueva enum BodyEvent pub
- nueva enum BrainCommand pub
- nueva struct Decorations pub
- nueva def MAX_FRAME pub
- nueva type OutputId pub
- nueva def SCREEN
- nueva struct WindowPlacement pub
- nueva fn a_floating_window_is_marked_and_stays_visible_in_monocle
- nueva fn a_fullscreen_window_covers_the_screen_and_hides_the_rest
- nueva fn an_empty_workspace_places_nothing
- nueva fn an_oversized_length_prefix_is_rejected
- nueva fn default
- nueva fn default_decorations_are_the_historic_values
- nueva fn empty_reader_is_a_clean_eof
- nueva fn frame_round_trips_a_body_event
- nueva fn frame_round_trips_a_brain_command
- nueva fn frame_round_trips_a_set_decorations_command
- nueva fn frame_round_trips_the_new_body_events
- nueva fn monocle_keeps_only_the_focused_window_visible
- nueva fn placements pub
- nueva fn placements_cover_every_window
- nueva fn placements_fill_a_place_command_round_trip
- nueva fn read_frame pub
- nueva fn several_frames_stream_in_order
- nueva mod tests
- nueva fn write_frame pub
- nueva fn ws
shared/app-bus/src/lib.rs
- nueva struct AppEntry pub
- nueva struct AppFile
- nueva struct AppMenu pub
- nueva struct AppRegistry pub
- nueva struct Bus pub
- nueva enum BusEvent pub
- nueva def DEFAULTS
- nueva enum Launch pub
- nueva enum LaunchError pub
- nueva struct LaunchFile
- nueva trait Launcher pub
- nueva struct Menu pub
- nueva struct MenuItem pub
- nueva struct ProcessLauncher pub
- nueva fn all pub
- nueva fn apps_dir pub
- nueva fn bus_fanout_y_poda
- nueva fn disabled pub
- nueva fn discover pub
- nueva fn expand_target pub
- nueva fn expand_target_agrega_si_no_hay_placeholder
- nueva fn expand_target_sustituye_placeholder
- nueva fn from_dir pub
- nueva fn get pub
- nueva fn handlers_for pub
- nueva fn handles_mime pub
- nueva fn icon pub
- nueva fn in_category pub
- nueva fn into_entry
- nueva fn is_empty pub
- nueva fn is_empty pub
- nueva fn item pub
- nueva fn launch
- nueva fn launch_sin_modo_es_none
- nueva fn len pub
- nueva fn manifiestos_de_ejemplo_parsean_y_resuelven_handlers
- nueva fn menu pub
- nueva fn menu_estandar_y_builder
- nueva fn menu_roundtrip_serde
- nueva fn new pub
- nueva fn new pub
- nueva fn new pub
- nueva fn new pub
- nueva fn new pub
- nueva fn open pub
- nueva fn open_with pub
- nueva fn open_with_sin_handler_devuelve_none
- nueva fn open_with_spawnea_handler_y_le_pasa_el_target
- nueva fn parse_action_and_wasm
- nueva fn parse_entry pub
- nueva fn parse_exec_entry
- nueva fn process_launcher_unsupported_para_action
- nueva fn publish pub
- nueva fn registry_from_dir_y_consultas
- nueva fn resolve
- nueva fn seed_default_apps pub
- nueva fn separated pub
- nueva fn shortcut pub
- nueva fn spawn pub
- nueva fn standard pub
- nueva fn subscribe pub
- nueva mod tests
- nueva fn yes
shared/auth/auth-core/examples/auth-probe.rs
- nueva fn main
shared/auth/auth-core/src/lib.rs
- nueva enum AuthError pub
- nueva trait Authenticator pub
- nueva struct MockAuthenticator pub
- nueva fn auth_error_is_displayable
- nueva fn authenticate
- nueva fn empty_mock_rejects_everything
- nueva fn mock_accepts_registered_user
- nueva fn mock_rejects_wrong_password
- nueva fn mock_unknown_user_indistinguishable_from_wrong_password
- nueva fn new pub
- nueva mod pam_backend
- nueva mod tests
- nueva mod ticket
- nueva mod user
- nueva fn with_user pub
shared/auth/auth-core/src/pam_backend.rs
- nueva def DEFAULT_SERVICE pub
- nueva struct PamAuthenticator pub
- nueva fn authenticate
- nueva fn carmen pub
- nueva fn carmen_uses_default_service
- nueva fn custom_service_name
- nueva fn default
- nueva fn map_pam_error
- nueva fn new pub
- nueva fn service pub
- nueva mod tests
- nueva fn unknown_service_fails_gracefully
shared/auth/auth-core/src/ticket.rs
- nueva struct SessionTicket pub
- nueva def TICKET_TAG pub
- nueva fn foreign pub
- nueva fn foreign_defaults_false_sin_campo
- nueva fn from_line pub
- nueva fn from_line_ignores_non_ticket
- nueva fn from_line_rejects_malformed
- nueva fn new pub
- nueva fn round_trip_with_foreign
- nueva fn round_trip_with_session
- nueva fn round_trip_without_session
- nueva fn sample
- nueva mod tests
- nueva fn to_line pub
- nueva fn tolerates_trailing_newline
- nueva fn with_session pub
shared/auth/auth-core/src/user.rs
- nueva struct UserInfo pub
- nueva fn resolve_user pub
- nueva fn resolves_root
- nueva fn synthetic pub
- nueva fn synthetic_has_home_under_slash_home
- nueva mod tests
- nueva fn unknown_user_errs
shared/format/src/cable.rs
- nueva enum AccionPropuesta pub
- nueva def CANAL_ASISTENTE pub
- nueva struct Contexto pub
- nueva def ETHERTYPE_ASISTENTE pub
- nueva enum MensajeAsistente pub
- nueva def TAM_CABECERA_CABLE pub
- nueva def TIPO_OBJETO_CONFIGURACION pub
- nueva def TIPO_OBJETO_CUADERNO pub
- nueva enum TipoCable pub
- nueva fn componer_registro pub
- nueva fn de_u16 pub
- nueva fn deserializar pub
- nueva fn escribir_cabecera_cable pub
- nueva fn hash pub
- nueva fn leer_cabecera_cable pub
- nueva fn longitud_registro pub
- nueva fn sectores_registro pub
- nueva fn serializar pub
shared/format/src/constantes.rs
- nueva type AgoraId pub
- nueva enum CodigoError pub
- nueva type Firma pub
- nueva type Hash pub
- nueva def MAGIA pub
- nueva def MAX_OBJETO pub
- nueva def NOMBRE_CANAL_LIMITE pub
- nueva def TAM_SECTOR pub
- nueva def VERSION_CANAL pub
- nueva def VERSION_CONFIGURACION pub
- nueva def VERSION_MANIFIESTO pub
- nueva def VERSION_SUPERBLOQUE pub
- nueva fn como_i32 pub
shared/format/src/firma.rs
- nueva struct ConcesionCapacidad pub
- nueva struct CuadernoFirmado pub
- nueva struct FirmaRevocacion pub
- nueva struct OverlayRevocacion pub
- nueva struct RaizFirmada pub
- nueva struct RevocacionFirmada pub
- nueva def VERSION_OVERLAY pub
- nueva fn deserializar pub
- nueva fn deserializar pub
- nueva fn deserializar pub
- nueva fn serializar pub
- nueva fn serializar pub
- nueva fn serializar pub
shared/format/src/grafo.rs
- nueva struct Arbol pub
- nueva struct CeldaWawa pub
- nueva def DOM_REVOCACION_CLAVE pub
- nueva def DOM_ROTACION_CLAVE pub
- nueva struct EntradaArbol pub
- nueva enum ModoEntrada pub
- nueva def VERSION_ARBOL pub
- nueva fn deserializar pub
- nueva fn deserializar pub
- nueva fn deserializar pub
- nueva fn deserializar pub
- nueva fn deserializar pub
- nueva fn deserializar_celdas pub
- nueva fn es_archivo pub
- nueva fn mensaje_a_firmar pub
- nueva fn mensaje_capacidad pub
- nueva fn mensaje_revocacion_clave pub
- nueva fn mensaje_rotacion_clave pub
- nueva fn objeto_arbol pub
- nueva fn objeto_blob pub
- nueva fn objeto_blob_indice pub
- nueva fn permisos_efectivos pub
- nueva fn serializar pub
- nueva fn serializar pub
- nueva fn serializar pub
- nueva fn serializar pub
- nueva fn serializar pub
- nueva fn serializar_celdas pub
- nueva fn vigente pub
shared/format/src/lib.rs
shared/format/src/pruebas.rs
- nueva def VANGUARDIA
- nueva fn arbol_es_determinista_independiente_del_orden_de_entrada
- nueva fn arbol_ordena_entradas_por_nombre
- nueva fn arbol_rechaza_version_desconocida
- nueva fn blob_no_tiene_hijos
- nueva fn cabecera_a_cero_es_fin_del_log
- nueva fn cabecera_cable_rechaza_canal_ajeno
- nueva fn cabecera_cable_rechaza_tipo_desconocido
- nueva fn cabecera_cable_rechaza_truncada
- nueva fn cabecera_cable_round_trip_consulta
- nueva fn cabecera_cable_round_trip_firma
- nueva fn cabecera_cable_round_trip_propuesta_lanzar
- nueva fn cabecera_cable_round_trip_request_firma
- nueva fn canal_asistente_no_choca_con_otros
- nueva fn canal_ida_y_vuelta_con_dos_raices
- nueva fn canal_rechaza_version_y_nombre_excedido
- nueva fn codigo_error_tiene_valores_estables
- nueva fn concesion_capacidad_roundtrip
- nueva fn configuracion_ida_y_vuelta_y_rechaza_version
- nueva fn configuracion_por_defecto_es_estable
- nueva fn cuaderno_acumulativo_anexa_celdas_en_orden
- nueva fn cuaderno_firmado_ida_y_vuelta
- nueva fn cuaderno_ida_y_vuelta_con_celdas_mixtas
- nueva fn entrada_app_transporta_permisos_y_distingue_hash
- nueva fn escribir_cabecera_cable_rechaza_buffer_corto
- nueva fn ethertype_asistente_distinto_de_akasha
- nueva fn idioma_iso639_empaqueta_en_little_endian
- nueva fn indice_de_blob_grande_tiene_datos_vacio_e_hijos
- nueva fn manifiesto_firmado_ida_y_vuelta
- nueva fn manifiesto_rechaza_version_ajena
- nueva fn manifiesto_transporta_enlace_de_configuracion
- nueva fn mensaje_a_firmar_es_canonico_y_distingue_canales
- nueva fn mensaje_asistente_basura_rechazada
- nueva fn mensaje_asistente_consulta_ida_y_vuelta
- nueva fn mensaje_asistente_error_ida_y_vuelta
- nueva fn mensaje_asistente_propuesta_instalar_app
- nueva fn mensaje_asistente_propuesta_lanzar_app
- nueva fn mensaje_asistente_propuesta_notar_sin_efecto
- nueva fn mensaje_capacidad_es_canonico_y_distingue_bytecode_y_permisos
- nueva fn mensaje_revocacion_clave_distingue_motivo_y_no_colisiona_none_some_cero
- nueva fn mensaje_rotacion_clave_layout_y_dominio
- nueva fn modo_es_archivo_distingue_contenido_de_estructura
- nueva fn modos_nuevos_sobreviven_round_trip_en_arbol
- nueva fn objeto_ida_y_vuelta
- nueva fn overlay_revocacion_roundtrip_y_rechaza_version_ajena
- nueva fn permisos_efectivos_es_la_interseccion
- nueva fn registro_alineado_a_sector
- nueva fn superbloque_cabe_en_un_sector_y_vuelve_intacto
- nueva fn superbloque_porta_log_inicio_distinto_de_uno
- nueva fn test_wawa_ecosystem_immutable_vanguard
- nueva fn tipo_cable_codigos_estables
- nueva fn tipo_cable_de_u16_acepta_nuevos
- nueva fn tipo_objeto_codigos_estables
shared/format/src/tipos.rs
- nueva struct Canal pub
- nueva struct Configuracion pub
- nueva struct EntradaApp pub
- nueva def IDIOMA_DEFECTO pub
- nueva type IdiomaCodigo pub
- nueva struct Manifiesto pub
- nueva struct ManifiestoFirmado pub
- nueva struct Objeto pub
- nueva def PALETA_DEFECTO pub
- nueva def PERMISO_ALTAVOZ pub
- nueva def PERMISO_COMPACTAR pub
- nueva def PERMISO_CONFIG pub
- nueva def PERMISO_GRAFO_ESCRITURA pub
- nueva def PERMISO_RAIZ pub
- nueva def PERMISO_RED pub
- nueva def PERMISO_TINKUY pub
- nueva type Paleta pub
- nueva type Permisos pub
- nueva struct SuperBloque pub
- nueva fn deserializar pub
- nueva fn deserializar pub
- nueva fn idioma_iso639 pub
- nueva fn por_defecto pub
- nueva fn serializar pub
- nueva fn serializar pub
shared/rimay-localize/src/lib.rs
- nueva def CATALOGS
- nueva def FALLBACK_LOCALE pub
- nueva enum LocalizeError pub
- nueva def SERIAL
- nueva def STATE
- nueva struct State
- nueva fn args_interpolate
- nueva fn available_locales pub
- nueva fn available_locales_lists_all
- nueva fn best_match
- nueva fn best_match_region_fallback
- nueva fn current_locale pub
- nueva fn ensure_bundle
- nueva fn fallback_locale_resolves
- nueva fn init pub
- nueva fn new
- nueva fn quechua_loads
- nueva fn resolve
- nueva fn set_locale pub
- nueva fn switches_to_english
- nueva fn t pub
- nueva fn t_args pub
- nueva mod tests
- nueva fn unknown_id_returns_id_as_degradation
- nueva fn unknown_locale_errors
shared/wawa-config/src/lib.rs
- nueva def ACCENTS pub
- nueva def AGORA pub
- nueva def AKASHA pub
- nueva def CHASQUI pub
- nueva def CONFIG_DIR pub
- nueva def CONFIG_FILE pub
- nueva enum ConfigError pub
- nueva struct ConfigWatcher pub
- nueva enum Layer pub
- nueva def MINGA pub
- nueva def MIRADA pub
- nueva def QUIET
- nueva def SHUMA pub
- nueva def SYSTEM_CONFIG_DIR_LINUX pub
- nueva def THEME_VARIANTS pub
- nueva struct WawaConfig pub
- nueva fn accent_rgb pub
- nueva fn accent_rgb_default_is_none
- nueva fn canonical_theme_maps_variants
- nueva fn canonical_theme_name pub
- nueva fn config_dir pub
- nueva fn constants_match_helpers
- nueva fn debounce_loop
- nueva fn default
- nueva fn default_accent
- nueva fn default_lang
- nueva fn default_modules
- nueva fn default_round_trip
- nueva fn default_theme_variant
- nueva fn default_timefmt
- nueva fn defaults pub
- nueva fn load pub
- nueva fn load_layer pub
- nueva fn load_layer_value
- nueva fn merge_json
- nueva fn merge_modules_is_deep_per_key
- nueva fn merge_user_over_system_overrides_field_by_field
- nueva fn module_enabled pub
- nueva mod modules pub
- nueva fn partial_json_uses_defaults
- nueva fn path pub
- nueva fn path_for pub
- nueva fn save pub
- nueva fn save_to pub
- nueva fn spawn pub
- nueva fn spawn_layer_watcher
- nueva fn system_config_dir pub
- nueva fn system_config_path pub
- nueva fn system_path_only_on_linux
- nueva mod tests
- nueva fn toggle_module pub
- nueva fn toggle_module_persists_value
- nueva fn unknown_fields_ignored
- nueva fn user_config_path pub
- nueva fn watch_path pub
116 archivos · +31060 −0
- .gitignore +3 −0
- 02_ruway/llimphi/widgets/menubar/Cargo.toml +15 −0
- 02_ruway/llimphi/widgets/menubar/src/lib.rs +334 −0
- 02_ruway/mirada/mirada-app-llimphi/Cargo.toml +25 −0
- 02_ruway/mirada/mirada-app-llimphi/LEEME.md +9 −0
- 02_ruway/mirada/mirada-app-llimphi/README.md +9 −0
- 02_ruway/mirada/mirada-app-llimphi/src/main.rs +1138 −0
- 02_ruway/mirada/mirada-bar-core/Cargo.toml +8 −0
- 02_ruway/mirada/mirada-bar-core/LEEME.md +9 −0
- 02_ruway/mirada/mirada-bar-core/README.md +9 −0
- 02_ruway/mirada/mirada-bar-core/src/lib.rs +108 −0
- 02_ruway/mirada/mirada-body/Cargo.toml +14 −0
- 02_ruway/mirada/mirada-body/LEEME.md +10 −0
- 02_ruway/mirada/mirada-body/README.md +10 −0
- 02_ruway/mirada/mirada-body/examples/headless.rs +121 −0
- 02_ruway/mirada/mirada-body/src/lib.rs +480 −0
- 02_ruway/mirada/mirada-brain/Cargo.toml +18 −0
- 02_ruway/mirada/mirada-brain/LEEME.md +9 −0
- 02_ruway/mirada/mirada-brain/README.md +9 −0
- 02_ruway/mirada/mirada-brain/examples/headless-ctl.rs +117 −0
- 02_ruway/mirada/mirada-brain/examples/keymap-default.rs +11 −0
- 02_ruway/mirada/mirada-brain/src/action.rs +454 −0
- 02_ruway/mirada/mirada-brain/src/config.rs +843 −0
- 02_ruway/mirada/mirada-brain/src/ctl.rs +229 −0
- 02_ruway/mirada/mirada-brain/src/desktop.rs +1946 −0
- 02_ruway/mirada/mirada-brain/src/keymap.rs +332 −0
- 02_ruway/mirada/mirada-brain/src/lib.rs +42 −0
- 02_ruway/mirada/mirada-brain/src/rules.rs +216 −0
- 02_ruway/mirada/mirada-brain/src/watch.rs +87 −0
- 02_ruway/mirada/mirada-compositor/Cargo.toml +26 −0
- 02_ruway/mirada/mirada-compositor/LEEME.md +221 −0
- 02_ruway/mirada/mirada-compositor/README.md +15 −0
- 02_ruway/mirada/mirada-compositor/session/autostart.example +21 −0
- 02_ruway/mirada/mirada-compositor/session/carmen.desktop +6 −0
- 02_ruway/mirada/mirada-compositor/session/mirada-pata.desktop +6 −0
- 02_ruway/mirada/mirada-compositor/session/mirada-session +30 −0
- 02_ruway/mirada/mirada-compositor/session/mirada-session-pata +26 −0
- 02_ruway/mirada/mirada-compositor/src/drm_backend.rs +2448 −0
- 02_ruway/mirada/mirada-compositor/src/main.rs +2572 −0
- 02_ruway/mirada/mirada-compositor/src/menu.rs +373 −0
- 02_ruway/mirada/mirada-compositor/src/text.rs +173 −0
- 02_ruway/mirada/mirada-ctl/Cargo.toml +16 −0
- 02_ruway/mirada/mirada-ctl/LEEME.md +16 −0
- 02_ruway/mirada/mirada-ctl/README.md +16 −0
- 02_ruway/mirada/mirada-ctl/src/main.rs +151 −0
- 02_ruway/mirada/mirada-greeter/Cargo.toml +27 −0
- 02_ruway/mirada/mirada-greeter/LEEME.md +44 −0
- 02_ruway/mirada/mirada-greeter/README.md +16 −0
- 02_ruway/mirada/mirada-greeter/src/main.rs +896 −0
- 02_ruway/mirada/mirada-greeter/src/rain.rs +163 −0
- 02_ruway/mirada/mirada-greeter/src/sessions.rs +268 −0
- 02_ruway/mirada/mirada-greeter/src/state.rs +217 −0
- 02_ruway/mirada/mirada-launcher/Cargo.toml +15 −0
- 02_ruway/mirada/mirada-launcher/LEEME.md +15 −0
- 02_ruway/mirada/mirada-launcher/README.md +15 −0
- 02_ruway/mirada/mirada-launcher/src/main.rs +274 −0
- 02_ruway/mirada/mirada-layout/Cargo.toml +25 −0
- 02_ruway/mirada/mirada-layout/LEEME.md +9 −0
- 02_ruway/mirada/mirada-layout/README.md +9 −0
- 02_ruway/mirada/mirada-layout/src/geometry.rs +107 −0
- 02_ruway/mirada/mirada-layout/src/layout.rs +628 −0
- 02_ruway/mirada/mirada-layout/src/lib.rs +31 −0
- 02_ruway/mirada/mirada-layout/src/outputs.rs +230 −0
- 02_ruway/mirada/mirada-layout/src/workspace.rs +414 −0
- 02_ruway/mirada/mirada-link/Cargo.toml +12 −0
- 02_ruway/mirada/mirada-link/LEEME.md +9 −0
- 02_ruway/mirada/mirada-link/README.md +9 −0
- 02_ruway/mirada/mirada-link/src/lib.rs +252 −0
- 02_ruway/mirada/mirada-portal/Cargo.toml +21 −0
- 02_ruway/mirada/mirada-portal/LEEME.md +76 −0
- 02_ruway/mirada/mirada-portal/README.md +10 −0
- 02_ruway/mirada/mirada-portal/data/mirada-portals.conf +3 −0
- 02_ruway/mirada/mirada-portal/data/mirada.portal +4 −0
- 02_ruway/mirada/mirada-portal/data/org.freedesktop.impl.portal.desktop.mirada.service +3 −0
- 02_ruway/mirada/mirada-portal/src/main.rs +430 −0
- 02_ruway/mirada/mirada-portal/src/theme_facts.rs +199 −0
- 02_ruway/mirada/mirada-protocol/Cargo.toml +15 −0
- 02_ruway/mirada/mirada-protocol/LEEME.md +9 −0
- 02_ruway/mirada/mirada-protocol/README.md +9 −0
- 02_ruway/mirada/mirada-protocol/src/lib.rs +425 −0
- Cargo.lock +5516 −0
- Cargo.toml +447 −0
- LICENSE +21 −0
- README.md +43 −0
- shared/app-bus/Cargo.toml +27 −0
- shared/app-bus/README.md +38 −0
- shared/app-bus/assets/apps/media.toml +32 −0
- shared/app-bus/assets/apps/nada.toml +40 −0
- shared/app-bus/src/lib.rs +795 −0
- shared/auth/auth-core/Cargo.toml +17 −0
- shared/auth/auth-core/LEEME.md +52 −0
- shared/auth/auth-core/data/carmen +15 −0
- shared/auth/auth-core/examples/auth-probe.rs +41 −0
- shared/auth/auth-core/src/lib.rs +141 −0
- shared/auth/auth-core/src/pam_backend.rs +120 −0
- shared/auth/auth-core/src/ticket.rs +175 −0
- shared/auth/auth-core/src/user.rs +79 −0
- shared/format/Cargo.toml +41 −0
- shared/format/README.md +36 −0
- shared/format/src/cable.rs +290 −0
- shared/format/src/constantes.rs +163 −0
- shared/format/src/firma.rs +222 −0
- shared/format/src/grafo.rs +400 −0
- shared/format/src/lib.rs +48 −0
- shared/format/src/pruebas.rs +904 −0
- shared/format/src/tipos.rs +334 −0
- shared/rimay-localize/Cargo.toml +20 −0
- shared/rimay-localize/README.md +34 −0
- shared/rimay-localize/examples/showcase.rs +37 −0
- shared/rimay-localize/locales/en.ftl +720 −0
- shared/rimay-localize/locales/es.ftl +723 −0
- shared/rimay-localize/locales/qu.ftl +724 −0
- shared/rimay-localize/src/lib.rs +333 −0
- shared/wawa-config/Cargo.toml +15 −0
- shared/wawa-config/README.md +35 −0
- shared/wawa-config/src/lib.rs +762 −0
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b7141ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/target
+**/*.rs.bk
+*.pdb
diff --git a/02_ruway/llimphi/widgets/menubar/Cargo.toml b/02_ruway/llimphi/widgets/menubar/Cargo.toml
new file mode 100644
index 0000000..d71f673
--- /dev/null
+++ b/02_ruway/llimphi/widgets/menubar/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "llimphi-widget-menubar"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "llimphi-widget-menubar — barra de menú principal in-window (Archivo/Editar/Ver/Ayuda) que cualquier app Llimphi monta a partir de un app_bus::AppMenu. menubar_view() pinta la fila de títulos; menubar_overlay() el dropdown (vía context-menu) para App::view_overlay. Decoplado del Surface del launcher: sirve dentro de la ventana de cada app."
+
+[dependencies]
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-widget-button = { workspace = true }
+llimphi-widget-context-menu = { workspace = true }
+app-bus = { path = "../../../../shared/app-bus" }
diff --git a/02_ruway/llimphi/widgets/menubar/src/lib.rs b/02_ruway/llimphi/widgets/menubar/src/lib.rs
new file mode 100644
index 0000000..abd178d
--- /dev/null
+++ b/02_ruway/llimphi/widgets/menubar/src/lib.rs
@@ -0,0 +1,334 @@
+//! `llimphi-widget-menubar` — la barra de menú principal de una app.
+//!
+//! Toda app Llimphi declara un [`app_bus::AppMenu`] (Archivo / Editar /
+//! Ver / Ayuda …) y lo monta in-window con este widget. Es el gemelo de
+//! la barra global de [`launcher_llimphi`], pero vive **dentro** de la
+//! ventana de la app — para las apps que corren standalone y no bajo el
+//! shell del launcher.
+//!
+//! Sin estado, al estilo Llimphi: el `Model` del host lleva qué menú raíz
+//! está abierto (`Option<usize>`); el widget aplana el `AppMenu` y emite
+//! `Msg` en cada interacción.
+//!
+//! Dos entradas:
+//! - [`menubar_view`] → la fila de títulos, para el tope de `App::view`.
+//! - [`menubar_overlay`] → el dropdown del menú abierto, para
+//! `App::view_overlay` (devolvé `None` si no hay nada abierto).
+//!
+//! El `command` de cada ítem es el id que la app entiende (convención
+//! `menu.<verbo>`, ver [`app_bus::AppMenu::standard`]); el widget lo
+//! rebota por `on_command`.
+
+#![forbid(unsafe_code)]
+
+use std::sync::Arc;
+
+use app_bus::{AppMenu, Menu};
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+ prelude::{auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style},
+ Rect,
+};
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::View;
+use llimphi_widget_button::{button_styled, ButtonPalette};
+use llimphi_widget_context_menu::{
+ context_menu_view_ex, step_active, ContextMenuExtras, ContextMenuItem, ContextMenuPalette,
+ ContextMenuSpec,
+};
+
+type MsgFromMenu<Msg> = Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>;
+type MsgFromStr<Msg> = Arc<dyn Fn(&str) -> Msg + Send + Sync>;
+
+/// Todo lo que el render necesita. El host lo arma en cada `view()`.
+pub struct MenuBarSpec<'a, Msg: Clone + 'static> {
+ /// El menú a pintar (típicamente `AppMenu::standard()` + menús propios).
+ pub menu: &'a AppMenu,
+ /// Índice del menú raíz abierto (estado del host). `None` = ninguno.
+ pub open: Option<usize>,
+ pub theme: &'a Theme,
+ /// Tamaño de la ventana — para clampear el dropdown.
+ pub viewport: (f32, f32),
+ /// Alto de la barra (px). Usar [`DEFAULT_HEIGHT`] si no hay razón.
+ pub height: f32,
+ /// Abrir/cerrar un menú raíz por índice (`None` = cerrar).
+ pub on_open: MsgFromMenu<Msg>,
+ /// command id → Msg, al elegir un ítem.
+ pub on_command: MsgFromStr<Msg>,
+}
+
+/// Alto recomendado de la barra de menú.
+pub const DEFAULT_HEIGHT: f32 = 30.0;
+
+fn title_palette(theme: &Theme) -> ButtonPalette {
+ ButtonPalette::from_theme(theme)
+}
+
+fn title_palette_active(theme: &Theme) -> ButtonPalette {
+ let base = ButtonPalette::from_theme(theme);
+ ButtonPalette {
+ bg: theme.accent,
+ bg_hover: theme.accent,
+ fg: theme.bg_panel,
+ radius: base.radius,
+ }
+}
+
+/// La fila de títulos (Archivo / Editar / …). Click sobre un título
+/// togglea su dropdown vía `on_open`. El abierto se resalta con el accent.
+/// `hover_switch = true` agrega `on_pointer_enter` a cada título para que,
+/// con un menú ya abierto, pasar el mouse sobre otro título cambie de menú
+/// (comportamiento clásico de barra de menú) — sólo se usa en el overlay,
+/// donde los títulos quedan por encima del scrim y son hovereables.
+fn titles_row<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>, hover_switch: bool) -> View<Msg> {
+ let pal = title_palette(spec.theme);
+ let pal_on = title_palette_active(spec.theme);
+
+ let mut titles: Vec<View<Msg>> = Vec::with_capacity(spec.menu.menus.len());
+ for (i, root) in spec.menu.menus.iter().enumerate() {
+ let open = spec.open == Some(i);
+ let target = if open { None } else { Some(i) };
+ let mut title = button_styled(
+ root.label.clone(),
+ title_style(),
+ Alignment::Center,
+ if open { &pal_on } else { &pal },
+ (spec.on_open)(target),
+ );
+ // Con un menú abierto, hover sobre otro título lo abre.
+ if hover_switch && !open {
+ title = title.on_pointer_enter((spec.on_open)(Some(i)));
+ }
+ titles.push(title);
+ }
+
+ View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(spec.height),
+ },
+ flex_shrink: 0.0,
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ padding: Rect {
+ left: length(6.0_f32),
+ right: length(6.0_f32),
+ top: length(0.0_f32),
+ bottom: length(0.0_f32),
+ },
+ gap: Size {
+ width: length(2.0_f32),
+ height: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(spec.theme.bg_panel_alt)
+ .children(titles)
+}
+
+/// La barra de menú principal — primer hijo del column raíz de `view()`.
+pub fn menubar_view<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>) -> View<Msg> {
+ titles_row(spec, false)
+}
+
+/// Aplana un menú raíz al par alineado `(items, commands)` que consume el
+/// context-menu (los separadores `separator_before` se insertan como
+/// filas y llevan `command = None`). Es la única fuente de verdad del
+/// orden de filas — la navegación por teclado y el render comparten esto.
+fn dropdown_items(root: &Menu) -> (Vec<ContextMenuItem>, Vec<Option<String>>) {
+ let mut items: Vec<ContextMenuItem> = Vec::new();
+ let mut commands: Vec<Option<String>> = Vec::new();
+ for (k, src) in root.items.iter().enumerate() {
+ if src.separator_before && k != 0 {
+ items.push(ContextMenuItem::separator());
+ commands.push(None);
+ }
+ let mut cm = ContextMenuItem::action(src.label.clone());
+ if let Some(s) = &src.shortcut {
+ cm = cm.with_shortcut(s.clone());
+ }
+ if let Some(ic) = &src.icon {
+ cm = cm.icon(ic.clone());
+ }
+ if !src.enabled {
+ cm = cm.disabled();
+ }
+ items.push(cm);
+ commands.push(Some(src.command.clone()));
+ }
+ (items, commands)
+}
+
+/// El dropdown del menú abierto, para `App::view_overlay`. `None` si no
+/// hay menú abierto. Hospeda además una copia de la fila de títulos por
+/// encima del scrim: así, con el menú abierto, mover el mouse a otro
+/// título cambia de menú (hover-switch).
+pub fn menubar_overlay<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>) -> Option<View<Msg>> {
+ menubar_overlay_core(spec, usize::MAX, 1.0)
+}
+
+/// Como [`menubar_overlay`] pero con `active` (fila resaltada por teclado;
+/// `usize::MAX` = ninguna) y `appear` (0..1, animación de aparición — útil
+/// para que el dropdown se deslice/funda al cambiar de menú por hover o
+/// flechas). La app guarda el `active` y un `Tween` para el `appear`.
+pub fn menubar_overlay_animated<Msg: Clone + 'static>(
+ spec: &MenuBarSpec<Msg>,
+ active: usize,
+ appear: f32,
+) -> Option<View<Msg>> {
+ menubar_overlay_core(spec, active, appear)
+}
+
+fn menubar_overlay_core<Msg: Clone + 'static>(
+ spec: &MenuBarSpec<Msg>,
+ active: usize,
+ appear: f32,
+) -> Option<View<Msg>> {
+ let idx = spec.open?;
+ let root = spec.menu.menus.get(idx)?;
+
+ let mut x = 6.0_f32;
+ for prev in spec.menu.menus.iter().take(idx) {
+ x += approx_title_width(&prev.label);
+ }
+
+ let (items, commands) = dropdown_items(root);
+
+ let on_command = spec.on_command.clone();
+ let on_open = spec.on_open.clone();
+ let commands = Arc::new(commands);
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
+ match commands.get(i).and_then(|c| c.clone()) {
+ Some(cmd) => (on_command)(&cmd),
+ None => (on_open)(None),
+ }
+ });
+
+ let dropdown = context_menu_view_ex(
+ ContextMenuSpec {
+ anchor: (x, spec.height),
+ viewport: spec.viewport,
+ header: Some(root.label.clone()),
+ items,
+ active,
+ on_pick,
+ on_dismiss: (spec.on_open)(None),
+ palette: ContextMenuPalette::from_theme(spec.theme),
+ },
+ ContextMenuExtras {
+ appear,
+ ..ContextMenuExtras::default()
+ },
+ );
+
+ // Fila de títulos por encima del scrim del dropdown: queda hovereable
+ // para cambiar de menú con el mouse. Absoluta al tope para no consumir
+ // el layout; se pinta después del dropdown ⇒ arriba en z-order ⇒ gana
+ // el hit-test.
+ let titles = View::new(Style {
+ position: Position::Absolute,
+ inset: Rect {
+ left: length(0.0_f32),
+ top: length(0.0_f32),
+ right: auto(),
+ bottom: auto(),
+ },
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(spec.height),
+ },
+ ..Default::default()
+ })
+ .children(vec![titles_row(spec, true)]);
+
+ Some(
+ View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![dropdown, titles]),
+ )
+}
+
+/// Navegación por teclado dentro del dropdown del menú `menu_idx`: dado el
+/// `active` actual y la dirección (`+1` baja, `-1` sube), devuelve el
+/// próximo índice de fila válido (saltea separadores y deshabilitados).
+/// `usize::MAX` si no hay menú abierto o sin filas elegibles.
+pub fn menubar_nav(menu: &AppMenu, menu_idx: usize, active: usize, dir: i32) -> usize {
+ let Some(root) = menu.menus.get(menu_idx) else {
+ return usize::MAX;
+ };
+ let (items, _) = dropdown_items(root);
+ step_active(&items, active, dir)
+}
+
+/// El `command` de la fila `active` del menú `menu_idx` (para ejecutar con
+/// Enter). `None` si el índice no es una fila-acción.
+pub fn menubar_command_at(menu: &AppMenu, menu_idx: usize, active: usize) -> Option<String> {
+ let root = menu.menus.get(menu_idx)?;
+ let (_, commands) = dropdown_items(root);
+ commands.get(active).cloned().flatten()
+}
+
+fn title_style() -> Style {
+ Style {
+ size: Size {
+ width: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
+ height: length(24.0_f32),
+ },
+ flex_shrink: 0.0,
+ padding: Rect {
+ left: length(10.0_f32),
+ right: length(10.0_f32),
+ top: length(0.0_f32),
+ bottom: length(0.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ ..Default::default()
+ }
+}
+
+/// Ancho aproximado de un título — mismo criterio que `launcher-llimphi`
+/// para anclar el dropdown sin medir la fuente.
+fn approx_title_width(label: &str) -> f32 {
+ label.chars().count() as f32 * 8.0 + 22.0
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn overlay_none_si_no_hay_abierto() {
+ let menu = AppMenu::standard();
+ let spec = MenuBarSpec {
+ menu: &menu,
+ open: None,
+ theme: &Theme::dark(),
+ viewport: (800.0, 600.0),
+ height: DEFAULT_HEIGHT,
+ on_open: Arc::new(|_| 0u8),
+ on_command: Arc::new(|_| 1u8),
+ };
+ assert!(menubar_overlay(&spec).is_none());
+ }
+
+ #[test]
+ fn overlay_some_si_hay_abierto() {
+ let menu = AppMenu::standard();
+ let spec = MenuBarSpec {
+ menu: &menu,
+ open: Some(0),
+ theme: &Theme::dark(),
+ viewport: (800.0, 600.0),
+ height: DEFAULT_HEIGHT,
+ on_open: Arc::new(|_| 0u8),
+ on_command: Arc::new(|_| 1u8),
+ };
+ assert!(menubar_overlay(&spec).is_some());
+ }
+}
diff --git a/02_ruway/mirada/mirada-app-llimphi/Cargo.toml b/02_ruway/mirada/mirada-app-llimphi/Cargo.toml
new file mode 100644
index 0000000..97eef0c
--- /dev/null
+++ b/02_ruway/mirada/mirada-app-llimphi/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "mirada-app-llimphi"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "mirada (Llimphi) — el Cerebro del compositor: ventana Llimphi que tesela el escritorio sobre mirada-brain y manda la geometría al Cuerpo (smithay) por mirada-link. Reemplazo del `mirada-app` GPUI; sin Cuerpo arranca en simulación."
+
+[[bin]]
+name = "mirada-llimphi"
+path = "src/main.rs"
+
+[dependencies]
+mirada-brain = { path = "../mirada-brain" }
+mirada-link = { path = "../mirada-link" }
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-widget-menubar = { workspace = true }
+llimphi-widget-context-menu = { workspace = true }
+llimphi-motion = { workspace = true }
+app-bus = { workspace = true }
+rimay-localize = { path = "../../../shared/rimay-localize" }
+wawa-config = { path = "../../../shared/wawa-config" }
diff --git a/02_ruway/mirada/mirada-app-llimphi/LEEME.md b/02_ruway/mirada/mirada-app-llimphi/LEEME.md
new file mode 100644
index 0000000..5373fe9
--- /dev/null
+++ b/02_ruway/mirada/mirada-app-llimphi/LEEME.md
@@ -0,0 +1,9 @@
+# mirada-app-llimphi
+
+> Apps shell del compositor de [mirada](../README.md).
+
+Conjunto de mini-apps que viven adentro del compositor (taskbar, notification center, screenshot tool). Llimphi nativo sobre [`mirada-protocol`](../mirada-protocol/README.md).
+
+## Deps
+
+- [`mirada-protocol`](../mirada-protocol/README.md), [`llimphi-ui`](../../llimphi/)
diff --git a/02_ruway/mirada/mirada-app-llimphi/README.md b/02_ruway/mirada/mirada-app-llimphi/README.md
new file mode 100644
index 0000000..aa02c47
--- /dev/null
+++ b/02_ruway/mirada/mirada-app-llimphi/README.md
@@ -0,0 +1,9 @@
+# mirada-app-llimphi
+
+> Compositor shell apps of [mirada](../README.md).
+
+Set of mini-apps that live inside the compositor (taskbar, notification center, screenshot tool). Llimphi native over [`mirada-protocol`](../mirada-protocol/README.md).
+
+## Deps
+
+- [`mirada-protocol`](../mirada-protocol/README.md), [`llimphi-ui`](../../llimphi/)
diff --git a/02_ruway/mirada/mirada-app-llimphi/src/main.rs b/02_ruway/mirada/mirada-app-llimphi/src/main.rs
new file mode 100644
index 0000000..c5dbf9c
--- /dev/null
+++ b/02_ruway/mirada/mirada-app-llimphi/src/main.rs
@@ -0,0 +1,1138 @@
+//! `mirada-llimphi` — la ventana del Cerebro del compositor.
+//!
+//! Es el "Cerebro" de la arquitectura carmen hecho app Llimphi: envuelve
+//! [`mirada_brain::Desktop`] (toda la lógica de teselado y foco) y lo
+//! pinta. La cadena completa:
+//!
+//! ```text
+//! mirada-layout ─► mirada-protocol ─► mirada-brain ─► [esta ventana]
+//! │
+//! mirada-link ─► mirada-compositor (Cuerpo)
+//! ```
+//!
+//! Con un Cuerpo conectado (variable `MIRADA_SOCKET`) sondea sus
+//! [`BodyEvent`]s y le devuelve [`BrainCommand`]s por el socket. Sin
+//! Cuerpo arranca en **simulación**: las ventanas son sintéticas y el
+//! teclado de esta ventana maneja el escritorio — útil para ver el
+//! motor de teselado sin hardware.
+//!
+//! Teclas (simulación):
+//!
+//! ```text
+//! n / Shift+n abre ventana / monitor tab / espacio cicla layout
+//! w cierra la enfocada t m g c r d s layout directo
+//! f / Shift+f flota / pantalla completa h / l área maestra −/+
+//! j / k foco siguiente/anterior , / . nmaster −/+
+//! Shift+j / k mueve la enfocada 1..9 ir a escritorio
+//! Enter promueve a maestra Ctrl+1..9 enviar a escritorio
+//! o siguiente monitor ` / Shift+` scratchpad ver/guardar
+//! ```
+//!
+//! Los pips de escritorio y las ventanas del lienzo son **clicables**, y
+//! `mirada-ctl` controla el escritorio desde la terminal — ambos pasan
+//! por el mismo `Desktop::apply`.
+
+#![forbid(unsafe_code)]
+
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
+
+use app_bus::{AppMenu, Menu, MenuItem};
+use llimphi_theme::Theme;
+use llimphi_widget_context_menu::{
+ context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
+};
+use llimphi_widget_menubar::{
+ menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec,
+ DEFAULT_HEIGHT as MENU_H,
+};
+use llimphi_motion::{animate, motion, Tween};
+use llimphi_ui::llimphi_layout::taffy::{
+ prelude::{auto, length, percent, AlignItems, Dimension, FlexDirection, JustifyContent, Position, Size, Style},
+ Rect,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
+use mirada_brain::{
+ BodyEvent, BrainCommand, CtlConn, CtlReply, CtlRequest, CtlServer, Desktop, DesktopAction,
+ Keymap, KeymapWatch, LayoutMode, Rules, WindowId, WindowPlacement,
+};
+use mirada_link::BrainLink;
+
+/// Pantalla virtual del modo simulación — coincide con el lienzo.
+const SCREEN_W: i32 = 1280;
+const SCREEN_H: i32 = 720;
+/// Período del sondeo del Cuerpo, en ms (~60 Hz).
+const POLL_MS: u64 = 16;
+
+/// Nombres de app ficticios para las ventanas de simulación.
+const APPS: &[&str] = &[
+ "shuma", "pluma_app", "revista", "cosmobiología", "matilda", "pluma_notebook_app", "barra",
+];
+
+struct Model {
+ theme: Theme,
+ desktop: Desktop,
+ /// Geometría vigente — lo que se pinta. Es la última `Place` emitida.
+ placements: Vec<WindowPlacement>,
+ /// Contador de ids para las ventanas sintéticas.
+ next_id: WindowId,
+ /// Cable al Cuerpo; `None` en simulación.
+ link: Option<BrainLink>,
+ /// Última acción, para la barra de estado.
+ note: String,
+ /// Ruta del keymap del usuario, para recargarlo en caliente.
+ keymap_path: Option<PathBuf>,
+ /// Vigía del keymap; `None` en simulación o si no hay archivo.
+ keymap_watch: Option<KeymapWatch>,
+ /// Socket del API de control externo (`mirada-ctl`).
+ ctl: Option<CtlServer>,
+ /// Barra de menú principal: índice del menú raíz abierto (`None`
+ /// cerrado).
+ menu_open: Option<usize>,
+ /// Fila resaltada dentro del dropdown abierto (`usize::MAX` = ninguna).
+ menu_active: usize,
+ /// Animación de aparición/swap del dropdown del menú principal.
+ menu_anim: Tween<f32>,
+ /// Menú contextual sobre la ventana enfocada: ancla `(x, y)` en
+ /// coordenadas de ventana. `None` cerrado. No hay edición de texto,
+ /// así que el contextual sólo ofrece acciones de gestión de ventana.
+ context_menu: Option<(f32, f32)>,
+}
+
+#[derive(Clone)]
+enum Msg {
+ /// Tick periódico: drena el Cuerpo, vigila el keymap, atiende ctl.
+ Tick,
+ /// Tecla recibida desde la ventana de simulación.
+ Key(KeyEvent),
+ /// Click en un pip de escritorio.
+ SwitchWorkspace(usize),
+ /// Click en una ventana del lienzo.
+ FocusWindow(WindowId),
+ /// Barra de menú principal: abrir/cerrar un menú raíz (`None` cerrar).
+ MenuOpen(Option<usize>),
+ /// Comando elegido en el menú principal — se traduce a acciones reales.
+ MenuCommand(String),
+ /// Navegación por teclado dentro del dropdown: +1 baja, -1 sube.
+ MenuNav(i32),
+ /// Enter sobre la fila resaltada del dropdown.
+ MenuActivate,
+ /// Tick de la animación del menú (sólo re-render).
+ MenuTick,
+ /// Cierra cualquier menú abierto (click-fuera / Esc).
+ CloseMenus,
+ /// Right-click en la raíz → abre el menú contextual anclado en
+ /// `(x, y)` sobre la ventana enfocada. Sin ventana enfocada es no-op.
+ ContextMenuOpen(f32, f32),
+ /// Ejecuta una acción de escritorio (usado por el menú contextual y
+ /// el principal sobre la ventana enfocada).
+ Act(DesktopAction),
+}
+
+struct Mirada;
+
+impl App for Mirada {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "brahman · mirada"
+ }
+
+ fn initial_size() -> (u32, u32) {
+ (SCREEN_W as u32, (SCREEN_H + 70) as u32)
+ }
+
+ fn init(handle: &Handle<Msg>) -> Model {
+ // Keymap del usuario (~/.config/mirada/keymap.ron): define los
+ // atajos que el Cuerpo intercepta y nos devuelve como `Keybind`.
+ let keymap_path = Keymap::default_path();
+ let keymap = match &keymap_path {
+ Some(p) => Keymap::load_or_init(p),
+ None => Keymap::default(),
+ };
+ let link = connect_body();
+ // Vigilar el keymap sólo tiene sentido con un Cuerpo conectado;
+ // en simulación, mirada usa las teclas de su propia ventana.
+ let keymap_watch = if link.is_some() {
+ keymap_path.as_deref().and_then(|p| Keymap::watch(p).ok())
+ } else {
+ None
+ };
+ // API de control: mirada siempre posee el Desktop, así que
+ // siempre abre el socket de `mirada-ctl`.
+ let ctl = match CtlServer::bind(&mirada_brain::ctl::default_socket_path()) {
+ Ok(s) => Some(s),
+ Err(e) => {
+ eprintln!("mirada · sin API de control: {e}");
+ None
+ }
+ };
+
+ let mut desktop = Desktop::with_keymap(keymap);
+ desktop.set_rules(load_user_rules());
+
+ let mut model = Model {
+ theme: Theme::dark(),
+ desktop,
+ placements: Vec::new(),
+ next_id: 1,
+ link,
+ note: rimay_localize::t("success"),
+ keymap_path,
+ keymap_watch,
+ ctl,
+ menu_open: None,
+ menu_active: usize::MAX,
+ menu_anim: Tween::idle(1.0),
+ context_menu: None,
+ };
+ if let Some(link) = model.link.as_mut() {
+ let _ = link.send(&model.desktop.grab_keys());
+ model.note = rimay_localize::t("mirada-status-body-connected");
+ } else {
+ // Simulación: una pantalla virtual y tres ventanas de muestra.
+ feed(&mut model, BodyEvent::OutputAdded {
+ id: 0,
+ width: SCREEN_W,
+ height: SCREEN_H,
+ });
+ for _ in 0..3 {
+ open_window(&mut model);
+ }
+ model.note = rimay_localize::t("mirada-status-simulation");
+ }
+
+ // El sondeo corre siempre: drena el Cuerpo (si lo hay), vigila el
+ // keymap y atiende `mirada-ctl`. Llega como `Msg::Tick` al update.
+ handle.spawn_periodic(Duration::from_millis(POLL_MS), || Msg::Tick);
+
+ model
+ }
+
+ fn on_key(model: &Model, e: &KeyEvent) -> Option<Msg> {
+ if e.state != KeyState::Pressed {
+ return None;
+ }
+ // Con el menú principal abierto las flechas navegan: ←/→ cambian de
+ // menú raíz (con wrap), ↑/↓ mueven la fila activa, Enter ejecuta y
+ // Esc cierra. Consume la tecla.
+ if let Some(mi) = model.menu_open {
+ let n = app_menu(model).menus.len().max(1);
+ return match &e.key {
+ Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
+ Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))),
+ Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))),
+ Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)),
+ Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)),
+ Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate),
+ _ => None,
+ };
+ }
+ Some(Msg::Key(e.clone()))
+ }
+
+ fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ let mut m = model;
+ match msg {
+ Msg::Tick => tick(&mut m),
+ Msg::Key(ev) => handle_key(&mut m, &ev),
+ Msg::SwitchWorkspace(i) => act(&mut m, DesktopAction::SwitchWorkspace(i)),
+ Msg::FocusWindow(id) => act(&mut m, DesktopAction::FocusWindow(id)),
+ Msg::MenuOpen(which) => {
+ m.menu_open = which;
+ // Abrir un menú raíz cierra cualquier contextual.
+ m.context_menu = None;
+ m.menu_active = usize::MAX;
+ // Animación de aparición/swap del dropdown.
+ if which.is_some() {
+ m.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
+ animate(handle, motion::FAST, || Msg::MenuTick);
+ }
+ }
+ Msg::MenuNav(dir) => {
+ if let Some(mi) = m.menu_open {
+ let menu = app_menu(&m);
+ m.menu_active = menubar_nav(&menu, mi, m.menu_active, dir);
+ }
+ }
+ Msg::MenuActivate => {
+ if let Some(mi) = m.menu_open {
+ let menu = app_menu(&m);
+ if let Some(cmd) = menubar_command_at(&menu, mi, m.menu_active) {
+ m.menu_open = None;
+ handle_menu_command(&mut m, &cmd);
+ }
+ }
+ }
+ Msg::MenuTick => {}
+ Msg::CloseMenus => {
+ m.menu_open = None;
+ m.context_menu = None;
+ m.menu_active = usize::MAX;
+ }
+ Msg::ContextMenuOpen(x, y) => {
+ // Sólo tiene sentido con una ventana enfocada.
+ if m.desktop.focused_window().is_some() {
+ m.menu_open = None;
+ m.context_menu = Some((x, y));
+ }
+ }
+ Msg::MenuCommand(cmd) => {
+ m.menu_open = None;
+ handle_menu_command(&mut m, &cmd);
+ }
+ Msg::Act(action) => {
+ m.menu_open = None;
+ m.context_menu = None;
+ act(&mut m, action);
+ }
+ }
+ m
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let theme = &model.theme;
+ // Barra de menú principal — primer hijo del column raíz.
+ let menu = app_menu(model);
+ let menubar = menubar_view(&menubar_spec(&menu, model, theme));
+ // Colores cromáticos heredados del original (HSL → RGB hardcoded).
+ let win_bg = Color::from_rgba8(28, 32, 41, 255);
+ let bar_bg = Color::from_rgba8(19, 22, 30, 255);
+ let canvas_bg = Color::from_rgba8(10, 13, 19, 255);
+ let on_accent = Color::from_rgba8(12, 16, 24, 255);
+
+ let active = model.desktop.active_index();
+ let mode = model.desktop.active_workspace().params().mode;
+ let loads = model.desktop.workspace_loads();
+ let focused = model.desktop.focused_window();
+
+ // --- Barra superior: identidad + escritorios + modo ----------
+ let bar = top_bar(model, theme, mode, &loads, active, focused, on_accent, bar_bg);
+
+ // --- Lienzo: el escritorio teselado, a escala ----------------
+ let canvas = canvas_view(model, theme, on_accent, win_bg, canvas_bg);
+
+ // --- Pie de estado ------------------------------------------
+ let status = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(26.0_f32),
+ },
+ padding: Rect {
+ left: length(14.0_f32),
+ right: length(14.0_f32),
+ top: length(0.0_f32),
+ bottom: length(0.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ ..Default::default()
+ })
+ .fill(bar_bg)
+ .text_aligned(model.note.clone(), 11.0, theme.fg_placeholder, Alignment::Start);
+
+ // --- Composición ---------------------------------------------
+ let canvas_wrap = View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: percent(1.0_f32),
+ height: Dimension::auto(),
+ },
+ flex_grow: 1.0,
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![canvas]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ // El origen de la raíz es (0,0) ⇒ coords locales == coords de
+ // ventana. Right-click abre el contextual sobre la ventana
+ // enfocada.
+ .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+ .children(vec![menubar, bar, canvas_wrap, status])
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ // El menú contextual de la ventana enfocada tiene prioridad.
+ if let Some((x, y)) = model.context_menu {
+ let focused = model.desktop.focused_window();
+ let label = focused
+ .and_then(|id| model.desktop.window_info(id))
+ .map(|i| i.title.clone())
+ .unwrap_or_else(|| rimay_localize::t("mirada-win-label-fallback"));
+ // Acciones reales del Desktop sobre la enfocada. Sin edición
+ // de texto: el contextual es de gestión de ventana.
+ let t = rimay_localize::t;
+ let actions: [(String, DesktopAction); 6] = [
+ (t("mirada-win-promote"), DesktopAction::PromoteToMaster),
+ (t("mirada-win-float"), DesktopAction::ToggleFloat),
+ (t("mirada-win-fullscreen"), DesktopAction::ToggleFullscreen),
+ (t("mirada-win-scratchpad"), DesktopAction::SendToScratchpad),
+ (t("mirada-output-next"), DesktopAction::FocusOutputNext),
+ (t("close"), DesktopAction::CloseFocused),
+ ];
+ // "Cerrar" (último) se marca como destructivo.
+ let last = actions.len() - 1;
+ let items: Vec<ContextMenuItem> = actions
+ .iter()
+ .enumerate()
+ .map(|(i, (l, _))| {
+ let it = ContextMenuItem::action(l.clone());
+ if i == last {
+ it.destructive()
+ } else {
+ it
+ }
+ })
+ .collect();
+ let acts: Vec<DesktopAction> = actions.iter().map(|(_, a)| a.clone()).collect();
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
+ Arc::new(move |i: usize| Msg::Act(acts[i].clone()));
+ return Some(context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport: viewport_of(model),
+ header: Some(label),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ }));
+ }
+ // Si no, el dropdown del menú principal.
+ let menu = app_menu(model);
+ menubar_overlay_animated(
+ &menubar_spec(&menu, model, &model.theme),
+ model.menu_active,
+ model.menu_anim.value(),
+ )
+ }
+}
+
+// ─── Lógica fuera del trait App ─────────────────────────────────────
+
+/// Bucle de poll consolidado: drena Cuerpo, recarga keymap, sirve ctl.
+fn tick(m: &mut Model) {
+ let events: Vec<BodyEvent> = match m.link.as_ref() {
+ Some(link) => link.drain(),
+ None => Vec::new(),
+ };
+ let keymap_changed = m.keymap_watch.as_ref().is_some_and(|w| w.changed());
+ if keymap_changed {
+ reload_keymap(m);
+ }
+ let _ctl_served = poll_ctl(m);
+ for ev in events {
+ feed(m, ev);
+ }
+}
+
+fn open_window(m: &mut Model) {
+ let id = m.next_id;
+ m.next_id += 1;
+ let app = APPS[(id as usize) % APPS.len()];
+ feed(m, BodyEvent::WindowOpened {
+ id,
+ app_id: format!("org.brahman.{app}"),
+ title: format!("{app} · ventana {id}"),
+ });
+ m.note = format!("abierta ventana {id}");
+}
+
+fn feed(m: &mut Model, event: BodyEvent) {
+ let cmds = m.desktop.on_event(event);
+ dispatch(m, cmds);
+}
+
+fn act(m: &mut Model, action: DesktopAction) {
+ let cmds = m.desktop.apply(action);
+ dispatch(m, cmds);
+}
+
+fn reload_keymap(m: &mut Model) {
+ let Some(path) = m.keymap_path.clone() else {
+ return;
+ };
+ match Keymap::load(&path) {
+ Ok(km) => {
+ let cmd = m.desktop.set_keymap(km);
+ dispatch(m, vec![cmd]);
+ m.note = rimay_localize::t("mirada-status-keymap-reloaded");
+ }
+ Err(e) => m.note = format!("{}: {e}", rimay_localize::t("mirada-status-keymap-invalid")),
+ }
+}
+
+fn poll_ctl(m: &mut Model) -> bool {
+ let conns: Vec<CtlConn> = match &m.ctl {
+ Some(ctl) => std::iter::from_fn(|| ctl.poll()).collect(),
+ None => return false,
+ };
+ let mut served = false;
+ for mut conn in conns {
+ let reply = match conn.read_request() {
+ Ok(Some(req)) => {
+ served = true;
+ serve_ctl(m, req)
+ }
+ Ok(None) => continue,
+ Err(e) => CtlReply::Error(format!("{e}")),
+ };
+ let _ = conn.reply(&reply);
+ }
+ served
+}
+
+fn serve_ctl(m: &mut Model, req: CtlRequest) -> CtlReply {
+ match req {
+ CtlRequest::Do(action) => {
+ act(m, action);
+ CtlReply::Ok
+ }
+ CtlRequest::ListWindows => CtlReply::Windows(m.desktop.window_lines()),
+ // Las zonas de arrastre son del compositor; esta app de Cerebro no las
+ // gestiona.
+ CtlRequest::CycleZones => CtlReply::Ok,
+ }
+}
+
+fn dispatch(m: &mut Model, cmds: Vec<BrainCommand>) {
+ for cmd in &cmds {
+ if let BrainCommand::Place(p) = cmd {
+ m.placements = p.clone();
+ }
+ }
+ match m.link.as_mut() {
+ Some(link) => {
+ for cmd in &cmds {
+ let _ = link.send(cmd);
+ }
+ }
+ None => {
+ for cmd in cmds {
+ match cmd {
+ BrainCommand::Close(id) | BrainCommand::Kill(id) => {
+ feed(m, BodyEvent::WindowClosed { id });
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+}
+
+/// Mapea una tecla a una acción de escritorio. La firma cambia respecto
+/// del original GPUI: ahora muta `Model` directamente porque el bucle
+/// Llimphi es Elm puro.
+fn handle_key(m: &mut Model, ev: &KeyEvent) {
+ let shift = ev.modifiers.shift;
+ let ctrl = ev.modifiers.ctrl;
+ let connected = m.link.is_some();
+
+ let key_str: Option<String> = match &ev.key {
+ Key::Named(NamedKey::Tab) => Some("tab".into()),
+ Key::Named(NamedKey::Space) => Some("space".into()),
+ Key::Named(NamedKey::Enter) => Some("enter".into()),
+ Key::Character(s) => Some(s.to_lowercase()),
+ _ => None,
+ };
+ let Some(k) = key_str else { return };
+
+ match k.as_str() {
+ "n" if shift && !connected => {
+ let id = m.desktop.outputs().len() as u32;
+ feed(m, BodyEvent::OutputAdded {
+ id,
+ width: SCREEN_W,
+ height: SCREEN_H,
+ });
+ }
+ "n" if !connected => open_window(m),
+ "w" => act(m, DesktopAction::CloseFocused),
+ "f" if shift => act(m, DesktopAction::ToggleFullscreen),
+ "f" => act(m, DesktopAction::ToggleFloat),
+ "j" if shift => act(m, DesktopAction::MoveForward),
+ "k" if shift => act(m, DesktopAction::MoveBackward),
+ "j" => act(m, DesktopAction::FocusNext),
+ "k" => act(m, DesktopAction::FocusPrev),
+ "tab" | "space" => act(m, DesktopAction::CycleLayout),
+ "t" => act(m, DesktopAction::SetLayout(LayoutMode::MasterStack)),
+ "m" => act(m, DesktopAction::SetLayout(LayoutMode::Monocle)),
+ "g" => act(m, DesktopAction::SetLayout(LayoutMode::Grid)),
+ "c" => act(m, DesktopAction::SetLayout(LayoutMode::Columns)),
+ "r" => act(m, DesktopAction::SetLayout(LayoutMode::Rows)),
+ "d" => act(m, DesktopAction::SetLayout(LayoutMode::CenteredMaster)),
+ "s" => act(m, DesktopAction::SetLayout(LayoutMode::Spiral)),
+ "h" => act(m, DesktopAction::ShrinkMaster),
+ "l" => act(m, DesktopAction::GrowMaster),
+ "o" => act(m, DesktopAction::FocusOutputNext),
+ "`" if shift => act(m, DesktopAction::SendToScratchpad),
+ "`" => act(m, DesktopAction::ToggleScratchpad),
+ "enter" => act(m, DesktopAction::PromoteToMaster),
+ "," => act(m, DesktopAction::IncMaster),
+ "." => act(m, DesktopAction::DecMaster),
+ d if d.len() == 1 && d.as_bytes()[0].is_ascii_digit() && d != "0" => {
+ let n = (d.as_bytes()[0] - b'1') as usize;
+ if ctrl {
+ act(m, DesktopAction::SendToWorkspace(n));
+ } else {
+ act(m, DesktopAction::SwitchWorkspace(n));
+ }
+ }
+ _ => {}
+ }
+}
+
+fn connect_body() -> Option<BrainLink> {
+ let path = std::env::var("MIRADA_SOCKET").ok()?;
+ BrainLink::connect(&path).ok()
+}
+
+fn load_user_rules() -> Rules {
+ match Rules::default_path() {
+ Some(p) => Rules::load_or_default(&p),
+ None => Rules::default(),
+ }
+}
+
+fn mode_name(m: LayoutMode) -> String {
+ match m {
+ LayoutMode::MasterStack => rimay_localize::t("mirada-layout-master-stack"),
+ LayoutMode::Monocle => rimay_localize::t("mirada-layout-monocle"),
+ LayoutMode::Grid => rimay_localize::t("mirada-layout-grid"),
+ LayoutMode::Columns => rimay_localize::t("mirada-layout-columns"),
+ LayoutMode::Rows => rimay_localize::t("mirada-layout-rows"),
+ LayoutMode::CenteredMaster => rimay_localize::t("mirada-layout-centered"),
+ LayoutMode::Spiral => rimay_localize::t("mirada-layout-spiral"),
+ }
+}
+
+// ─── Menú principal y contextual ────────────────────────────────────
+
+/// Viewport para clampear overlays. El Model no trackea el tamaño de
+/// ventana, así que usamos `initial_size()`.
+fn viewport_of(_model: &Model) -> (f32, f32) {
+ let (w, h) = Mirada::initial_size();
+ (w as f32, h as f32)
+}
+
+/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &Model, theme: &'a Theme) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+/// El menú principal de mirada. Archivo / Ver / Ayuda — sólo comandos que
+/// mapean a acciones reales del `Desktop`. Sin "Editar": no hay campos de
+/// texto editables. Los items que actúan sobre la enfocada se inhabilitan
+/// cuando no hay ventana enfocada. Abrir ventana/monitor sólo tiene
+/// sentido en simulación (sin Cuerpo conectado).
+fn app_menu(model: &Model) -> AppMenu {
+ let has_focus = model.desktop.focused_window().is_some();
+ let sim = model.link.is_none();
+ let mode = model.desktop.active_workspace().params().mode;
+
+ // Etiquetas de UI localizadas: IDs genéricos del catálogo o prefijados
+ // con `mirada-`. Los segundos argumentos de MenuItem (ids de comando)
+ // son estables y NO se localizan.
+ let t = rimay_localize::t;
+
+ let mut abrir = MenuItem::new(t("mirada-menu-open-window"), "file.new_window").shortcut("n");
+ let mut abrir_mon = MenuItem::new(t("mirada-menu-open-output"), "file.new_output").shortcut("Shift+n");
+ if !sim {
+ // Con Cuerpo conectado, las ventanas las crea el compositor real.
+ abrir = abrir.disabled();
+ abrir_mon = abrir_mon.disabled();
+ }
+ let mut cerrar = MenuItem::new(t("mirada-menu-close-focused"), "win.close").shortcut("w").separated();
+ if !has_focus {
+ cerrar = cerrar.disabled();
+ }
+
+ // Submenú de layouts: el modo vigente queda en gris (ya aplicado).
+ let layout_item = |label: String, cmd: &str, m: LayoutMode| {
+ let it = MenuItem::new(label, cmd);
+ if mode == m {
+ it.disabled()
+ } else {
+ it
+ }
+ };
+
+ let mut promover = MenuItem::new(t("mirada-win-promote"), "win.promote").shortcut("Enter");
+ let mut flotar = MenuItem::new(t("mirada-win-float"), "win.float").shortcut("f");
+ let mut fullscreen = MenuItem::new(t("mirada-win-fullscreen"), "win.fullscreen").shortcut("Shift+f");
+ let mut scratch = MenuItem::new(t("mirada-win-scratchpad"), "win.scratchpad").shortcut("Shift+`");
+ if !has_focus {
+ promover = promover.disabled();
+ flotar = flotar.disabled();
+ fullscreen = fullscreen.disabled();
+ scratch = scratch.disabled();
+ }
+
+ // Menú de idioma: autónimos sin traducir (convención del SO). El item
+ // activo lleva ✔. El comando `lang.<code>` lo resuelve `handle_menu_command`.
+ let cur = rimay_localize::current_locale();
+ let lang_item = |label: &str, code: &str| {
+ let mut it = MenuItem::new(label, format!("lang.{code}"));
+ if cur == code {
+ it = it.icon("\u{2714}");
+ }
+ it
+ };
+
+ AppMenu::new()
+ .menu(
+ Menu::new(t("file"))
+ .item(abrir)
+ .item(abrir_mon)
+ .item(cerrar)
+ .item(MenuItem::new(t("exit"), "file.quit").shortcut("Ctrl+Q").separated()),
+ )
+ .menu(
+ Menu::new(t("view"))
+ .item(MenuItem::new(t("mirada-layout-cycle"), "view.cycle").shortcut("Tab"))
+ .item(layout_item(t("mirada-layout-master-stack"), "layout.master", LayoutMode::MasterStack).separated())
+ .item(layout_item(t("mirada-layout-monocle"), "layout.monocle", LayoutMode::Monocle))
+ .item(layout_item(t("mirada-layout-grid"), "layout.grid", LayoutMode::Grid))
+ .item(layout_item(t("mirada-layout-columns"), "layout.columns", LayoutMode::Columns))
+ .item(layout_item(t("mirada-layout-rows"), "layout.rows", LayoutMode::Rows))
+ .item(layout_item(t("mirada-layout-centered"), "layout.centered", LayoutMode::CenteredMaster))
+ .item(layout_item(t("mirada-layout-spiral"), "layout.spiral", LayoutMode::Spiral))
+ .item(MenuItem::new(t("mirada-layout-shrink"), "view.shrink").shortcut("h").separated())
+ .item(MenuItem::new(t("mirada-layout-grow"), "view.grow").shortcut("l"))
+ .item(MenuItem::new(t("mirada-output-next"), "view.output_next").shortcut("o").separated()),
+ )
+ .menu(
+ Menu::new(t("mirada-menu-window"))
+ .item(promover)
+ .item(flotar)
+ .item(fullscreen)
+ .item(scratch),
+ )
+ .menu(
+ Menu::new(t("help"))
+ .item(MenuItem::new(t("about"), "help.about")),
+ )
+ .menu(
+ Menu::new(t("language"))
+ .item(lang_item("Español", "es-PE"))
+ .item(lang_item("English", "en-US"))
+ .item(lang_item("Runasimi", "qu-PE")),
+ )
+}
+
+/// Traduce un command id del menú principal a la acción real del Desktop.
+fn handle_menu_command(m: &mut Model, cmd: &str) {
+ // Cambio de idioma: aplica el locale en caliente y lo persiste en wawa-config.
+ if let Some(code) = cmd.strip_prefix("lang.") {
+ let _ = rimay_localize::set_locale(code);
+ let mut cfg = wawa_config::WawaConfig::load();
+ cfg.lang = code.to_string();
+ let _ = cfg.save();
+ return;
+ }
+ match cmd {
+ "file.new_window" if m.link.is_none() => open_window(m),
+ "file.new_output" if m.link.is_none() => {
+ let id = m.desktop.outputs().len() as u32;
+ feed(m, BodyEvent::OutputAdded {
+ id,
+ width: SCREEN_W,
+ height: SCREEN_H,
+ });
+ }
+ "win.close" => act(m, DesktopAction::CloseFocused),
+ "file.quit" => std::process::exit(0),
+ "view.cycle" => act(m, DesktopAction::CycleLayout),
+ "layout.master" => act(m, DesktopAction::SetLayout(LayoutMode::MasterStack)),
+ "layout.monocle" => act(m, DesktopAction::SetLayout(LayoutMode::Monocle)),
+ "layout.grid" => act(m, DesktopAction::SetLayout(LayoutMode::Grid)),
+ "layout.columns" => act(m, DesktopAction::SetLayout(LayoutMode::Columns)),
+ "layout.rows" => act(m, DesktopAction::SetLayout(LayoutMode::Rows)),
+ "layout.centered" => act(m, DesktopAction::SetLayout(LayoutMode::CenteredMaster)),
+ "layout.spiral" => act(m, DesktopAction::SetLayout(LayoutMode::Spiral)),
+ "view.shrink" => act(m, DesktopAction::ShrinkMaster),
+ "view.grow" => act(m, DesktopAction::GrowMaster),
+ "view.output_next" => act(m, DesktopAction::FocusOutputNext),
+ "win.promote" => act(m, DesktopAction::PromoteToMaster),
+ "win.float" => act(m, DesktopAction::ToggleFloat),
+ "win.fullscreen" => act(m, DesktopAction::ToggleFullscreen),
+ "win.scratchpad" => act(m, DesktopAction::SendToScratchpad),
+ // "help.about" y desconocidos: no-op (sin diálogo todavía).
+ _ => {}
+ }
+}
+
+// ─── Subviews ───────────────────────────────────────────────────────
+
+#[allow(clippy::too_many_arguments)]
+fn top_bar(
+ model: &Model,
+ theme: &Theme,
+ mode: LayoutMode,
+ loads: &[usize],
+ active: usize,
+ focused: Option<WindowId>,
+ on_accent: Color,
+ bar_bg: Color,
+) -> View<Msg> {
+ let mut pips: Vec<View<Msg>> = Vec::with_capacity(loads.len());
+ for (i, &load) in loads.iter().enumerate() {
+ let is_active = i == active;
+ let fg = if is_active {
+ on_accent
+ } else if load > 0 {
+ theme.fg_text
+ } else {
+ theme.fg_placeholder
+ };
+ let bg = if is_active {
+ theme.accent
+ } else if load > 0 {
+ theme.bg_row_hover
+ } else {
+ bar_bg
+ };
+ pips.push(
+ View::new(Style {
+ size: Size {
+ width: length(24.0_f32),
+ height: length(22.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ ..Default::default()
+ })
+ .fill(bg)
+ .radius(4.0)
+ .text_aligned(format!("{}", i + 1), 12.0, fg, Alignment::Start)
+ .on_click(Msg::SwitchWorkspace(i)),
+ );
+ }
+
+ let focus_label = match focused.and_then(|id| model.desktop.window_info(id)) {
+ Some(info) => info.title.clone(),
+ None => "—".to_string(),
+ };
+
+ let pips_row = View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: Dimension::auto(),
+ height: length(22.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ gap: Size {
+ width: length(4.0_f32),
+ height: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(pips);
+
+ let label_node = |text: String, color: Color, size: f32, width: f32| {
+ View::new(Style {
+ size: Size {
+ width: length(width),
+ height: length(22.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ ..Default::default()
+ })
+ .text_aligned(text, size, color, Alignment::Start)
+ };
+
+ let mirada_tag = label_node("mirada".into(), theme.accent, 13.0, 70.0);
+ let sep_a = label_node("·".into(), theme.fg_placeholder, 12.0, 12.0);
+ let sep_b = label_node("·".into(), theme.fg_placeholder, 12.0, 12.0);
+ let layout_label = label_node(
+ format!("{}: {}", rimay_localize::t("mirada-label-layout"), mode_name(mode)),
+ theme.fg_muted,
+ 12.0,
+ 180.0,
+ );
+ let spacer = View::new(Style {
+ size: Size {
+ width: Dimension::auto(),
+ height: length(22.0_f32),
+ },
+ flex_grow: 1.0,
+ ..Default::default()
+ });
+ let focus_label_node = label_node(
+ format!("{}: {focus_label}", rimay_localize::t("mirada-label-focus")),
+ theme.fg_muted,
+ 12.0,
+ 320.0,
+ );
+
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(44.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ padding: Rect {
+ left: length(14.0_f32),
+ right: length(14.0_f32),
+ top: length(0.0_f32),
+ bottom: length(0.0_f32),
+ },
+ gap: Size {
+ width: length(12.0_f32),
+ height: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(bar_bg)
+ .children(vec![
+ mirada_tag,
+ sep_a,
+ pips_row,
+ sep_b,
+ layout_label,
+ spacer,
+ focus_label_node,
+ ])
+}
+
+fn canvas_view(
+ model: &Model,
+ theme: &Theme,
+ on_accent: Color,
+ win_bg: Color,
+ canvas_bg: Color,
+) -> View<Msg> {
+ let outs = model.desktop.outputs();
+ let (bb_w, bb_h) = if outs.is_empty() {
+ (SCREEN_W as f32, SCREEN_H as f32)
+ } else {
+ let w = outs.iter().map(|o| o.rect.x + o.rect.w).max().unwrap_or(SCREEN_W);
+ let h = outs.iter().map(|o| o.rect.y + o.rect.h).max().unwrap_or(SCREEN_H);
+ (w as f32, h as f32)
+ };
+ let scale = (SCREEN_W as f32 / bb_w)
+ .min(SCREEN_H as f32 / bb_h)
+ .min(1.0);
+
+ let mut children: Vec<View<Msg>> = Vec::new();
+
+ // Marcos de cada salida.
+ for (i, o) in outs.iter().enumerate() {
+ let is_focused_out = i == model.desktop.focused_output();
+ let border = if is_focused_out { theme.accent } else { theme.border };
+ let label = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(16.0_f32),
+ },
+ padding: Rect {
+ left: length(4.0_f32),
+ right: length(4.0_f32),
+ top: length(2.0_f32),
+ bottom: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(
+ format!(
+ "{} {} · {} {}",
+ rimay_localize::t("mirada-label-output"),
+ i + 1,
+ rimay_localize::t("mirada-label-workspace"),
+ o.workspace + 1,
+ ),
+ 10.0,
+ theme.fg_placeholder,
+ Alignment::Start,
+ );
+ children.push(
+ View::new(Style {
+ position: Position::Absolute,
+ inset: Rect {
+ left: length(o.rect.x as f32 * scale),
+ top: length(o.rect.y as f32 * scale),
+ right: auto(),
+ bottom: auto(),
+ },
+ size: Size {
+ width: length(o.rect.w as f32 * scale),
+ height: length(o.rect.h as f32 * scale),
+ },
+ ..Default::default()
+ })
+ // Llimphi no tiene "border 1px" como propiedad — emulamos
+ // con un fill del color del border que pinta una franja
+ // alrededor del contenido vía padding (cheap edge).
+ .fill(border)
+ .children(vec![label]),
+ );
+ }
+
+ // Mensaje vacío.
+ let visible = model.placements.iter().filter(|p| p.visible).count();
+ if visible == 0 {
+ children.push(
+ View::new(Style {
+ position: Position::Absolute,
+ inset: Rect {
+ left: length(0.0_f32),
+ top: length(0.0_f32),
+ right: length(0.0_f32),
+ bottom: length(0.0_f32),
+ },
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ ..Default::default()
+ })
+ .text_aligned(
+ rimay_localize::t("mirada-canvas-empty-hint"),
+ 13.0,
+ theme.fg_placeholder,
+ Alignment::Center,
+ ),
+ );
+ }
+
+ // Ventanas.
+ for p in model.placements.iter().filter(|p| p.visible) {
+ let info = model.desktop.window_info(p.id);
+ let title = info
+ .map(|i| i.title.clone())
+ .unwrap_or_else(|| format!("ventana {}", p.id));
+ let app_id = info.map(|i| i.app_id.clone()).unwrap_or_default();
+ let border = if p.focused { theme.accent } else { theme.border };
+ let tb_bg = if p.focused { theme.accent } else { theme.bg_row_hover };
+ let tb_fg = if p.focused { on_accent } else { theme.fg_muted };
+ let kind_label = if p.fullscreen {
+ rimay_localize::t("mirada-win-kind-fullscreen")
+ } else if p.floating {
+ rimay_localize::t("mirada-win-kind-floating")
+ } else {
+ rimay_localize::t("mirada-win-kind-surface")
+ };
+
+ let titlebar = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(22.0_f32),
+ },
+ padding: Rect {
+ left: length(8.0_f32),
+ right: length(8.0_f32),
+ top: length(0.0_f32),
+ bottom: length(0.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ ..Default::default()
+ })
+ .fill(tb_bg)
+ .text_aligned(title, 11.0, tb_fg, Alignment::Start);
+
+ let interior = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: Dimension::auto(),
+ },
+ flex_grow: 1.0,
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ gap: Size {
+ width: length(0.0_f32),
+ height: length(4.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(win_bg)
+ .children(vec![
+ View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(16.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(app_id, 11.0, theme.fg_placeholder, Alignment::Center),
+ View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(16.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(kind_label, 11.0, theme.fg_placeholder, Alignment::Center),
+ ]);
+
+ children.push(
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ position: Position::Absolute,
+ inset: Rect {
+ left: length(p.rect.x as f32 * scale),
+ top: length(p.rect.y as f32 * scale),
+ right: auto(),
+ bottom: auto(),
+ },
+ size: Size {
+ width: length(p.rect.w as f32 * scale),
+ height: length(p.rect.h as f32 * scale),
+ },
+ padding: Rect {
+ left: length(2.0_f32),
+ right: length(2.0_f32),
+ top: length(2.0_f32),
+ bottom: length(2.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(border)
+ .radius(5.0)
+ .on_click(Msg::FocusWindow(p.id))
+ .children(vec![titlebar, interior]),
+ );
+ }
+
+ View::new(Style {
+ position: Position::Relative,
+ size: Size {
+ width: length(SCREEN_W as f32),
+ height: length(SCREEN_H as f32),
+ },
+ ..Default::default()
+ })
+ .fill(canvas_bg)
+ .children(children)
+}
+
+fn main() {
+ rimay_localize::init();
+ let _ = rimay_localize::set_locale(&wawa_config::WawaConfig::load().lang);
+ llimphi_ui::run::<Mirada>();
+}
diff --git a/02_ruway/mirada/mirada-bar-core/Cargo.toml b/02_ruway/mirada/mirada-bar-core/Cargo.toml
new file mode 100644
index 0000000..6c3505c
--- /dev/null
+++ b/02_ruway/mirada/mirada-bar-core/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "mirada-bar-core"
+description = "Barra — modelo de taskbar agnóstico: Task + render-to-html + sanitizadores. Sin dependencias web/DOM."
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
diff --git a/02_ruway/mirada/mirada-bar-core/LEEME.md b/02_ruway/mirada/mirada-bar-core/LEEME.md
new file mode 100644
index 0000000..22f7133
--- /dev/null
+++ b/02_ruway/mirada/mirada-bar-core/LEEME.md
@@ -0,0 +1,9 @@
+# mirada-bar-core
+
+> Trait de status bar de [mirada](../README.md).
+
+`StatusBar` define qué slots tiene la barra (workspaces, clock, tray, battery, ...). Permite múltiples implementaciones (Llimphi nativa, HTML overlay).
+
+## Deps
+
+- [`mirada-protocol`](../mirada-protocol/README.md), [`mirada-body`](../mirada-body/README.md)
diff --git a/02_ruway/mirada/mirada-bar-core/README.md b/02_ruway/mirada/mirada-bar-core/README.md
new file mode 100644
index 0000000..9c1750a
--- /dev/null
+++ b/02_ruway/mirada/mirada-bar-core/README.md
@@ -0,0 +1,9 @@
+# mirada-bar-core
+
+> Status bar trait of [mirada](../README.md).
+
+`StatusBar` defines what slots the bar has (workspaces, clock, tray, battery, ...). Allows multiple implementations (Llimphi native, HTML overlay).
+
+## Deps
+
+- [`mirada-protocol`](../mirada-protocol/README.md), [`mirada-body`](../mirada-body/README.md)
diff --git a/02_ruway/mirada/mirada-bar-core/src/lib.rs b/02_ruway/mirada/mirada-bar-core/src/lib.rs
new file mode 100644
index 0000000..ef4afc7
--- /dev/null
+++ b/02_ruway/mirada/mirada-bar-core/src/lib.rs
@@ -0,0 +1,108 @@
+//! Barra core — modelo agnóstico de taskbar.
+//!
+//! Provee la lista de `Task`, los helpers de sanitización para atributos
+//! HTML, y `render_html` puro. El binding DOM vive en `barra-web`.
+
+/// Una tarea (cajita) en la barra.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Task {
+ pub id: String,
+ pub label: String,
+ pub active: bool,
+}
+
+impl Task {
+ pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
+ Self { id: id.into(), label: label.into(), active: false }
+ }
+ pub fn active(mut self) -> Self {
+ self.active = true;
+ self
+ }
+}
+
+/// Renderiza un slice de tareas a markup HTML. Sanitiza IDs y escapa
+/// labels. La salida es la lista de `<li>` que el host inyecta en su `<ul>`.
+pub fn render_html(tasks: &[Task]) -> String {
+ let mut html = String::new();
+ for t in tasks {
+ let id_safe = sanitize_attr(&t.id);
+ let label_safe = escape_text(&t.label);
+ let active_cls = if t.active { " active" } else { "" };
+ html.push_str(&format!(
+ "<li><button class=\"taskbar-item{active_cls}\" data-task=\"{id_safe}\" type=\"button\">\
+ <span class=\"taskbar-item-dot\" aria-hidden=\"true\"></span>{label_safe}</button></li>"
+ ));
+ }
+ html
+}
+
+/// Filtra a `[a-zA-Z0-9_-]` para uso seguro en atributos HTML.
+pub fn sanitize_attr(s: &str) -> String {
+ s.chars()
+ .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
+ .collect()
+}
+
+/// HTML-escape de texto para insertarlo en posiciones de contenido.
+pub fn escape_text(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for c in s.chars() {
+ match c {
+ '&' => out.push_str("&"),
+ '<' => out.push_str("<"),
+ '>' => out.push_str(">"),
+ '"' => out.push_str("""),
+ c => out.push(c),
+ }
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn task_builder_defaults_inactive() {
+ let t = Task::new("aire", "AIRE");
+ assert!(!t.active);
+ assert!(Task::new("f", "F").active().active);
+ }
+
+ #[test]
+ fn sanitize_attr_strips_unsafe() {
+ assert_eq!(sanitize_attr("aire"), "aire");
+ assert_eq!(sanitize_attr("a-b_c"), "a-b_c");
+ assert_eq!(sanitize_attr("ai<re>"), "aire");
+ assert_eq!(sanitize_attr("a\"b"), "ab");
+ }
+
+ #[test]
+ fn escape_text_escapes_html() {
+ assert_eq!(escape_text("AIRE"), "AIRE");
+ assert_eq!(escape_text("<script>"), "<script>");
+ assert_eq!(escape_text("a & b"), "a & b");
+ }
+
+ #[test]
+ fn render_html_emits_active_class() {
+ let tasks = [
+ Task::new("aire", "AIRE"),
+ Task::new("fuego", "FUEGO").active(),
+ ];
+ let html = render_html(&tasks);
+ assert!(html.contains("data-task=\"aire\""));
+ assert!(html.contains("data-task=\"fuego\""));
+ assert!(html.contains("taskbar-item active"));
+ }
+
+ #[test]
+ fn render_html_escapes_label_and_sanitizes_id() {
+ let tasks = [Task::new("a<b", "x<script>y")];
+ let html = render_html(&tasks);
+ assert!(html.contains("data-task=\"ab\""));
+ assert!(html.contains("x<script>y"));
+ assert!(!html.contains("<script>"));
+ }
+}
diff --git a/02_ruway/mirada/mirada-body/Cargo.toml b/02_ruway/mirada/mirada-body/Cargo.toml
new file mode 100644
index 0000000..d2ba2bc
--- /dev/null
+++ b/02_ruway/mirada/mirada-body/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "mirada-body"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "mirada — estado del Cuerpo del compositor: lleva la cuenta de salidas y superficies, traduce los BrainCommand a operaciones de backend y emite los BodyEvent. Agnóstico de smithay."
+
+[dependencies]
+mirada-protocol = { path = "../mirada-protocol" }
+
+[dev-dependencies]
+mirada-link = { path = "../mirada-link" }
diff --git a/02_ruway/mirada/mirada-body/LEEME.md b/02_ruway/mirada/mirada-body/LEEME.md
new file mode 100644
index 0000000..96ddbc2
--- /dev/null
+++ b/02_ruway/mirada/mirada-body/LEEME.md
@@ -0,0 +1,10 @@
+# mirada-body
+
+> Estado físico del display (monitors, modes) de [mirada](../README.md).
+
+Inventario de outputs (HDMI/DP/eDP/...) y sus modos (resolution + refresh + scale). El operador puede cambiar layout/scale en runtime sin reiniciar el compositor.
+
+## Deps
+
+- [`mirada-protocol`](../mirada-protocol/README.md)
+- `drm`, `gbm` (Linux)
diff --git a/02_ruway/mirada/mirada-body/README.md b/02_ruway/mirada/mirada-body/README.md
new file mode 100644
index 0000000..4c09a5a
--- /dev/null
+++ b/02_ruway/mirada/mirada-body/README.md
@@ -0,0 +1,10 @@
+# mirada-body
+
+> Display physical state (monitors, modes) of [mirada](../README.md).
+
+Inventory of outputs (HDMI/DP/eDP/...) and their modes (resolution + refresh + scale). The operator can change layout/scale at runtime without restarting the compositor.
+
+## Deps
+
+- [`mirada-protocol`](../mirada-protocol/README.md)
+- `drm`, `gbm` (Linux)
diff --git a/02_ruway/mirada/mirada-body/examples/headless.rs b/02_ruway/mirada/mirada-body/examples/headless.rs
new file mode 100644
index 0000000..0fb479d
--- /dev/null
+++ b/02_ruway/mirada/mirada-body/examples/headless.rs
@@ -0,0 +1,121 @@
+//! `headless` — un Cuerpo de carmen sin gráficos, guiado por stdin.
+//!
+//! Es el banco de pruebas del Cerebro: implementa el lado del Cuerpo del
+//! protocolo (escucha en un socket, lleva un [`BodyState`], manda
+//! [`BodyEvent`]s y ejecuta —imprimiéndolas— las [`BodyOp`]s) sin tocar
+//! `smithay` ni el hardware. Así se ejercita el bucle entero
+//! Cerebro↔Cuerpo desde una terminal.
+//!
+//! ```text
+//! # terminal 1 — el Cuerpo escucha
+//! cargo run -p mirada-body --example headless -- /tmp/mirada.sock
+//! # terminal 2 — el Cerebro se conecta
+//! MIRADA_SOCKET=/tmp/mirada.sock cargo run -p mirada
+//! ```
+//!
+//! Órdenes de stdin: `output <w> <h>`, `open <app>`, `close <id>`,
+//! `title <id> <texto>`, `key <combo>`, `pointer <id>`, `tick`, `quit`.
+
+use std::io::BufRead;
+use std::time::Duration;
+
+use mirada_body::BodyState;
+use mirada_link::BodyLink;
+
+fn main() {
+ let path = std::env::args()
+ .nth(1)
+ .unwrap_or_else(|| "/tmp/mirada.sock".to_string());
+ let _ = std::fs::remove_file(&path);
+
+ println!("Cuerpo headless · escuchando en {path} — esperando al Cerebro…");
+ let mut link: BodyLink = match BodyLink::listen(&path) {
+ Ok(l) => l,
+ Err(e) => {
+ eprintln!("no se pudo escuchar en {path}: {e}");
+ std::process::exit(1);
+ }
+ };
+ println!("Cerebro conectado. Órdenes: output / open / close / title / key / pointer / tick / quit");
+
+ let mut body = BodyState::new();
+ let mut next_id: u64 = 1;
+ let stdin = std::io::stdin();
+
+ for line in stdin.lock().lines() {
+ let line = match line {
+ Ok(l) => l,
+ Err(_) => break,
+ };
+ let mut parts = line.split_whitespace();
+ let cmd = parts.next().unwrap_or("");
+ let rest: Vec<&str> = parts.collect();
+
+ // Cada orden o bien manda un evento al Cerebro, o no manda nada.
+ let event = match cmd {
+ "output" if rest.len() == 2 => {
+ match (rest[0].parse(), rest[1].parse()) {
+ (Ok(w), Ok(h)) => Some(body.add_output(0, w, h)),
+ _ => {
+ eprintln!("uso: output <ancho> <alto>");
+ None
+ }
+ }
+ }
+ "open" if !rest.is_empty() => {
+ let app = rest[0];
+ let id = next_id;
+ next_id += 1;
+ println!(" → ventana {id} ({app})");
+ Some(body.open_surface(id, format!("org.brahman.{app}"), format!("{app} {id}")))
+ }
+ "close" if rest.len() == 1 => match rest[0].parse() {
+ Ok(id) => body.close_surface(id),
+ Err(_) => {
+ eprintln!("uso: close <id>");
+ None
+ }
+ },
+ "title" if rest.len() >= 2 => match rest[0].parse() {
+ Ok(id) => body.retitle_surface(id, rest[1..].join(" ")),
+ Err(_) => {
+ eprintln!("uso: title <id> <texto>");
+ None
+ }
+ },
+ "key" if rest.len() == 1 => Some(body.keybind(rest[0])),
+ "pointer" if rest.len() == 1 => match rest[0].parse() {
+ Ok(id) => Some(body.pointer_enter(id)),
+ Err(_) => {
+ eprintln!("uso: pointer <id>");
+ None
+ }
+ },
+ "tick" => None,
+ "quit" | "exit" => break,
+ "" => None,
+ other => {
+ eprintln!("orden desconocida: {other}");
+ None
+ }
+ };
+
+ if let Some(ev) = event {
+ if link.send(&ev).is_err() {
+ eprintln!("el Cerebro cerró la conexión.");
+ break;
+ }
+ }
+
+ // Deja que el Cerebro responda y ejecuta lo que ordene.
+ std::thread::sleep(Duration::from_millis(40));
+ for command in link.drain() {
+ for op in body.apply(command) {
+ println!(" · op: {op:?}");
+ }
+ }
+ }
+
+ println!("Cuerpo headless · adiós.");
+ let _ = std::fs::remove_file(&path);
+}
diff --git a/02_ruway/mirada/mirada-body/src/lib.rs b/02_ruway/mirada/mirada-body/src/lib.rs
new file mode 100644
index 0000000..d81d5c8
--- /dev/null
+++ b/02_ruway/mirada/mirada-body/src/lib.rs
@@ -0,0 +1,480 @@
+//! `mirada-body` — el estado del Cuerpo del compositor.
+//!
+//! El "Cuerpo" de carmen (`mirada-compositor`, sobre `smithay`) tiene
+//! dos mitades: el *backend*, que habla Wayland y posee el hardware, y
+//! esta *contabilidad* — qué salidas y superficies existen y con qué
+//! geometría. Aislarla deja el backend reducido a "ejecuta estas
+//! [`BodyOp`]" y la hace testeable sin un servidor gráfico.
+//!
+//! El flujo es simétrico al del Cerebro:
+//!
+//! - El backend avisa de cambios de hardware/clientes con los mutadores
+//! ([`BodyState::open_surface`], [`BodyState::add_output`], …), que
+//! devuelven el [`BodyEvent`] a mandar al Cerebro.
+//! - El Cerebro responde con [`BrainCommand`]s; [`BodyState::apply`] los
+//! traduce a [`BodyOp`]s concretas que el backend ejecuta.
+
+#![forbid(unsafe_code)]
+
+use std::collections::BTreeMap;
+
+use mirada_protocol::{BodyEvent, BrainCommand, Decorations, OutputId, Rect, WindowId};
+
+/// Una superficie Wayland desde la óptica del Cuerpo.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Surface {
+ pub app_id: String,
+ pub title: String,
+ /// Geometría aplicada — `None` hasta la primera [`BodyOp::Configure`].
+ pub geometry: Option<Rect>,
+ pub visible: bool,
+ pub focused: bool,
+ /// `true` si flota: el backend la pinta por encima de las teseladas.
+ pub floating: bool,
+ /// `true` si está en pantalla completa.
+ pub fullscreen: bool,
+}
+
+impl Surface {
+ fn new(app_id: String, title: String) -> Self {
+ Self {
+ app_id,
+ title,
+ geometry: None,
+ visible: false,
+ focused: false,
+ floating: false,
+ fullscreen: false,
+ }
+ }
+}
+
+/// Una orden concreta para el backend (smithay, headless, …).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum BodyOp {
+ /// Recoloca una superficie, la muestra u oculta y dice si flota o
+ /// está en pantalla completa (el backend ajusta el orden de pintado
+ /// y el estado `xdg_toplevel` en consecuencia).
+ Configure {
+ id: WindowId,
+ rect: Rect,
+ visible: bool,
+ floating: bool,
+ fullscreen: bool,
+ },
+ /// Da el foco del teclado a una superficie.
+ Focus(WindowId),
+ /// Quita el foco a todas las superficies.
+ Unfocus,
+ /// Pide el cierre ordenado de un cliente.
+ CloseClient(WindowId),
+ /// Mata a un cliente que no responde.
+ KillClient(WindowId),
+ /// Registra los atajos globales a interceptar.
+ SetGrabs(Vec<String>),
+ /// Cambia el cursor del puntero.
+ SetCursor(String),
+ /// Fija los parámetros de decoración de las ventanas (marco, …).
+ SetDecorations(Decorations),
+ /// Lanza un programa como proceso hijo del compositor.
+ Spawn(String),
+ /// Apaga el compositor y libera el hardware.
+ Shutdown,
+}
+
+/// La contabilidad del Cuerpo: salidas y superficies.
+#[derive(Debug, Default)]
+pub struct BodyState {
+ outputs: Vec<(OutputId, Rect)>,
+ /// `BTreeMap` para que el orden de las `BodyOp` sea determinista.
+ surfaces: BTreeMap<WindowId, Surface>,
+ focused: Option<WindowId>,
+}
+
+impl BodyState {
+ /// Cuerpo recién arrancado: sin salidas ni superficies.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ // --- Traducción de comandos del Cerebro --------------------------
+
+ /// Traduce un comando del Cerebro a las operaciones de backend que lo
+ /// materializan. Sólo emite lo que de verdad cambia: un `Place`
+ /// idéntico al estado actual no produce ninguna `BodyOp`.
+ pub fn apply(&mut self, cmd: BrainCommand) -> Vec<BodyOp> {
+ match cmd {
+ BrainCommand::Place(placements) => {
+ let mut ops = Vec::new();
+ let listed: Vec<WindowId> = placements.iter().map(|p| p.id).collect();
+ let mut new_focus = None;
+
+ // Reconfigura las superficies que aparecen en la lista.
+ for p in &placements {
+ if p.focused {
+ new_focus = Some(p.id);
+ }
+ if let Some(s) = self.surfaces.get_mut(&p.id) {
+ if s.geometry != Some(p.rect)
+ || s.visible != p.visible
+ || s.floating != p.floating
+ || s.fullscreen != p.fullscreen
+ {
+ s.geometry = Some(p.rect);
+ s.visible = p.visible;
+ s.floating = p.floating;
+ s.fullscreen = p.fullscreen;
+ ops.push(BodyOp::Configure {
+ id: p.id,
+ rect: p.rect,
+ visible: p.visible,
+ floating: p.floating,
+ fullscreen: p.fullscreen,
+ });
+ }
+ }
+ }
+
+ // Oculta lo que el Cerebro ya no coloca.
+ for (id, s) in &mut self.surfaces {
+ if !listed.contains(id) && s.visible {
+ s.visible = false;
+ let rect = s.geometry.unwrap_or(Rect::new(0, 0, 0, 0));
+ ops.push(BodyOp::Configure {
+ id: *id,
+ rect,
+ visible: false,
+ floating: s.floating,
+ fullscreen: s.fullscreen,
+ });
+ }
+ }
+
+ // Reasigna el foco sólo si cambió.
+ if new_focus != self.focused {
+ self.focused = new_focus;
+ for (id, s) in &mut self.surfaces {
+ s.focused = Some(*id) == new_focus;
+ }
+ ops.push(match new_focus {
+ Some(id) => BodyOp::Focus(id),
+ None => BodyOp::Unfocus,
+ });
+ }
+ ops
+ }
+ BrainCommand::Close(id) => vec![BodyOp::CloseClient(id)],
+ BrainCommand::Kill(id) => vec![BodyOp::KillClient(id)],
+ BrainCommand::GrabKeys(keys) => vec![BodyOp::SetGrabs(keys)],
+ BrainCommand::SetCursor(name) => vec![BodyOp::SetCursor(name)],
+ BrainCommand::SetDecorations(d) => vec![BodyOp::SetDecorations(d)],
+ BrainCommand::Spawn(cmd) => vec![BodyOp::Spawn(cmd)],
+ BrainCommand::Shutdown => vec![BodyOp::Shutdown],
+ }
+ }
+
+ // --- Mutadores del backend → eventos para el Cerebro -------------
+
+ /// Registra una salida recién conectada.
+ pub fn add_output(&mut self, id: OutputId, width: i32, height: i32) -> BodyEvent {
+ self.outputs.push((id, Rect::new(0, 0, width, height)));
+ BodyEvent::OutputAdded { id, width, height }
+ }
+
+ /// Da de baja una salida desconectada.
+ pub fn remove_output(&mut self, id: OutputId) -> BodyEvent {
+ self.outputs.retain(|(o, _)| *o != id);
+ BodyEvent::OutputRemoved { id }
+ }
+
+ /// Cambia el área útil de una salida sin desconectarla — al
+ /// redimensionar la ventana anfitriona o al reservar/liberar la
+ /// franja del shell. Conserva el escritorio que muestra.
+ pub fn resize_output(&mut self, id: OutputId, width: i32, height: i32) -> BodyEvent {
+ if let Some((_, rect)) = self.outputs.iter_mut().find(|(o, _)| *o == id) {
+ rect.w = width;
+ rect.h = height;
+ }
+ BodyEvent::OutputResized { id, width, height }
+ }
+
+ /// Reserva —o libera— franjas en los bordes de una salida: las zonas
+ /// exclusivas (px desde cada borde) que el teselado debe esquivar. Las usa
+ /// el marco (`pata`) para acoplar sus barras sin que las ventanas las tapen;
+ /// cero en los cuatro libera la reserva. No toca el tamaño físico, así que
+ /// admite barras en varios bordes a la vez.
+ pub fn reserve_output(
+ &self,
+ id: OutputId,
+ top: i32,
+ bottom: i32,
+ left: i32,
+ right: i32,
+ ) -> BodyEvent {
+ BodyEvent::OutputReserved {
+ id,
+ top,
+ bottom,
+ left,
+ right,
+ }
+ }
+
+ /// Registra una superficie recién creada por un cliente.
+ pub fn open_surface(
+ &mut self,
+ id: WindowId,
+ app_id: impl Into<String>,
+ title: impl Into<String>,
+ ) -> BodyEvent {
+ let app_id = app_id.into();
+ let title = title.into();
+ self.surfaces
+ .insert(id, Surface::new(app_id.clone(), title.clone()));
+ BodyEvent::WindowOpened { id, app_id, title }
+ }
+
+ /// Da de baja una superficie destruida. `None` si no se conocía.
+ pub fn close_surface(&mut self, id: WindowId) -> Option<BodyEvent> {
+ self.surfaces.remove(&id)?;
+ if self.focused == Some(id) {
+ self.focused = None;
+ }
+ Some(BodyEvent::WindowClosed { id })
+ }
+
+ /// Actualiza el título de una superficie. `None` si no se conocía.
+ pub fn retitle_surface(&mut self, id: WindowId, title: impl Into<String>) -> Option<BodyEvent> {
+ let title = title.into();
+ let s = self.surfaces.get_mut(&id)?;
+ s.title = title.clone();
+ Some(BodyEvent::WindowRetitled { id, title })
+ }
+
+ /// Construye un evento de puntero entrando en una superficie.
+ pub fn pointer_enter(&self, id: WindowId) -> BodyEvent {
+ BodyEvent::PointerEntered { id }
+ }
+
+ /// Construye un evento de click (foco-al-click) sobre una superficie.
+ pub fn clicked(&self, id: WindowId) -> BodyEvent {
+ BodyEvent::Clicked { id }
+ }
+
+ /// Construye un evento de arrastre teselado al punto `(x, y)`.
+ pub fn window_dragged(&self, id: WindowId, x: i32, y: i32) -> BodyEvent {
+ BodyEvent::WindowDragged { id, x, y }
+ }
+
+ /// Construye un evento de atajo pulsado.
+ pub fn keybind(&self, combo: impl Into<String>) -> BodyEvent {
+ BodyEvent::Keybind(combo.into())
+ }
+
+ // --- Accesores de sólo lectura -----------------------------------
+
+ /// Las salidas conectadas.
+ pub fn outputs(&self) -> &[(OutputId, Rect)] {
+ &self.outputs
+ }
+
+ /// Una superficie conocida.
+ pub fn surface(&self, id: WindowId) -> Option<&Surface> {
+ self.surfaces.get(&id)
+ }
+
+ /// Número de superficies registradas.
+ pub fn surface_count(&self) -> usize {
+ self.surfaces.len()
+ }
+
+ /// Las superficies visibles, en orden de id.
+ pub fn visible(&self) -> impl Iterator<Item = (WindowId, &Surface)> {
+ self.surfaces.iter().filter(|(_, s)| s.visible).map(|(id, s)| (*id, s))
+ }
+
+ /// La superficie enfocada.
+ pub fn focused(&self) -> Option<WindowId> {
+ self.focused
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use mirada_protocol::WindowPlacement;
+
+ fn placement(id: WindowId, visible: bool, focused: bool) -> WindowPlacement {
+ WindowPlacement {
+ id,
+ rect: Rect::new(0, 0, 800, 600),
+ visible,
+ focused,
+ floating: false,
+ fullscreen: false,
+ }
+ }
+
+ /// Cuerpo con dos superficies abiertas.
+ fn body_with_two() -> BodyState {
+ let mut b = BodyState::new();
+ b.add_output(0, 1920, 1080);
+ b.open_surface(1, "app1", "uno");
+ b.open_surface(2, "app2", "dos");
+ b
+ }
+
+ #[test]
+ fn opening_a_surface_yields_a_window_opened_event() {
+ let mut b = BodyState::new();
+ let ev = b.open_surface(7, "org.brahman.shuma", "shell");
+ assert_eq!(
+ ev,
+ BodyEvent::WindowOpened {
+ id: 7,
+ app_id: "org.brahman.shuma".into(),
+ title: "shell".into()
+ }
+ );
+ assert_eq!(b.surface_count(), 1);
+ }
+
+ #[test]
+ fn placing_surfaces_configures_and_focuses_them() {
+ let mut b = body_with_two();
+ let ops = b.apply(BrainCommand::Place(vec![
+ placement(1, true, false),
+ placement(2, true, true),
+ ]));
+ // Dos Configure + un Focus.
+ let configures = ops.iter().filter(|o| matches!(o, BodyOp::Configure { .. })).count();
+ assert_eq!(configures, 2);
+ assert!(ops.contains(&BodyOp::Focus(2)));
+ assert_eq!(b.focused(), Some(2));
+ assert!(b.surface(2).unwrap().focused);
+ }
+
+ #[test]
+ fn an_identical_place_produces_no_ops() {
+ let mut b = body_with_two();
+ let cmd = BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)]);
+ assert!(!b.apply(cmd.clone()).is_empty());
+ // Repetir el mismo Place no cambia nada.
+ assert!(b.apply(cmd).is_empty());
+ }
+
+ #[test]
+ fn dropping_a_surface_from_the_list_hides_it() {
+ let mut b = body_with_two();
+ b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)]));
+ // El Cerebro deja de colocar la 2 (p. ej. cambió de escritorio).
+ let ops = b.apply(BrainCommand::Place(vec![placement(1, true, true)]));
+ assert!(ops.contains(&BodyOp::Configure {
+ id: 2,
+ rect: Rect::new(0, 0, 800, 600),
+ visible: false,
+ floating: false,
+ fullscreen: false,
+ }));
+ assert!(!b.surface(2).unwrap().visible);
+ }
+
+ #[test]
+ fn placement_for_an_unknown_surface_is_ignored() {
+ let mut b = body_with_two();
+ // La 99 no existe — no debe producir Configure.
+ let ops = b.apply(BrainCommand::Place(vec![placement(99, true, true)]));
+ assert!(!ops.iter().any(|o| matches!(o, BodyOp::Configure { id: 99, .. })));
+ }
+
+ #[test]
+ fn close_and_kill_map_to_client_ops() {
+ let mut b = body_with_two();
+ assert_eq!(b.apply(BrainCommand::Close(1)), vec![BodyOp::CloseClient(1)]);
+ assert_eq!(b.apply(BrainCommand::Kill(2)), vec![BodyOp::KillClient(2)]);
+ }
+
+ #[test]
+ fn grab_keys_cursor_and_shutdown_pass_through() {
+ let mut b = BodyState::new();
+ assert_eq!(
+ b.apply(BrainCommand::GrabKeys(vec!["Super+q".into()])),
+ vec![BodyOp::SetGrabs(vec!["Super+q".into()])]
+ );
+ assert_eq!(
+ b.apply(BrainCommand::SetCursor("crosshair".into())),
+ vec![BodyOp::SetCursor("crosshair".into())]
+ );
+ assert_eq!(b.apply(BrainCommand::Shutdown), vec![BodyOp::Shutdown]);
+ }
+
+ #[test]
+ fn closing_a_surface_clears_its_focus() {
+ let mut b = body_with_two();
+ b.apply(BrainCommand::Place(vec![placement(1, true, true)]));
+ assert_eq!(b.focused(), Some(1));
+ let ev = b.close_surface(1);
+ assert_eq!(ev, Some(BodyEvent::WindowClosed { id: 1 }));
+ assert_eq!(b.focused(), None);
+ assert_eq!(b.surface_count(), 1);
+ }
+
+ #[test]
+ fn closing_an_unknown_surface_yields_nothing() {
+ let mut b = body_with_two();
+ assert!(b.close_surface(404).is_none());
+ }
+
+ #[test]
+ fn retitling_updates_the_surface() {
+ let mut b = body_with_two();
+ let ev = b.retitle_surface(1, "uno bis");
+ assert_eq!(ev, Some(BodyEvent::WindowRetitled { id: 1, title: "uno bis".into() }));
+ assert_eq!(b.surface(1).unwrap().title, "uno bis");
+ }
+
+ #[test]
+ fn outputs_are_tracked() {
+ let mut b = BodyState::new();
+ b.add_output(0, 2560, 1440);
+ b.add_output(1, 1920, 1080);
+ assert_eq!(b.outputs().len(), 2);
+ b.remove_output(0);
+ assert_eq!(b.outputs().len(), 1);
+ assert_eq!(b.outputs()[0].0, 1);
+ }
+
+ #[test]
+ fn moving_focus_emits_a_single_focus_op() {
+ let mut b = body_with_two();
+ b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)]));
+ // Cambia el foco a la 2; geometría igual → sólo un Focus.
+ let ops = b.apply(BrainCommand::Place(vec![
+ placement(1, true, false),
+ placement(2, true, true),
+ ]));
+ assert_eq!(ops, vec![BodyOp::Focus(2)]);
+ }
+
+ #[test]
+ fn a_floating_change_alone_triggers_a_configure() {
+ let mut b = body_with_two();
+ let mut p1 = placement(1, true, true);
+ b.apply(BrainCommand::Place(vec![p1, placement(2, true, false)]));
+ // Sólo cambia `floating` — misma geometría y visibilidad.
+ p1.floating = true;
+ let ops = b.apply(BrainCommand::Place(vec![p1, placement(2, true, false)]));
+ assert!(ops
+ .iter()
+ .any(|o| matches!(o, BodyOp::Configure { id: 1, floating: true, .. })));
+ assert!(b.surface(1).unwrap().floating);
+ }
+
+ #[test]
+ fn visible_iterates_only_shown_surfaces() {
+ let mut b = body_with_two();
+ b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, false, false)]));
+ let shown: Vec<_> = b.visible().map(|(id, _)| id).collect();
+ assert_eq!(shown, vec![1]);
+ }
+}
diff --git a/02_ruway/mirada/mirada-brain/Cargo.toml b/02_ruway/mirada/mirada-brain/Cargo.toml
new file mode 100644
index 0000000..f55109a
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "mirada-brain"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "mirada — orquestador de escritorio del compositor: mantiene salidas, escritorios virtuales, ventanas y foco; consume BodyEvent y produce BrainCommand. Agnóstico de GPUI y de smithay."
+
+[dependencies]
+# `serde` activa los `derive` de los tipos de layout (este crate
+# serializa el estado del escritorio a RON).
+mirada-layout = { path = "../mirada-layout", features = ["serde"] }
+mirada-protocol = { path = "../mirada-protocol" }
+serde = { workspace = true }
+ron = { workspace = true }
+directories = { workspace = true }
+notify = { workspace = true }
diff --git a/02_ruway/mirada/mirada-brain/LEEME.md b/02_ruway/mirada/mirada-brain/LEEME.md
new file mode 100644
index 0000000..1ce11bb
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/LEEME.md
@@ -0,0 +1,9 @@
+# mirada-brain
+
+> Inteligencia del compositor (placement, focus) de [mirada](../README.md).
+
+Reglas que deciden dónde se abre una ventana nueva, qué ventana recibe focus al cerrar otra, cómo se distribuye el espacio entre tiles. Reglas declarativas; permite scripting básico sin recompilar.
+
+## Deps
+
+- [`mirada-layout`](../mirada-layout/README.md), [`mirada-body`](../mirada-body/README.md)
diff --git a/02_ruway/mirada/mirada-brain/README.md b/02_ruway/mirada/mirada-brain/README.md
new file mode 100644
index 0000000..59736c1
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/README.md
@@ -0,0 +1,9 @@
+# mirada-brain
+
+> Compositor intelligence (placement, focus) of [mirada](../README.md).
+
+Rules that decide where a new window opens, which window gets focus when another closes, how space is distributed between tiles. Declarative rules; allows basic scripting without recompiling.
+
+## Deps
+
+- [`mirada-layout`](../mirada-layout/README.md), [`mirada-body`](../mirada-body/README.md)
diff --git a/02_ruway/mirada/mirada-brain/examples/headless-ctl.rs b/02_ruway/mirada/mirada-brain/examples/headless-ctl.rs
new file mode 100644
index 0000000..1dec3ca
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/examples/headless-ctl.rs
@@ -0,0 +1,117 @@
+//! Un Cerebro *headless* para probar el API de control sin gráficos.
+//!
+//! Abre el socket de `mirada-ctl`, arranca un [`Desktop`] con una pantalla
+//! y unas ventanas de muestra, y atiende peticiones en bucle, imprimiendo
+//! el estado tras cada una. Útil para ejercitar `mirada-ctl` en modo
+//! desatendido.
+//!
+//! ```sh
+//! cargo run -p mirada-brain --example headless-ctl # terminal 1
+//! mirada-ctl windows # terminal 2
+//! mirada-ctl focus-next
+//! mirada-ctl focus-window 2
+//! ```
+
+use std::thread;
+use std::time::Duration;
+
+use mirada_brain::ctl::{self, CtlReply, CtlRequest, CtlServer};
+use mirada_brain::{BodyEvent, BrainCommand, Desktop};
+
+fn main() {
+ let path = ctl::default_socket_path();
+ let server = match CtlServer::bind(&path) {
+ Ok(s) => s,
+ Err(e) => {
+ eprintln!("Cerebro headless · no pude abrir el control: {e}");
+ std::process::exit(1);
+ }
+ };
+ eprintln!("Cerebro headless · control en {}", path.display());
+
+ // Dos pantallas y tres ventanas de muestra.
+ let mut desktop = Desktop::new();
+ desktop.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+ desktop.on_event(BodyEvent::OutputAdded { id: 1, width: 1920, height: 1080 });
+ for id in 1..=3 {
+ desktop.on_event(BodyEvent::WindowOpened {
+ id,
+ app_id: format!("org.brahman.app{id}"),
+ title: format!("ventana {id}"),
+ });
+ }
+ print_state(&desktop);
+ eprintln!(" esperando a mirada-ctl …");
+
+ loop {
+ if let Some(mut conn) = server.poll() {
+ if let Ok(Some(req)) = conn.read_request() {
+ let reply = match req {
+ CtlRequest::Do(action) => {
+ eprintln!("· {action}");
+ for cmd in desktop.apply(action) {
+ match cmd {
+ // La geometría que el Cerebro mandaría al Cuerpo.
+ BrainCommand::Place(places) => {
+ for p in places {
+ eprintln!(
+ " win {} → {:>5}×{:<4} @ ({:>5},{:>4}){}{}",
+ p.id,
+ p.rect.w,
+ p.rect.h,
+ p.rect.x,
+ p.rect.y,
+ if p.fullscreen {
+ " ~pantalla"
+ } else if p.floating {
+ " ~flotante"
+ } else {
+ ""
+ },
+ if p.focused { " *" } else { "" },
+ );
+ }
+ }
+ // Sin Cuerpo: simulamos nosotros el cierre.
+ BrainCommand::Close(id) | BrainCommand::Kill(id) => {
+ desktop.on_event(BodyEvent::WindowClosed { id });
+ }
+ _ => {}
+ }
+ }
+ print_state(&desktop);
+ CtlReply::Ok
+ }
+ CtlRequest::ListWindows => CtlReply::Windows(desktop.window_lines()),
+ // Las zonas son del Cuerpo (compositor); este ejemplo
+ // headless del Cerebro no las tiene.
+ CtlRequest::CycleZones => CtlReply::Ok,
+ };
+ let _ = conn.reply(&reply);
+ }
+ }
+ thread::sleep(Duration::from_millis(16));
+ }
+}
+
+fn print_state(d: &Desktop) {
+ let ws = d.active_workspace();
+ eprintln!(
+ " activo: escritorio {} · {:?} (maestra {:.0}%) · foco {:?}",
+ d.active_index() + 1,
+ ws.params().mode,
+ ws.params().master_ratio * 100.0,
+ d.focused_window(),
+ );
+ for (i, o) in d.outputs().iter().enumerate() {
+ let mark = if i == d.focused_output() { '*' } else { ' ' };
+ eprintln!(
+ " {mark} salida {} {}×{} @ x{} → escritorio {}",
+ o.id,
+ o.rect.w,
+ o.rect.h,
+ o.rect.x,
+ o.workspace + 1,
+ );
+ }
+}
diff --git a/02_ruway/mirada/mirada-brain/examples/keymap-default.rs b/02_ruway/mirada/mirada-brain/examples/keymap-default.rs
new file mode 100644
index 0000000..89e0c6b
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/examples/keymap-default.rs
@@ -0,0 +1,11 @@
+//! Imprime el keymap por defecto de mirada en format RON — exactamente
+//! lo que la app escribe la primera vez en `~/.config/mirada/keymap.ron`.
+//!
+//! ```sh
+//! cargo run -p mirada-brain --example keymap-default
+//! cargo run -p mirada-brain --example keymap-default > ~/.config/mirada/keymap.ron
+//! ```
+
+fn main() {
+ print!("{}", mirada_brain::Keymap::default().documented_ron());
+}
diff --git a/02_ruway/mirada/mirada-brain/src/action.rs b/02_ruway/mirada/mirada-brain/src/action.rs
new file mode 100644
index 0000000..add0083
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/action.rs
@@ -0,0 +1,454 @@
+//! Acciones de escritorio y su mapa de teclas por defecto.
+//!
+//! Una [`DesktopAction`] es una orden de alto nivel del usuario, ya
+//! desligada de la tecla concreta: el [`Desktop`](crate::Desktop) las
+//! aplica sin saber qué combinación las disparó.
+//!
+//! Cada acción tiene una **forma textual** estable ([`Display`] /
+//! [`FromStr`]) — `"focus-next"`, `"layout:grid"`, `"workspace:3"` — que
+//! es el vocabulario del keymap configurable en RON (ver [`crate::keymap`]).
+
+use std::fmt;
+use std::str::FromStr;
+
+use serde::{Deserialize, Serialize};
+
+use mirada_layout::{LayoutMode, WindowId};
+
+/// Número de escritorios virtuales que mantiene el `Desktop`.
+pub const WORKSPACE_COUNT: usize = 9;
+
+/// Una dirección cardinal en pantalla — para el foco (y, a futuro, el
+/// movimiento) espacial entre ventanas teseladas.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Direction {
+ Left,
+ Right,
+ Up,
+ Down,
+}
+
+impl Direction {
+ /// El sufijo textual de la dirección — `"left"`, `"right"`, …
+ fn slug(self) -> &'static str {
+ match self {
+ Direction::Left => "left",
+ Direction::Right => "right",
+ Direction::Up => "up",
+ Direction::Down => "down",
+ }
+ }
+
+ /// La dirección desde su sufijo, o `None` si no calza.
+ fn from_slug(s: &str) -> Option<Direction> {
+ Some(match s {
+ "left" => Direction::Left,
+ "right" => Direction::Right,
+ "up" => Direction::Up,
+ "down" => Direction::Down,
+ _ => return None,
+ })
+ }
+}
+
+/// Una orden de escritorio de alto nivel.
+///
+/// Es serializable (`postcard`) para viajar por el API de control
+/// ([`crate::ctl`]) y tiene una forma textual estable ([`Display`] /
+/// [`FromStr`]) para el keymap y `mirada-ctl`.
+///
+/// No es `Copy`: [`Spawn`](DesktopAction::Spawn) lleva su comando.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum DesktopAction {
+ /// Mueve el foco a la ventana siguiente del escritorio activo.
+ FocusNext,
+ /// Mueve el foco a la ventana anterior.
+ FocusPrev,
+ /// Mueve el foco a la ventana teselada más cercana en una dirección
+ /// cardinal (espacial, no cíclico) — el `Super+flechas` clásico.
+ FocusDir(Direction),
+ /// Enfoca una ventana concreta por su id; si está en otro escritorio,
+ /// salta a él. Para clics de taskbar o `mirada-ctl focus-window`.
+ FocusWindow(WindowId),
+ /// Intercambia la ventana enfocada con su vecina teselada en una
+ /// dirección cardinal (mueve la ventana por geometría) — Super+Shift+flechas.
+ MoveDir(Direction),
+ /// Adelanta la ventana enfocada en el orden de teselado.
+ MoveForward,
+ /// Atrasa la ventana enfocada en el orden de teselado.
+ MoveBackward,
+ /// Cierra la ventana enfocada (cierre ordenado).
+ CloseFocused,
+ /// Alterna entre flotante y teselada la ventana enfocada.
+ ToggleFloat,
+ /// Alterna el escritorio entero entre teselado y flotante: si queda
+ /// alguna teselada, las hace flotar todas (en cascada); si ya están
+ /// todas flotando, las devuelve al teselado.
+ ToggleTiling,
+ /// Alterna pantalla completa en la ventana enfocada.
+ ToggleFullscreen,
+ /// Guarda la ventana enfocada en el scratchpad (la oculta).
+ SendToScratchpad,
+ /// Invoca u oculta la ventana del scratchpad — aparece flotando.
+ ToggleScratchpad,
+ /// Despliega/oculta la terminal dropdown estilo *quake* — un toplevel
+ /// real anclado arriba a todo el ancho, con foco de teclado normal. La
+ /// crea perezosamente la primera vez (patrón pypr).
+ ToggleDropterm,
+ /// Pasa al siguiente modo de teselado.
+ CycleLayout,
+ /// Fija un modo de teselado concreto.
+ SetLayout(LayoutMode),
+ /// Agranda el área de la ventana maestra (`MasterStack`/`CenteredMaster`).
+ GrowMaster,
+ /// Encoge el área de la ventana maestra.
+ ShrinkMaster,
+ /// Mete una ventana más en el área maestra (`nmaster`).
+ IncMaster,
+ /// Saca una ventana del área maestra.
+ DecMaster,
+ /// Lleva la ventana enfocada al puesto maestro (la inserta al frente,
+ /// desplazando al resto).
+ PromoteToMaster,
+ /// Intercambia la ventana enfocada con la maestra (sólo esas dos; el
+ /// resto del orden queda igual). El foco acompaña a la ventana.
+ SwapMaster,
+ /// Activa el escritorio virtual `n` (índice 0-based).
+ SwitchWorkspace(usize),
+ /// Manda la ventana enfocada al escritorio virtual `n` (queda donde está).
+ SendToWorkspace(usize),
+ /// Manda la ventana enfocada al escritorio `n` y salta con ella allí.
+ MoveToWorkspace(usize),
+ /// Mueve el foco a la siguiente salida (monitor).
+ FocusOutputNext,
+ /// Mueve el foco a la salida (monitor) vecina en una dirección cardinal.
+ FocusOutputDir(Direction),
+ /// Manda la ventana enfocada a la salida vecina en una dirección — pasa
+ /// al escritorio que muestra esa salida.
+ SendToOutputDir(Direction),
+ /// Redimensiona la ventana flotante enfocada hacia una dirección
+ /// (derecha/abajo agrandan; izquierda/arriba achican), por `float_step`
+ /// px. No hace nada sobre una teselada.
+ ResizeFloatDir(Direction),
+ /// Lanza un programa — abre una terminal, un navegador, lo que sea.
+ /// El comando se pasa a `sh -c` en el Cuerpo.
+ Spawn(String),
+ /// Apaga el compositor.
+ Quit,
+}
+
+/// El nombre RON-seguro de un modo de teselado (sin guiones problemáticos
+/// para identificadores: aquí van como valor de cadena, no de enum).
+pub(crate) fn layout_slug(mode: LayoutMode) -> &'static str {
+ match mode {
+ LayoutMode::MasterStack => "master-stack",
+ LayoutMode::Monocle => "monocle",
+ LayoutMode::Grid => "grid",
+ LayoutMode::Columns => "columns",
+ LayoutMode::Rows => "rows",
+ LayoutMode::CenteredMaster => "centered-master",
+ LayoutMode::Spiral => "spiral",
+ }
+}
+
+/// Modo de teselado desde su `slug`.
+pub(crate) fn layout_from_slug(slug: &str) -> Option<LayoutMode> {
+ Some(match slug {
+ "master-stack" => LayoutMode::MasterStack,
+ "monocle" => LayoutMode::Monocle,
+ "grid" => LayoutMode::Grid,
+ "columns" => LayoutMode::Columns,
+ "rows" => LayoutMode::Rows,
+ "centered-master" => LayoutMode::CenteredMaster,
+ "spiral" => LayoutMode::Spiral,
+ _ => return None,
+ })
+}
+
+impl fmt::Display for DesktopAction {
+ /// La forma textual estable de la acción — el vocabulario del keymap.
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ DesktopAction::FocusNext => f.write_str("focus-next"),
+ DesktopAction::FocusPrev => f.write_str("focus-prev"),
+ DesktopAction::FocusDir(d) => write!(f, "focus-{}", d.slug()),
+ DesktopAction::MoveDir(d) => write!(f, "move-{}", d.slug()),
+ DesktopAction::FocusWindow(id) => write!(f, "focus-window:{id}"),
+ DesktopAction::MoveForward => f.write_str("move-forward"),
+ DesktopAction::MoveBackward => f.write_str("move-backward"),
+ DesktopAction::CloseFocused => f.write_str("close-focused"),
+ DesktopAction::ToggleFloat => f.write_str("toggle-float"),
+ DesktopAction::ToggleTiling => f.write_str("toggle-tiling"),
+ DesktopAction::ToggleFullscreen => f.write_str("toggle-fullscreen"),
+ DesktopAction::SendToScratchpad => f.write_str("send-to-scratchpad"),
+ DesktopAction::ToggleScratchpad => f.write_str("toggle-scratchpad"),
+ DesktopAction::ToggleDropterm => f.write_str("toggle-dropterm"),
+ DesktopAction::CycleLayout => f.write_str("cycle-layout"),
+ DesktopAction::SetLayout(m) => write!(f, "layout:{}", layout_slug(*m)),
+ DesktopAction::GrowMaster => f.write_str("grow-master"),
+ DesktopAction::ShrinkMaster => f.write_str("shrink-master"),
+ DesktopAction::IncMaster => f.write_str("inc-master"),
+ DesktopAction::DecMaster => f.write_str("dec-master"),
+ DesktopAction::PromoteToMaster => f.write_str("promote-to-master"),
+ DesktopAction::SwapMaster => f.write_str("swap-master"),
+ // Los escritorios se numeran 1-based de cara al usuario.
+ DesktopAction::SwitchWorkspace(n) => write!(f, "workspace:{}", n + 1),
+ DesktopAction::SendToWorkspace(n) => write!(f, "send-to-workspace:{}", n + 1),
+ DesktopAction::MoveToWorkspace(n) => write!(f, "move-to-workspace:{}", n + 1),
+ DesktopAction::FocusOutputNext => f.write_str("focus-output-next"),
+ DesktopAction::FocusOutputDir(d) => write!(f, "focus-output-{}", d.slug()),
+ DesktopAction::SendToOutputDir(d) => write!(f, "send-to-output-{}", d.slug()),
+ DesktopAction::ResizeFloatDir(d) => write!(f, "resize-float-{}", d.slug()),
+ DesktopAction::Spawn(cmd) => write!(f, "spawn:{cmd}"),
+ DesktopAction::Quit => f.write_str("quit"),
+ }
+ }
+}
+
+impl FromStr for DesktopAction {
+ /// Mensaje de error ya formateado, listo para mostrar al usuario.
+ type Err = String;
+
+ fn from_str(s: &str) -> Result<Self, String> {
+ let s = s.trim();
+ Ok(match s {
+ "focus-next" => Self::FocusNext,
+ "focus-prev" => Self::FocusPrev,
+ "move-forward" => Self::MoveForward,
+ "move-backward" => Self::MoveBackward,
+ "close-focused" => Self::CloseFocused,
+ "toggle-float" => Self::ToggleFloat,
+ "toggle-tiling" => Self::ToggleTiling,
+ "toggle-fullscreen" => Self::ToggleFullscreen,
+ "send-to-scratchpad" => Self::SendToScratchpad,
+ "toggle-scratchpad" => Self::ToggleScratchpad,
+ "toggle-dropterm" => Self::ToggleDropterm,
+ "cycle-layout" => Self::CycleLayout,
+ "grow-master" => Self::GrowMaster,
+ "shrink-master" => Self::ShrinkMaster,
+ "inc-master" => Self::IncMaster,
+ "dec-master" => Self::DecMaster,
+ "promote-to-master" => Self::PromoteToMaster,
+ "swap-master" => Self::SwapMaster,
+ "focus-output-next" => Self::FocusOutputNext,
+ "quit" => Self::Quit,
+ _ => {
+ if let Some(slug) = s.strip_prefix("layout:") {
+ Self::SetLayout(
+ layout_from_slug(slug)
+ .ok_or_else(|| format!("modo de teselado desconocido: '{slug}'"))?,
+ )
+ } else if let Some(d) = s.strip_prefix("focus-").and_then(Direction::from_slug) {
+ Self::FocusDir(d)
+ } else if let Some(d) = s.strip_prefix("focus-output-").and_then(Direction::from_slug) {
+ Self::FocusOutputDir(d)
+ } else if let Some(d) = s.strip_prefix("send-to-output-").and_then(Direction::from_slug) {
+ Self::SendToOutputDir(d)
+ } else if let Some(d) = s.strip_prefix("resize-float-").and_then(Direction::from_slug) {
+ Self::ResizeFloatDir(d)
+ } else if let Some(d) = s.strip_prefix("move-").and_then(Direction::from_slug) {
+ Self::MoveDir(d)
+ } else if let Some(id) = s.strip_prefix("focus-window:") {
+ Self::FocusWindow(
+ id.trim()
+ .parse()
+ .map_err(|_| format!("id de ventana inválido: '{id}'"))?,
+ )
+ } else if let Some(n) = s.strip_prefix("send-to-workspace:") {
+ Self::SendToWorkspace(parse_workspace(n)?)
+ } else if let Some(n) = s.strip_prefix("move-to-workspace:") {
+ Self::MoveToWorkspace(parse_workspace(n)?)
+ } else if let Some(n) = s.strip_prefix("workspace:") {
+ Self::SwitchWorkspace(parse_workspace(n)?)
+ } else if let Some(cmd) = s.strip_prefix("spawn:") {
+ let cmd = cmd.trim();
+ if cmd.is_empty() {
+ return Err("spawn: necesita un comando".into());
+ }
+ Self::Spawn(cmd.to_string())
+ } else {
+ return Err(format!("acción desconocida: '{s}'"));
+ }
+ }
+ })
+ }
+}
+
+/// Parsea el número de escritorio del keymap (1-based) a índice (0-based),
+/// acotado a [`WORKSPACE_COUNT`].
+fn parse_workspace(s: &str) -> Result<usize, String> {
+ let n: usize = s
+ .trim()
+ .parse()
+ .map_err(|_| format!("número de escritorio inválido: '{s}'"))?;
+ if (1..=WORKSPACE_COUNT).contains(&n) {
+ Ok(n - 1)
+ } else {
+ Err(format!("escritorio fuera de rango (1..={WORKSPACE_COUNT}): {n}"))
+ }
+}
+
+/// Mapa de teclas por defecto, estilo *tiling WM* (modificador `Super`).
+///
+/// Las cadenas deben coincidir literalmente con las que el Cuerpo emite
+/// en [`BodyEvent::Keybind`](mirada_protocol::BodyEvent::Keybind); son
+/// también las que se registran con
+/// [`BrainCommand::GrabKeys`](mirada_protocol::BrainCommand::GrabKeys).
+pub fn default_keymap() -> Vec<(String, DesktopAction)> {
+ let mut map = vec![
+ ("Super+j".into(), DesktopAction::FocusNext),
+ ("Super+k".into(), DesktopAction::FocusPrev),
+ // Foco espacial estilo i3/sway — el clásico Super+flechas.
+ ("Super+Left".into(), DesktopAction::FocusDir(Direction::Left)),
+ ("Super+Right".into(), DesktopAction::FocusDir(Direction::Right)),
+ ("Super+Up".into(), DesktopAction::FocusDir(Direction::Up)),
+ ("Super+Down".into(), DesktopAction::FocusDir(Direction::Down)),
+ // Mover la ventana enfocada por geometría — Super+Shift+flechas.
+ ("Super+Shift+Left".into(), DesktopAction::MoveDir(Direction::Left)),
+ ("Super+Shift+Right".into(), DesktopAction::MoveDir(Direction::Right)),
+ ("Super+Shift+Up".into(), DesktopAction::MoveDir(Direction::Up)),
+ ("Super+Shift+Down".into(), DesktopAction::MoveDir(Direction::Down)),
+ ("Super+Shift+j".into(), DesktopAction::MoveForward),
+ ("Super+Shift+k".into(), DesktopAction::MoveBackward),
+ ("Super+q".into(), DesktopAction::CloseFocused),
+ ("Super+f".into(), DesktopAction::ToggleFloat),
+ ("Super+Shift+f".into(), DesktopAction::ToggleFullscreen),
+ // La tecla «quake» clásica baja la terminal dropdown.
+ ("Super+`".into(), DesktopAction::ToggleDropterm),
+ // Scratchpad genérico: enviar la enfocada / invocar la guardada.
+ // (Shift+` produce «~» tras canonizar, así que ése es el combo.)
+ ("Super+Shift+s".into(), DesktopAction::SendToScratchpad),
+ ("Super+Shift+~".into(), DesktopAction::ToggleScratchpad),
+ ("Super+space".into(), DesktopAction::CycleLayout),
+ ("Super+Shift+space".into(), DesktopAction::ToggleTiling),
+ ("Super+t".into(), DesktopAction::SetLayout(LayoutMode::MasterStack)),
+ ("Super+m".into(), DesktopAction::SetLayout(LayoutMode::Monocle)),
+ ("Super+g".into(), DesktopAction::SetLayout(LayoutMode::Grid)),
+ ("Super+c".into(), DesktopAction::SetLayout(LayoutMode::Columns)),
+ ("Super+r".into(), DesktopAction::SetLayout(LayoutMode::Rows)),
+ ("Super+d".into(), DesktopAction::SetLayout(LayoutMode::CenteredMaster)),
+ ("Super+s".into(), DesktopAction::SetLayout(LayoutMode::Spiral)),
+ ("Super+h".into(), DesktopAction::ShrinkMaster),
+ ("Super+l".into(), DesktopAction::GrowMaster),
+ ("Super+o".into(), DesktopAction::FocusOutputNext),
+ ("Super+Return".into(), DesktopAction::PromoteToMaster),
+ ("Super+Shift+Return".into(), DesktopAction::Spawn("foot".into())),
+ ("Super+p".into(), DesktopAction::Spawn("foot -e mirada-launcher".into())),
+ ("Super+,".into(), DesktopAction::IncMaster),
+ ("Super+.".into(), DesktopAction::DecMaster),
+ ("Super+Shift+e".into(), DesktopAction::Quit),
+ ];
+ // Un escritorio por dígito: `Super+1`..`Super+9` lo activan,
+ // `Super+Shift+1`.. mandan la ventana enfocada allí.
+ for n in 0..WORKSPACE_COUNT {
+ map.push((format!("Super+{}", n + 1), DesktopAction::SwitchWorkspace(n)));
+ map.push((
+ format!("Super+Shift+{}", n + 1),
+ DesktopAction::SendToWorkspace(n),
+ ));
+ }
+ map
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn keymap_has_no_duplicate_bindings() {
+ let map = default_keymap();
+ let mut keys: Vec<_> = map.iter().map(|(k, _)| k.clone()).collect();
+ keys.sort();
+ let unique = keys.len();
+ keys.dedup();
+ assert_eq!(keys.len(), unique, "hay un atajo repetido");
+ }
+
+ #[test]
+ fn keymap_covers_every_virtual_workspace() {
+ let map = default_keymap();
+ for n in 0..WORKSPACE_COUNT {
+ assert!(map
+ .iter()
+ .any(|(_, a)| a == &DesktopAction::SwitchWorkspace(n)));
+ assert!(map
+ .iter()
+ .any(|(_, a)| a == &DesktopAction::SendToWorkspace(n)));
+ }
+ }
+
+ #[test]
+ fn every_default_action_round_trips_through_its_text_form() {
+ for (_, action) in default_keymap() {
+ let text = action.to_string();
+ let back: DesktopAction = text.parse().unwrap();
+ assert_eq!(action, back, "no redondea: {text}");
+ }
+ }
+
+ #[test]
+ fn every_layout_mode_round_trips() {
+ for mode in LayoutMode::ALL {
+ let a = DesktopAction::SetLayout(mode);
+ assert_eq!(a, a.to_string().parse().unwrap());
+ }
+ }
+
+ #[test]
+ fn workspace_actions_are_one_based_in_text() {
+ assert_eq!(DesktopAction::SwitchWorkspace(0).to_string(), "workspace:1");
+ assert_eq!(
+ "workspace:1".parse::<DesktopAction>().unwrap(),
+ DesktopAction::SwitchWorkspace(0)
+ );
+ assert_eq!(
+ "send-to-workspace:9".parse::<DesktopAction>().unwrap(),
+ DesktopAction::SendToWorkspace(8)
+ );
+ }
+
+ #[test]
+ fn out_of_range_or_unknown_actions_are_rejected() {
+ assert!("workspace:0".parse::<DesktopAction>().is_err());
+ assert!("workspace:99".parse::<DesktopAction>().is_err());
+ assert!("layout:fractal".parse::<DesktopAction>().is_err());
+ assert!("focus-window:abc".parse::<DesktopAction>().is_err());
+ assert!("teleport".parse::<DesktopAction>().is_err());
+ }
+
+ #[test]
+ fn directional_actions_round_trip_in_every_direction() {
+ for d in [Direction::Left, Direction::Right, Direction::Up, Direction::Down] {
+ for a in [
+ DesktopAction::FocusDir(d),
+ DesktopAction::MoveDir(d),
+ DesktopAction::FocusOutputDir(d),
+ DesktopAction::SendToOutputDir(d),
+ DesktopAction::ResizeFloatDir(d),
+ ] {
+ let text = a.to_string();
+ assert_eq!(text.parse::<DesktopAction>().unwrap(), a, "no redondea: {text}");
+ }
+ }
+ }
+
+ #[test]
+ fn focus_window_round_trips_with_its_id() {
+ let a = DesktopAction::FocusWindow(42);
+ assert_eq!(a.to_string(), "focus-window:42");
+ assert_eq!("focus-window:42".parse::<DesktopAction>().unwrap(), a);
+ }
+
+ #[test]
+ fn spawn_round_trips_keeping_the_whole_command() {
+ let a = DesktopAction::Spawn("foot --title diario".into());
+ assert_eq!(a.to_string(), "spawn:foot --title diario");
+ assert_eq!(a.to_string().parse::<DesktopAction>().unwrap(), a);
+ }
+
+ #[test]
+ fn spawn_without_a_command_is_rejected() {
+ assert!("spawn:".parse::<DesktopAction>().is_err());
+ assert!("spawn: ".parse::<DesktopAction>().is_err());
+ }
+}
diff --git a/02_ruway/mirada/mirada-brain/src/config.rs b/02_ruway/mirada/mirada-brain/src/config.rs
new file mode 100644
index 0000000..f47d331
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/config.rs
@@ -0,0 +1,843 @@
+//! Config general del WM — los ajustes que no son atajos ([`crate::keymap`])
+//! ni reglas de ventana ([`crate::rules`]): el comando de la terminal
+//! dropdown, la geometría del cajón quake, los parámetros iniciales del
+//! teselado y si el foco sigue al puntero.
+//!
+//! Mismo patrón que keymap/rules: RON de texto en
+//! `~/.config/mirada/config.ron`, leído una vez al arrancar y aplicado al
+//! [`Desktop`](crate::Desktop). Si el archivo no existe se escribe una
+//! plantilla documentada y se usan los defaults; si está corrupto, se
+//! avisa y se cae a los defaults.
+
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+use mirada_layout::{Disposicion, LayoutMode, LayoutParams, WallpaperFit};
+use mirada_protocol::Decorations;
+
+/// `app_id` con el que se marca y reconoce la terminal dropdown (quake).
+/// El comando configurable [`Config::dropterm_cmd`] **debe** fijar este
+/// `app_id` (con `kitty --class`, `foot --app-id`, etc.) o el Cerebro no
+/// la reconocerá al abrirse.
+pub const DROPTERM_APP_ID: &str = "mirada.dropterm";
+
+/// El comando por defecto de la terminal dropdown. `kitty --class` fija el
+/// `app_id` en Wayland, que es como se la reconoce.
+const DEFAULT_DROPTERM_CMD: &str = "kitty --class mirada.dropterm";
+
+/// (De)serializa un [`LayoutMode`] como su `slug` de cadena (`"grid"`,
+/// `"master-stack"`, …), reusando el vocabulario de [`crate::action`].
+mod layout_slug_serde {
+ use mirada_layout::LayoutMode;
+ use serde::{Deserialize, Deserializer, Serializer};
+
+ pub fn serialize<S: Serializer>(mode: &LayoutMode, s: S) -> Result<S::Ok, S::Error> {
+ s.serialize_str(crate::action::layout_slug(*mode))
+ }
+
+ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<LayoutMode, D::Error> {
+ let slug = String::deserialize(d)?;
+ crate::action::layout_from_slug(&slug).ok_or_else(|| {
+ serde::de::Error::custom(format!(
+ "modo de teselado desconocido «{slug}» (usa master-stack, centered-master, \
+ spiral, grid, columns, rows o monocle)"
+ ))
+ })
+ }
+}
+
+/// (De)serializa un [`WallpaperFit`] como su slug en kebab-case (`"stretch"`,
+/// `"fit"`, `"fill"`, `"center"`, `"tile"`). El derive `serde` del propio
+/// enum produce identificadores RON desnudos, incompatibles con la forma
+/// quoteada que escribimos en la plantilla.
+mod wallpaper_fit_slug_serde {
+ use mirada_layout::WallpaperFit;
+ use serde::{Deserialize, Deserializer, Serializer};
+
+ pub fn serialize<S: Serializer>(fit: &WallpaperFit, s: S) -> Result<S::Ok, S::Error> {
+ s.serialize_str(fit.slug())
+ }
+
+ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<WallpaperFit, D::Error> {
+ let slug = String::deserialize(d)?;
+ WallpaperFit::from_slug(&slug).ok_or_else(|| {
+ serde::de::Error::custom(format!(
+ "modo de wallpaper desconocido «{slug}» (usa stretch, fit, fill, center o tile)"
+ ))
+ })
+ }
+}
+
+/// Los ajustes del escritorio que el usuario puede configurar sin tocar el
+/// keymap ni las reglas.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Config {
+ /// Comando que lanza la terminal dropdown (quake). Debe fijar el
+ /// `app_id` [`DROPTERM_APP_ID`] para que el Cerebro la reconozca.
+ pub dropterm_cmd: String,
+ /// Alto del cajón dropdown como porcentaje (`1..=100`) del alto de la
+ /// salida; baja anclado arriba a todo el ancho.
+ pub dropterm_height_pct: u32,
+ /// Modo de teselado inicial de cada escritorio. En RON va como cadena
+ /// con su `slug` (`"master-stack"`, `"grid"`, …): los guiones no son
+ /// identificadores válidos para un enum sin comillas.
+ #[serde(with = "layout_slug_serde")]
+ pub layout: LayoutMode,
+ /// Margen en píxeles alrededor de cada ventana teselada.
+ pub gap: i32,
+ /// Fracción del ancho de la ventana maestra (se acota a `0.05..=0.95`).
+ pub master_ratio: f32,
+ /// Cuántas ventanas van en el área maestra (`nmaster`; al menos 1).
+ pub master_count: usize,
+ /// Paso al agrandar/encoger el área maestra (`grow-master`/`shrink-master`).
+ /// Más chico = control más fino. Se acota al rango útil.
+ pub master_step: f32,
+ /// Paso en px al mover o redimensionar una ventana flotante por teclado.
+ pub float_step: i32,
+ /// El foco del teclado sigue al puntero, sin necesidad de click.
+ pub focus_follows_mouse: bool,
+ /// Grosor del marco de ventana en píxeles; `0` = sin marco.
+ pub border_width: i32,
+ /// Color RGBA (`0..=255`) del marco de la ventana enfocada.
+ pub border_focus: [u8; 4],
+ /// Color RGBA (`0..=255`) del marco de las ventanas sin foco.
+ pub border_normal: [u8; 4],
+ /// Alto de la barra de título en px; `0` = sin barra (sólo el título de la
+ /// ventana enfocada superpuesto). Se reserva arriba de cada ventana.
+ pub titlebar_height: i32,
+ /// Ruta a la fuente para las etiquetas del compositor (título, menú).
+ /// Vacía = se prueba una lista de fuentes comunes del sistema.
+ pub font_path: String,
+ /// Ruta a la imagen de fondo del escritorio (PNG/JPEG/WebP). Vacía =
+ /// color sólido. Su colocación dentro de la salida la dicta
+ /// [`Self::wallpaper_fit`].
+ pub wallpaper_path: String,
+ /// Cómo se ajusta el wallpaper a la salida: `stretch` (deforma para cubrir),
+ /// `fit` (entra entero con barras), `fill` (cubre y recorta), `center`
+ /// (tamaño nativo centrado) o `tile` (repetido). En RON va como cadena
+ /// kebab-case: `"stretch"`, `"fit"`, `"fill"`, `"center"`, `"tile"`.
+ #[serde(with = "wallpaper_fit_slug_serde")]
+ pub wallpaper_fit: WallpaperFit,
+ /// Entradas del menú raíz (estilo openbox) que aparece al click derecho
+ /// sobre el fondo. Vacío = sin menú (el click derecho en el fondo no hace
+ /// nada). Cada entrada lanza su `command` con `sh -c`.
+ pub menu: Vec<MenuEntry>,
+ /// Zonas de la pantalla (fracciones `0..=1`): **blancos de arrastre**.
+ /// Al arrastrar una ventana sobre una zona, el compositor la resalta; al
+ /// soltarla encima, la ancla a ese rect (flotante). Soltarla fuera de toda
+ /// zona la deja flotando donde cae (overflow). Vacío = sin zonas. Es el
+ /// primer preset; `mirada-ctl cycle-zones` cicla a los de [`Self::zone_presets`].
+ pub zones: Vec<ZoneCfg>,
+ /// Presets adicionales de zonas. `mirada-ctl cycle-zones` (bindeable a un
+ /// atajo) cicla `zones → preset 0 → preset 1 → … → zones`. Cada preset es
+ /// una lista de zonas como [`Self::zones`].
+ pub zone_presets: Vec<Vec<ZoneCfg>>,
+ /// Cómo se reparten los monitores en el escritorio global cuando hay más
+ /// de uno: `"horizontal"` (uno al lado del otro, default) o `"vertical"`
+ /// (uno encima del otro). El orden lo dicta [`OutputOverride::order`].
+ /// Mismo vocabulario que [`mirada_layout::Disposicion`].
+ pub output_direction: String,
+ /// Overrides por salida (monitor). Cada entrada se identifica por el
+ /// `name` del conector DRM (`HDMI-A-1`, `DP-1`, …) y puede sobreescribir
+ /// el wallpaper, su modo de ajuste y el orden de la salida en el
+ /// escritorio compuesto. Lo que no se indique cae al valor global.
+ /// Vacío = orden de discovery, wallpaper global para todas.
+ pub outputs: Vec<OutputOverride>,
+}
+
+/// Ajustes específicos de una salida (monitor) — se aplican sólo a la salida
+/// cuyo nombre coincide. Hoy alcanzan el fondo del escritorio: imagen y modo
+/// de ajuste. Lo que se deja vacío (`""`) cae al valor global.
+///
+/// El `name` es el nombre del conector como lo reporta el backend DRM en sus
+/// logs de arranque: `HDMI-A-1`, `DP-1`, `eDP-1`, … (mayúsculas y guiones).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct OutputOverride {
+ /// Nombre del conector DRM al que se aplica este override.
+ pub name: String,
+ /// Wallpaper específico de esta salida. Vacío = usa el global.
+ #[serde(default)]
+ pub wallpaper_path: String,
+ /// Ajuste del wallpaper específico para esta salida. Vacío = usa el
+ /// global. Mismo vocabulario que [`Config::wallpaper_fit`] (`"stretch"`,
+ /// `"fit"`, `"fill"`, `"center"`, `"tile"`). Se guarda como slug en vez
+ /// de [`WallpaperFit`] para que en RON quepa una cadena desnuda y
+ /// `""` sirva como ausente — el `Option<WallpaperFit>` exigiría
+ /// `Some("fill")` / `None`, ruido innecesario en la config.
+ #[serde(default)]
+ pub wallpaper_fit: String,
+ /// Orden de esta salida en el escritorio compuesto: las salidas se
+ /// disponen ordenadas crecientemente por `(order, name)`. La de menor
+ /// `order` queda **primaria** (origen `(0, 0)`). Default `0` — entonces
+ /// el desempate por `name` da un orden estable, predecible y reproducible
+ /// (sin override, todas son `0` y mandan los nombres alfabéticamente).
+ #[serde(default)]
+ pub order: i32,
+ /// Escala HiDPI en 120-avos: `120` = 100 %, `180` = 150 %, `240` = 200 %.
+ /// Misma convención que `wp_fractional_scale` de Wayland y que
+ /// [`mirada_layout::ESCALA_100`]. Vale `0` (default) = sin override → la
+ /// salida se anuncia a 100 % nativo. Valores `<= 0` se ignoran.
+ #[serde(default)]
+ pub scale_120: u32,
+ /// Rotación / espejado del scanout. Slugs: `"normal"` (default si vacío),
+ /// `"90"`, `"180"`, `"270"`, `"flipped"`, `"flipped-90"`, `"flipped-180"`,
+ /// `"flipped-270"`. Validado al cargar la config (`from_ron`); el
+ /// compositor lo parsea a su `Transform` al usar.
+ #[serde(default)]
+ pub transform: String,
+}
+
+/// Parsea el slug de [`Config::output_direction`] a [`Disposicion`].
+fn parse_disposition(slug: &str) -> Option<Disposicion> {
+ match slug {
+ "horizontal" => Some(Disposicion::Horizontal),
+ "vertical" => Some(Disposicion::Vertical),
+ _ => None,
+ }
+}
+
+/// Slugs válidos para [`OutputOverride::transform`]. Mismo orden que la enum
+/// `Transform` de smithay (Normal / 90 / 180 / 270 / Flipped / Flipped90 /
+/// Flipped180 / Flipped270). El consumidor (drm_backend) hace el match a su
+/// tipo; acá sólo validamos.
+pub const TRANSFORM_SLUGS: &[&str] = &[
+ "normal",
+ "90",
+ "180",
+ "270",
+ "flipped",
+ "flipped-90",
+ "flipped-180",
+ "flipped-270",
+];
+
+/// `true` si `slug` es un valor reconocido de [`OutputOverride::transform`].
+/// Vacío (`""`) cuenta como ausente y es válido — significa «sin override».
+pub fn is_valid_transform_slug(slug: &str) -> bool {
+ slug.is_empty() || TRANSFORM_SLUGS.contains(&slug)
+}
+
+impl OutputOverride {
+ /// El `wallpaper_fit` parseado, si la cadena no está vacía. `None` =
+ /// no se setea (el llamante debe caer al global). `Err` si la cadena
+ /// trae un slug desconocido — se propaga al cargar la config.
+ fn parsed_wallpaper_fit(&self) -> Result<Option<WallpaperFit>, String> {
+ if self.wallpaper_fit.is_empty() {
+ return Ok(None);
+ }
+ WallpaperFit::from_slug(&self.wallpaper_fit)
+ .map(Some)
+ .ok_or_else(|| {
+ format!(
+ "modo de wallpaper desconocido «{}» en outputs[name=\"{}\"] (usa stretch, fit, fill, center o tile)",
+ self.wallpaper_fit, self.name
+ )
+ })
+ }
+}
+
+/// Una zona: `(x, y, w, h)` en fracciones `0..=1` de la pantalla. El `name` es
+/// opcional, sólo una etiqueta para tu propia referencia (no se pinta).
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ZoneCfg {
+ #[serde(default)]
+ pub name: String,
+ pub x: f32,
+ pub y: f32,
+ pub w: f32,
+ pub h: f32,
+}
+
+/// Una entrada del menú raíz. Es una **hoja** que lanza `command`, o un
+/// **submenú** si trae `submenu` no vacío (en ese caso `command` se ignora).
+/// La forma plana `(label, command)` sigue siendo válida: `submenu` default
+/// vacío. Anidan a cualquier profundidad.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct MenuEntry {
+ pub label: String,
+ /// Comando a lanzar (`sh -c`) si es hoja. Ignorado si hay `submenu`.
+ #[serde(default)]
+ pub command: String,
+ /// Entradas hijas; no vacío = esta entrada es un submenú.
+ #[serde(default)]
+ pub submenu: Vec<MenuEntry>,
+}
+
+impl Default for Config {
+ fn default() -> Self {
+ let lp = LayoutParams::default();
+ let dec = Decorations::default();
+ Self {
+ dropterm_cmd: DEFAULT_DROPTERM_CMD.to_string(),
+ dropterm_height_pct: 45,
+ layout: lp.mode,
+ gap: lp.gap,
+ master_ratio: lp.master_ratio,
+ master_count: lp.master_count,
+ master_step: 0.05,
+ float_step: 40,
+ focus_follows_mouse: true,
+ border_width: dec.border_width,
+ border_focus: dec.border_focus,
+ border_normal: dec.border_normal,
+ titlebar_height: dec.titlebar_height,
+ font_path: String::new(),
+ wallpaper_path: String::new(),
+ wallpaper_fit: WallpaperFit::default(),
+ menu: Vec::new(),
+ zones: Vec::new(),
+ zone_presets: Vec::new(),
+ output_direction: "horizontal".to_string(),
+ outputs: Vec::new(),
+ }
+ }
+}
+
+impl Config {
+ /// El paso del área maestra, acotado a un rango útil (`0.01..=0.5`).
+ pub fn master_step(&self) -> f32 {
+ self.master_step.clamp(0.01, 0.5)
+ }
+
+ /// El paso en px para mover/redimensionar flotantes, al menos `1`.
+ pub fn float_step(&self) -> i32 {
+ self.float_step.max(1)
+ }
+
+ /// El alto del dropdown acotado a `1..=100`, listo para multiplicar.
+ pub fn dropterm_height_pct(&self) -> i32 {
+ self.dropterm_height_pct.clamp(1, 100) as i32
+ }
+
+ /// Los parámetros de decoración que derivan de la config (marco, …),
+ /// con el grosor acotado a `>= 0`.
+ pub fn decorations(&self) -> Decorations {
+ Decorations {
+ border_width: self.border_width.max(0),
+ border_focus: self.border_focus,
+ border_normal: self.border_normal,
+ titlebar_height: self.titlebar_height.max(0),
+ }
+ }
+
+ /// La dirección de disposición de las salidas en el escritorio compuesto.
+ /// Default `Horizontal` si el slug no se reconoce — el chequeo duro se
+ /// hace al cargar la config (ver [`Self::from_ron`]).
+ pub fn output_disposition(&self) -> Disposicion {
+ parse_disposition(&self.output_direction).unwrap_or(Disposicion::Horizontal)
+ }
+
+ /// El `order` configurado para la salida `name` — `0` si no hay override.
+ pub fn output_order_for(&self, name: &str) -> i32 {
+ self.outputs
+ .iter()
+ .find(|o| o.name == name)
+ .map(|o| o.order)
+ .unwrap_or(0)
+ }
+
+ /// Escala HiDPI en 120-avos a usar para la salida `name`: el override si
+ /// existe y es positivo; si no, `120` (100 % nativo, [`mirada_layout::ESCALA_100`]).
+ pub fn output_scale_120_for(&self, name: &str) -> u32 {
+ for o in &self.outputs {
+ if o.name == name && o.scale_120 > 0 {
+ return o.scale_120;
+ }
+ }
+ mirada_layout::ESCALA_100 as u32
+ }
+
+ /// Slug de transformación a usar para la salida `name`: el override si
+ /// existe y es no vacío; si no, `"normal"`. Vocabulario en
+ /// [`TRANSFORM_SLUGS`]. Un slug inválido se ignora silenciosamente —
+ /// el chequeo duro se hace al cargar la config (ver [`Self::from_ron`]).
+ pub fn output_transform_for(&self, name: &str) -> &str {
+ for o in &self.outputs {
+ if o.name == name && is_valid_transform_slug(&o.transform) && !o.transform.is_empty() {
+ return &o.transform;
+ }
+ }
+ "normal"
+ }
+
+ /// La ruta del wallpaper a usar para la salida `name`. Si hay un override
+ /// en [`Self::outputs`] con `wallpaper_path` no vacío para esa salida, se
+ /// usa esa; si no, cae al global [`Self::wallpaper_path`]. Vacía = fondo
+ /// de color sólido.
+ pub fn wallpaper_path_for(&self, name: &str) -> &str {
+ for o in &self.outputs {
+ if o.name == name && !o.wallpaper_path.is_empty() {
+ return &o.wallpaper_path;
+ }
+ }
+ &self.wallpaper_path
+ }
+
+ /// El modo de ajuste del wallpaper para la salida `name`. Si hay un
+ /// override en [`Self::outputs`] con `wallpaper_fit` no vacío para esa
+ /// salida, se usa ese; si no, cae al global [`Self::wallpaper_fit`].
+ /// Un slug inválido en el override se ignora silenciosamente — el chequeo
+ /// duro se hace al cargar la config (ver [`Self::from_ron`]).
+ pub fn wallpaper_fit_for(&self, name: &str) -> WallpaperFit {
+ for o in &self.outputs {
+ if o.name == name {
+ if let Ok(Some(f)) = o.parsed_wallpaper_fit() {
+ return f;
+ }
+ }
+ }
+ self.wallpaper_fit
+ }
+
+ /// Los parámetros de teselado iniciales que derivan de la config, ya
+ /// acotados — lo que se le da a cada escritorio al arrancar.
+ pub fn layout_params(&self) -> LayoutParams {
+ LayoutParams {
+ mode: self.layout,
+ master_ratio: self.master_ratio.clamp(0.05, 0.95),
+ master_count: self.master_count.max(1),
+ gap: self.gap.max(0),
+ }
+ }
+
+ /// Parsea la config desde el texto RON de un archivo. Valida también que
+ /// los slugs de overrides sean conocidos —`wallpaper_fit` de cada
+ /// [`OutputOverride`] y el [`Self::output_direction`] global— para que un
+ /// typo (ej. `"marciano"`) se rechace acá con un mensaje claro, en vez
+ /// de ignorarse en silencio al pintar.
+ pub fn from_ron(text: &str) -> Result<Config, String> {
+ let cfg: Config = ron::from_str(text).map_err(|e| format!("RON inválido: {e}"))?;
+ for o in &cfg.outputs {
+ o.parsed_wallpaper_fit()?;
+ if !is_valid_transform_slug(&o.transform) {
+ return Err(format!(
+ "transform desconocido «{}» en outputs[name=\"{}\"] (usa {})",
+ o.transform,
+ o.name,
+ TRANSFORM_SLUGS.join(", ")
+ ));
+ }
+ }
+ if parse_disposition(&cfg.output_direction).is_none() {
+ return Err(format!(
+ "output_direction desconocido «{}» (usa horizontal o vertical)",
+ cfg.output_direction
+ ));
+ }
+ Ok(cfg)
+ }
+
+ /// La ruta canónica de la config: `~/.config/mirada/config.ron`.
+ pub fn default_path() -> Option<PathBuf> {
+ directories::ProjectDirs::from("", "", "mirada")
+ .map(|d| d.config_dir().join("config.ron"))
+ }
+
+ /// Carga la config de un archivo RON.
+ pub fn load(path: &Path) -> Result<Config, String> {
+ let text = std::fs::read_to_string(path).map_err(|e| format!("E/S: {e}"))?;
+ Config::from_ron(&text)
+ }
+
+ /// Vigila el archivo de config para recargarlo en caliente.
+ pub fn watch(path: &Path) -> notify::Result<crate::watch::FileWatch> {
+ crate::watch::FileWatch::new(path)
+ }
+
+ /// Carga la config del usuario con un fallback amable: si el archivo no
+ /// existe, escribe una plantilla documentada y devuelve los defaults; si
+ /// está corrupto, avisa y devuelve los defaults.
+ pub fn load_or_default(path: &Path) -> Config {
+ if path.exists() {
+ match Config::load(path) {
+ Ok(c) => c,
+ Err(e) => {
+ eprintln!(
+ "mirada · config «{}» inválida ({e}); uso los valores por defecto.",
+ path.display()
+ );
+ Config::default()
+ }
+ }
+ } else {
+ if let Some(dir) = path.parent() {
+ let _ = std::fs::create_dir_all(dir);
+ }
+ match std::fs::write(path, CONFIG_TEMPLATE) {
+ Ok(()) => eprintln!("mirada · plantilla de config escrita en {}", path.display()),
+ Err(e) => eprintln!("mirada · no pude escribir la plantilla de config: {e}"),
+ }
+ Config::default()
+ }
+ }
+}
+
+/// La plantilla que se escribe la primera vez: los defaults explícitos, con
+/// comentarios. Editarla cambia el comportamiento al reiniciar mirada.
+const CONFIG_TEMPLATE: &str = "\
+// Config de mirada — ajustes del escritorio que no son atajos (keymap.ron)
+// ni reglas de ventana (rules.ron). Reinicia mirada para aplicar cambios.
+(
+ // Comando de la terminal dropdown (quake), que Super+grave despliega.
+ // DEBE fijar el app_id `mirada.dropterm` para que mirada la reconozca:
+ // kitty --class mirada.dropterm · foot --app-id mirada.dropterm
+ dropterm_cmd: \"kitty --class mirada.dropterm\",
+ // Alto del cajón dropdown, en % del alto de pantalla (baja desde arriba).
+ dropterm_height_pct: 45,
+
+ // Teselado inicial de cada escritorio.
+ // master-stack · centered-master · spiral · grid · columns · rows · monocle
+ layout: \"master-stack\",
+ gap: 8, // margen en px alrededor de cada ventana
+ master_ratio: 0.6, // fracción de ancho de la ventana maestra
+ master_count: 1, // cuántas ventanas en el área maestra
+ master_step: 0.05, // paso de grow/shrink-master (más chico = más fino)
+ float_step: 40, // paso en px para mover/redimensionar flotantes por teclado
+
+ // El foco del teclado sigue al puntero (sin click). false = foco al clickear.
+ focus_follows_mouse: true,
+
+ // Marco de ventana. Colores RGBA en 0..=255; border_width: 0 = sin marco.
+ border_width: 2,
+ border_focus: (92, 143, 235, 255), // azul al foco
+ border_normal: (56, 56, 69, 255), // gris discreto sin foco
+ // Barra de título sobre cada ventana (px). 0 = sin barra (sólo el título
+ // de la ventana enfocada, superpuesto). La franja se reserva arriba.
+ titlebar_height: 24,
+
+ // Fuente para las etiquetas (título, menú). Vacía = se prueba una lista
+ // de fuentes comunes del sistema (Liberation, DejaVu, Noto, Adwaita…).
+ font_path: \"\",
+
+ // Imagen de fondo del escritorio (PNG/JPEG/WebP). Vacía = color sólido.
+ // Ej: \"/home/yo/.config/mirada/fondo.png\".
+ wallpaper_path: \"\",
+ // Cómo encaja la imagen en la salida:
+ // stretch — deforma para cubrir exactamente (default).
+ // fit — la imagen entra entera, con barras negras (letterbox).
+ // fill — la imagen cubre la salida, los bordes se recortan.
+ // center — tamaño nativo centrado (padding negro o recorte si es grande).
+ // tile — repetida en su tamaño nativo desde la esquina superior-izquierda.
+ wallpaper_fit: \"stretch\",
+
+ // Menú raíz (estilo openbox): aparece al click DERECHO sobre el fondo.
+ // Vacío = sin menú. Una entrada es hoja (lanza command con `sh -c`) o
+ // submenú (si trae `submenu`, anidable a cualquier profundidad). Ej:
+ // menu: [
+ // (label: \"Terminal\", command: \"kitty\"),
+ // (label: \"Apps\", submenu: [
+ // (label: \"Navegador\", command: \"firefox\"),
+ // (label: \"Editores\", submenu: [
+ // (label: \"nada\", command: \"nada\"),
+ // ]),
+ // ]),
+ // ],
+ menu: [],
+
+ // Zonas: blancos de arrastre (fracciones 0..=1 de la pantalla). Al arrastrar
+ // una ventana sobre una zona se resalta; al soltarla encima, aterriza en ese
+ // rect; soltarla fuera la deja flotando donde cae (overflow). El `name` es
+ // opcional (sólo tu referencia). Vacío = sin zonas. Ej (media/cuartos):
+ // zones: [
+ // (x: 0.0, y: 0.0, w: 0.5, h: 1.0),
+ // (x: 0.5, y: 0.0, w: 0.5, h: 0.5),
+ // (x: 0.5, y: 0.5, w: 0.5, h: 0.5),
+ // ],
+ zones: [],
+
+ // Presets adicionales de zonas. `mirada-ctl cycle-zones` (bindealo a un
+ // atajo) cicla zones → preset 0 → preset 1 → … → zones. Ej:
+ // zone_presets: [
+ // [ (x: 0.0, y: 0.0, w: 0.5, h: 1.0), (x: 0.5, y: 0.0, w: 0.5, h: 1.0) ],
+ // [ (x: 0.0, y: 0.0, w: 1.0, h: 1.0) ],
+ // ],
+ zone_presets: [],
+
+ // Cómo se reparten los monitores en el escritorio global cuando hay más
+ // de uno: \"horizontal\" (uno al lado del otro) o \"vertical\" (encima).
+ output_direction: \"horizontal\",
+
+ // Overrides por salida (monitor). Cada entrada identifica el conector
+ // DRM por su `name` (ej. \"HDMI-A-1\", \"DP-1\", \"eDP-1\"; sale en los
+ // logs de arranque del compositor). Sobreescribe wallpaper + orden +
+ // escala HiDPI + transformación de la salida. Lo que se deja vacío
+ // cae al global. La salida con `order` más chico queda primaria
+ // (origen 0,0). `scale_120` en 120-avos (120=100%, 180=150%, 240=200%).
+ // `transform`: normal / 90 / 180 / 270 / flipped / flipped-90 /
+ // flipped-180 / flipped-270. Vacío = orden alfabético, sin overrides. Ej:
+ // outputs: [
+ // (name: \"DP-1\", order: 0, scale_120: 240,
+ // wallpaper_path: \"/home/yo/fondos/code.png\",
+ // wallpaper_fit: \"fill\"),
+ // (name: \"HDMI-A-1\", order: 1, transform: \"90\",
+ // wallpaper_path: \"/home/yo/fondos/sala.png\"),
+ // ],
+ outputs: [],
+)
+";
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_template_parses_to_the_defaults() {
+ assert_eq!(Config::from_ron(CONFIG_TEMPLATE).unwrap(), Config::default());
+ }
+
+ #[test]
+ fn omitted_fields_fall_back_to_defaults() {
+ let c = Config::from_ron("( gap: 20 )").unwrap();
+ assert_eq!(c.gap, 20);
+ // El resto queda en su default.
+ assert_eq!(c.dropterm_cmd, Config::default().dropterm_cmd);
+ assert!(c.focus_follows_mouse);
+ }
+
+ #[test]
+ fn layout_params_clamp_out_of_range_values() {
+ let c = Config::from_ron("( master_ratio: 2.0, master_count: 0, gap: -5 )").unwrap();
+ let lp = c.layout_params();
+ assert_eq!(lp.master_ratio, 0.95);
+ assert_eq!(lp.master_count, 1);
+ assert_eq!(lp.gap, 0);
+ }
+
+ #[test]
+ fn dropterm_height_is_clamped() {
+ let c = Config::from_ron("( dropterm_height_pct: 250 )").unwrap();
+ assert_eq!(c.dropterm_height_pct(), 100);
+ let c = Config::from_ron("( dropterm_height_pct: 0 )").unwrap();
+ assert_eq!(c.dropterm_height_pct(), 1);
+ }
+
+ #[test]
+ fn decorations_derive_from_the_config_and_clamp_width() {
+ let c = Config::from_ron(
+ "( border_width: -3, border_focus: (10, 20, 30, 255), border_normal: (1, 2, 3, 4) )",
+ )
+ .unwrap();
+ let d = c.decorations();
+ assert_eq!(d.border_width, 0); // acotado a >= 0
+ assert_eq!(d.border_focus, [10, 20, 30, 255]);
+ assert_eq!(d.border_normal, [1, 2, 3, 4]);
+ }
+
+ #[test]
+ fn default_decorations_match_the_protocol_default() {
+ assert_eq!(Config::default().decorations(), Decorations::default());
+ }
+
+ #[test]
+ fn the_layout_mode_parses_from_its_slug_string() {
+ let c = Config::from_ron(r#"( layout: "centered-master" )"#).unwrap();
+ assert_eq!(c.layout, LayoutMode::CenteredMaster);
+ }
+
+ #[test]
+ fn an_unknown_layout_slug_is_rejected() {
+ assert!(Config::from_ron(r#"( layout: "tetris" )"#).is_err());
+ }
+
+ #[test]
+ fn menu_flat_entries_parse_without_submenu() {
+ let c = Config::from_ron(
+ r#"( menu: [(label: "Terminal", command: "kitty")] )"#,
+ )
+ .unwrap();
+ assert_eq!(c.menu.len(), 1);
+ assert_eq!(c.menu[0].label, "Terminal");
+ assert_eq!(c.menu[0].command, "kitty");
+ assert!(c.menu[0].submenu.is_empty());
+ }
+
+ #[test]
+ fn zones_parsean_con_nombre_opcional() {
+ let c = Config::from_ron(
+ r#"( zones: [
+ (x: 0.0, y: 0.0, w: 0.6, h: 1.0),
+ (name: "chat", x: 0.6, y: 0.0, w: 0.4, h: 1.0),
+ ] )"#,
+ )
+ .unwrap();
+ assert_eq!(c.zones.len(), 2);
+ assert_eq!(c.zones[0].name, ""); // name es opcional
+ assert!((c.zones[0].w - 0.6).abs() < 1e-6);
+ assert_eq!(c.zones[1].name, "chat");
+ }
+
+ #[test]
+ fn wallpaper_fit_parsea_de_su_slug() {
+ for (slug, want) in [
+ ("stretch", WallpaperFit::Stretch),
+ ("fit", WallpaperFit::Fit),
+ ("fill", WallpaperFit::Fill),
+ ("center", WallpaperFit::Center),
+ ("tile", WallpaperFit::Tile),
+ ] {
+ let c = Config::from_ron(&format!(r#"( wallpaper_fit: "{slug}" )"#)).unwrap();
+ assert_eq!(c.wallpaper_fit, want);
+ }
+ }
+
+ #[test]
+ fn wallpaper_fit_default_es_stretch() {
+ assert_eq!(Config::default().wallpaper_fit, WallpaperFit::Stretch);
+ }
+
+ #[test]
+ fn output_override_aplica_su_wallpaper_solo_al_monitor_nombrado() {
+ let c = Config::from_ron(
+ r#"( wallpaper_path: "global.png", outputs: [
+ (name: "HDMI-A-1", wallpaper_path: "sala.png"),
+ (name: "DP-1", wallpaper_path: "code.png", wallpaper_fit: "fill"),
+ ] )"#,
+ )
+ .unwrap();
+ assert_eq!(c.wallpaper_path_for("HDMI-A-1"), "sala.png");
+ assert_eq!(c.wallpaper_fit_for("HDMI-A-1"), WallpaperFit::Stretch);
+ assert_eq!(c.wallpaper_path_for("DP-1"), "code.png");
+ assert_eq!(c.wallpaper_fit_for("DP-1"), WallpaperFit::Fill);
+ // Salida sin override cae al global.
+ assert_eq!(c.wallpaper_path_for("eDP-1"), "global.png");
+ assert_eq!(c.wallpaper_fit_for("eDP-1"), WallpaperFit::Stretch);
+ }
+
+ #[test]
+ fn output_override_con_path_vacio_no_pisa_al_global() {
+ // Un override con sólo `name` (path vacío) deja el wallpaper en el
+ // global — útil si sólo se quiere cambiar el `fit` del monitor.
+ let c = Config::from_ron(
+ r#"( wallpaper_path: "global.png", outputs: [
+ (name: "HDMI-A-1", wallpaper_fit: "fit"),
+ ] )"#,
+ )
+ .unwrap();
+ assert_eq!(c.wallpaper_path_for("HDMI-A-1"), "global.png");
+ assert_eq!(c.wallpaper_fit_for("HDMI-A-1"), WallpaperFit::Fit);
+ }
+
+ #[test]
+ fn output_override_rechaza_fit_desconocido() {
+ let err = Config::from_ron(
+ r#"( outputs: [ (name: "DP-1", wallpaper_fit: "marciano") ] )"#,
+ )
+ .unwrap_err();
+ assert!(err.contains("marciano"), "mensaje útil: {err}");
+ }
+
+ #[test]
+ fn output_direction_default_es_horizontal() {
+ let c = Config::default();
+ assert_eq!(c.output_disposition(), Disposicion::Horizontal);
+ }
+
+ #[test]
+ fn output_direction_parsea_vertical_y_horizontal() {
+ let c = Config::from_ron(r#"( output_direction: "vertical" )"#).unwrap();
+ assert_eq!(c.output_disposition(), Disposicion::Vertical);
+ let c = Config::from_ron(r#"( output_direction: "horizontal" )"#).unwrap();
+ assert_eq!(c.output_disposition(), Disposicion::Horizontal);
+ }
+
+ #[test]
+ fn output_direction_desconocido_es_rechazado() {
+ let err = Config::from_ron(r#"( output_direction: "diagonal" )"#).unwrap_err();
+ assert!(err.contains("diagonal"), "mensaje útil: {err}");
+ }
+
+ #[test]
+ fn output_order_for_cae_a_cero_sin_override() {
+ let c = Config::default();
+ assert_eq!(c.output_order_for("HDMI-A-1"), 0);
+ }
+
+ #[test]
+ fn output_scale_120_default_es_100_pct_si_no_hay_override() {
+ let c = Config::default();
+ assert_eq!(c.output_scale_120_for("HDMI-A-1"), 120);
+ }
+
+ #[test]
+ fn output_scale_120_lee_el_override() {
+ let c = Config::from_ron(
+ r#"( outputs: [
+ (name: "DP-1", scale_120: 240),
+ (name: "HDMI-A-1", scale_120: 0),
+ ] )"#,
+ )
+ .unwrap();
+ assert_eq!(c.output_scale_120_for("DP-1"), 240);
+ // `scale_120: 0` cuenta como sin override → 100 %.
+ assert_eq!(c.output_scale_120_for("HDMI-A-1"), 120);
+ // Salida sin entrada → 100 %.
+ assert_eq!(c.output_scale_120_for("eDP-1"), 120);
+ }
+
+ #[test]
+ fn output_transform_default_es_normal() {
+ let c = Config::default();
+ assert_eq!(c.output_transform_for("HDMI-A-1"), "normal");
+ }
+
+ #[test]
+ fn output_transform_lee_el_override() {
+ let c = Config::from_ron(
+ r#"( outputs: [
+ (name: "HDMI-A-1", transform: "90"),
+ (name: "DP-1", transform: "flipped-180"),
+ ] )"#,
+ )
+ .unwrap();
+ assert_eq!(c.output_transform_for("HDMI-A-1"), "90");
+ assert_eq!(c.output_transform_for("DP-1"), "flipped-180");
+ assert_eq!(c.output_transform_for("eDP-1"), "normal");
+ }
+
+ #[test]
+ fn output_transform_desconocido_es_rechazado() {
+ let err = Config::from_ron(
+ r#"( outputs: [ (name: "DP-1", transform: "diagonal") ] )"#,
+ )
+ .unwrap_err();
+ assert!(err.contains("diagonal"), "mensaje útil: {err}");
+ }
+
+ #[test]
+ fn output_order_for_lee_el_override() {
+ let c = Config::from_ron(
+ r#"( outputs: [
+ (name: "DP-1", order: 0),
+ (name: "HDMI-A-1", order: 5),
+ ] )"#,
+ )
+ .unwrap();
+ assert_eq!(c.output_order_for("HDMI-A-1"), 5);
+ assert_eq!(c.output_order_for("DP-1"), 0);
+ // Sin entrada cae a 0.
+ assert_eq!(c.output_order_for("eDP-1"), 0);
+ }
+
+ #[test]
+ fn menu_nested_submenus_parse() {
+ let c = Config::from_ron(
+ r#"( menu: [
+ (label: "Apps", submenu: [
+ (label: "Navegador", command: "firefox"),
+ (label: "Más", submenu: [
+ (label: "nada", command: "nada"),
+ ]),
+ ]),
+ ] )"#,
+ )
+ .unwrap();
+ assert_eq!(c.menu.len(), 1);
+ let apps = &c.menu[0];
+ assert_eq!(apps.label, "Apps");
+ assert_eq!(apps.submenu.len(), 2);
+ assert_eq!(apps.submenu[0].command, "firefox");
+ assert_eq!(apps.submenu[1].submenu[0].label, "nada");
+ }
+}
diff --git a/02_ruway/mirada/mirada-brain/src/ctl.rs b/02_ruway/mirada/mirada-brain/src/ctl.rs
new file mode 100644
index 0000000..ee0905b
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/ctl.rs
@@ -0,0 +1,229 @@
+//! `ctl` — el API de control externo del Cerebro.
+//!
+//! Mientras el keymap ([`crate::keymap`]) es la cara *configurable* de las
+//! acciones, este módulo es su cara *programable*: deja que otro proceso
+//! —un script, una taskbar, el binario `mirada-ctl`— dispare una
+//! [`DesktopAction`] o consulte el estado, sin tocar el teclado.
+//!
+//! Todo converge igualmente en `Desktop::apply`: una petición de control
+//! no es más que otro front-end del mismo embudo. El transporte es un
+//! socket Unix de petición/respuesta, con el marco `postcard` que ya usa
+//! [`mirada_protocol`]; `DesktopAction` viaja como enum serializado (no
+//! como cadena), así que el contrato es tipado de punta a punta.
+//!
+//! - El Cerebro abre un [`CtlServer`] y atiende [`CtlConn`]s en su bucle.
+//! - El cliente usa [`send_request`] — una petición, una respuesta, cierra.
+
+use std::io::{self, ErrorKind};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+use mirada_layout::WindowId;
+use mirada_protocol::{read_frame, write_frame};
+
+use crate::action::DesktopAction;
+
+/// Una orden de un cliente de control al Cerebro.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum CtlRequest {
+ /// Aplica una acción de escritorio — el equivalente a pulsar su atajo.
+ Do(DesktopAction),
+ /// Pide la lista de ventanas conocidas, en todos los escritorios.
+ ListWindows,
+ /// Cicla al siguiente conjunto de zonas de arrastre (presets de
+ /// `config.ron`). Lo atiende el Cuerpo (las zonas son suyas), no el Cerebro.
+ CycleZones,
+}
+
+/// La respuesta del Cerebro a un [`CtlRequest`].
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum CtlReply {
+ /// La orden se aplicó.
+ Ok,
+ /// La orden no se pudo aplicar; el motivo, para mostrar al usuario.
+ Error(String),
+ /// La lista pedida con [`CtlRequest::ListWindows`].
+ Windows(Vec<WindowLine>),
+}
+
+/// Una ventana en la vista de `mirada-ctl windows`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WindowLine {
+ /// Id de la ventana — el que se pasa a `focus-window:N`.
+ pub id: WindowId,
+ pub app_id: String,
+ pub title: String,
+ /// Escritorio virtual donde está (1-based); `0` = guardada en el
+ /// scratchpad, en ningún escritorio.
+ pub workspace: usize,
+ /// `true` si es la ventana enfocada del escritorio activo.
+ pub focused: bool,
+}
+
+/// La ruta del socket de control: `$XDG_RUNTIME_DIR/mirada-ctl.sock`, o
+/// el directorio temporal si esa variable no está.
+pub fn default_socket_path() -> PathBuf {
+ let dir = std::env::var_os("XDG_RUNTIME_DIR")
+ .map(PathBuf::from)
+ .unwrap_or_else(std::env::temp_dir);
+ dir.join("mirada-ctl.sock")
+}
+
+/// El extremo servidor del API de control — lo abre el dueño del
+/// [`Desktop`](crate::Desktop) (la app `mirada`, o `mirada-compositor`
+/// con el Cerebro embebido).
+pub struct CtlServer {
+ listener: UnixListener,
+ path: PathBuf,
+}
+
+impl CtlServer {
+ /// Abre el socket de control en `path`. Si ya hay un Cerebro vivo
+ /// escuchando ahí, falla; si encuentra un socket muerto (de un
+ /// compositor anterior), lo retira y se queda con él.
+ pub fn bind(path: &Path) -> io::Result<Self> {
+ if path.exists() {
+ if UnixStream::connect(path).is_ok() {
+ return Err(io::Error::new(
+ ErrorKind::AddrInUse,
+ "ya hay un Cerebro escuchando en el socket de control",
+ ));
+ }
+ let _ = std::fs::remove_file(path);
+ }
+ if let Some(dir) = path.parent() {
+ std::fs::create_dir_all(dir)?;
+ }
+ let listener = UnixListener::bind(path)?;
+ listener.set_nonblocking(true)?;
+ Ok(Self { listener, path: path.to_path_buf() })
+ }
+
+ /// Acepta una conexión pendiente sin bloquear. `None` si no hay
+ /// ninguna — pensado para llamarse cada vuelta del bucle de eventos.
+ pub fn poll(&self) -> Option<CtlConn> {
+ match self.listener.accept() {
+ Ok((stream, _)) => Some(CtlConn { stream }),
+ Err(_) => None,
+ }
+ }
+}
+
+impl Drop for CtlServer {
+ fn drop(&mut self) {
+ // Dejar el socket limpio para el próximo arranque.
+ let _ = std::fs::remove_file(&self.path);
+ }
+}
+
+/// Una conexión de control aceptada: una petición y una respuesta.
+pub struct CtlConn {
+ stream: UnixStream,
+}
+
+impl CtlConn {
+ /// Lee la petición del cliente (bloquea hasta el marco completo; es
+ /// uno solo y llega enseguida).
+ pub fn read_request(&mut self) -> io::Result<Option<CtlRequest>> {
+ self.stream.set_nonblocking(false)?;
+ read_frame(&mut self.stream)
+ }
+
+ /// Envía la respuesta. El cliente cierra al recibirla.
+ pub fn reply(&mut self, reply: &CtlReply) -> io::Result<()> {
+ write_frame(&mut self.stream, reply)
+ }
+}
+
+/// Envía una petición al Cerebro y espera su respuesta. Es el camino que
+/// usa el binario `mirada-ctl`: conecta, pregunta, cierra.
+pub fn send_request(path: &Path, request: &CtlRequest) -> io::Result<CtlReply> {
+ let mut stream = UnixStream::connect(path)?;
+ write_frame(&mut stream, request)?;
+ read_frame(&mut stream)?
+ .ok_or_else(|| io::Error::new(ErrorKind::UnexpectedEof, "el Cerebro cerró sin responder"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::thread;
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ /// Una ruta de socket única para un test (los sockets no se pueden
+ /// reabrir; cada test necesita la suya).
+ fn temp_socket(tag: &str) -> PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_nanos();
+ std::env::temp_dir().join(format!("mirada-ctl-test-{tag}-{nanos}.sock"))
+ }
+
+ #[test]
+ fn default_socket_path_lives_under_a_runtime_dir() {
+ let p = default_socket_path();
+ assert_eq!(p.file_name().unwrap(), "mirada-ctl.sock");
+ }
+
+ #[test]
+ fn a_request_round_trips_over_the_socket() {
+ let path = temp_socket("roundtrip");
+ let server = CtlServer::bind(&path).unwrap();
+
+ // El "Cerebro": atiende una petición y responde.
+ let srv = thread::spawn(move || loop {
+ if let Some(mut conn) = server.poll() {
+ let req = conn.read_request().unwrap().unwrap();
+ let reply = match req {
+ CtlRequest::Do(DesktopAction::FocusNext) => CtlReply::Ok,
+ other => CtlReply::Error(format!("inesperado: {other:?}")),
+ };
+ conn.reply(&reply).unwrap();
+ return;
+ }
+ thread::yield_now();
+ });
+
+ let reply = send_request(&path, &CtlRequest::Do(DesktopAction::FocusNext)).unwrap();
+ assert_eq!(reply, CtlReply::Ok);
+ srv.join().unwrap();
+ }
+
+ #[test]
+ fn list_windows_carries_the_window_lines() {
+ let path = temp_socket("windows");
+ let server = CtlServer::bind(&path).unwrap();
+ let lines = vec![WindowLine {
+ id: 7,
+ app_id: "org.brahman.shuma".into(),
+ title: "shell".into(),
+ workspace: 2,
+ focused: true,
+ }];
+ let expected = lines.clone();
+
+ let srv = thread::spawn(move || loop {
+ if let Some(mut conn) = server.poll() {
+ assert_eq!(conn.read_request().unwrap().unwrap(), CtlRequest::ListWindows);
+ conn.reply(&CtlReply::Windows(lines)).unwrap();
+ return;
+ }
+ thread::yield_now();
+ });
+
+ let reply = send_request(&path, &CtlRequest::ListWindows).unwrap();
+ assert_eq!(reply, CtlReply::Windows(expected));
+ srv.join().unwrap();
+ }
+
+ #[test]
+ fn binding_twice_on_a_live_socket_is_refused() {
+ let path = temp_socket("dup");
+ let _first = CtlServer::bind(&path).unwrap();
+ // El primero sigue vivo: el segundo debe rechazarse.
+ assert!(CtlServer::bind(&path).is_err());
+ }
+}
diff --git a/02_ruway/mirada/mirada-brain/src/desktop.rs b/02_ruway/mirada/mirada-brain/src/desktop.rs
new file mode 100644
index 0000000..220d0eb
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/desktop.rs
@@ -0,0 +1,1946 @@
+//! El [`Desktop`] — el estado del escritorio y el bucle `evento → comandos`.
+
+use std::collections::HashMap;
+
+use mirada_layout::{LayoutParams, Rect, WindowId, Workspace};
+use mirada_protocol::{placements, BodyEvent, BrainCommand, OutputId};
+
+use crate::action::{Direction, DesktopAction, WORKSPACE_COUNT};
+use crate::config::Config;
+use crate::keymap::Keymap;
+use crate::rules::Rules;
+
+pub use crate::config::DROPTERM_APP_ID;
+
+/// Lo que el Cerebro sabe de una ventana: su identidad de aplicación.
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct WindowInfo {
+ pub app_id: String,
+ pub title: String,
+}
+
+/// Una salida física y el escritorio virtual que muestra ahora mismo.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Output {
+ pub id: OutputId,
+ /// Rectángulo en el espacio global — las salidas van en fila horizontal.
+ pub rect: Rect,
+ /// Zonas exclusivas reservadas por el marco (`pata`/shell), en px desde
+ /// cada borde: `(top, bottom, left, right)`. El teselado las esquiva.
+ pub reserved: (i32, i32, i32, i32),
+ /// Índice del escritorio que esta salida muestra.
+ pub workspace: usize,
+}
+
+impl Output {
+ /// El área teselable: el rect global menos las zonas reservadas. Es lo que
+ /// se le pasa al motor de layout, así que las barras de cualquier borde
+ /// quedan libres de ventanas.
+ pub fn work_rect(&self) -> Rect {
+ let (top, bottom, left, right) = self.reserved;
+ Rect::new(
+ self.rect.x + left,
+ self.rect.y + top,
+ (self.rect.w - left - right).max(1),
+ (self.rect.h - top - bottom).max(1),
+ )
+ }
+}
+
+/// El estado completo del escritorio.
+///
+/// Mantiene las salidas físicas, [`WORKSPACE_COUNT`] escritorios
+/// virtuales, el registro de ventanas, el keymap y las reglas. El único
+/// punto de entrada es [`Desktop::on_event`]: traga un [`BodyEvent`],
+/// muta el estado y devuelve los [`BrainCommand`]s a enviar al Cuerpo.
+///
+/// **Multi-monitor**: cada salida muestra un escritorio distinto; el
+/// teselado se calcula para todas y el `Place` resultante las cubre. Un
+/// escritorio se ve en una salida como mucho — pedir uno que ya muestra
+/// otra salida las intercambia.
+pub struct Desktop {
+ /// Salidas físicas, en fila horizontal y en orden de aparición.
+ outputs: Vec<Output>,
+ /// Escritorios virtuales — `WORKSPACE_COUNT` fijos.
+ workspaces: Vec<Workspace>,
+ /// Índice (en `outputs`) de la salida con el foco.
+ focused_output: usize,
+ /// Identidad de cada ventana conocida.
+ windows: HashMap<WindowId, WindowInfo>,
+ /// Atajos globales → acción. Configurable, recargable en caliente.
+ keymap: Keymap,
+ /// Reglas de ventana — escritorio/flotante por `app_id`/título.
+ rules: Rules,
+ /// Config general del WM — dropterm, parámetros del teselado, foco.
+ config: Config,
+ /// Ventanas del scratchpad: se invocan flotando y se ocultan a
+ /// voluntad; mientras están guardadas no viven en ningún escritorio.
+ scratchpad: Vec<WindowId>,
+}
+
+impl Default for Desktop {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Desktop {
+ /// Escritorio recién arrancado: sin salidas ni ventanas, con los
+ /// escritorios virtuales vacíos y el mapa de teclas por defecto.
+ pub fn new() -> Self {
+ Self::with_keymap(Keymap::default())
+ }
+
+ /// Como [`Desktop::new`], pero con un keymap dado — el que la app
+ /// cargó del archivo de configuración del usuario.
+ pub fn with_keymap(keymap: Keymap) -> Self {
+ let workspaces = (0..WORKSPACE_COUNT)
+ .map(|_| Workspace::new(LayoutParams::default()))
+ .collect();
+ Self {
+ outputs: Vec::new(),
+ workspaces,
+ focused_output: 0,
+ windows: HashMap::new(),
+ keymap,
+ rules: Rules::default(),
+ config: Config::default(),
+ scratchpad: Vec::new(),
+ }
+ }
+
+ /// Reemplaza las reglas de ventana. Se aplican a las ventanas que se
+ /// abran a partir de ahora; las ya abiertas no se tocan.
+ pub fn set_rules(&mut self, rules: Rules) {
+ self.rules = rules;
+ }
+
+ /// Aplica la config general del WM. Los parámetros de teselado
+ /// (modo/gap/ratio/nmaster) se siembran en **todos** los escritorios;
+ /// el resto (dropterm, foco-sigue-ratón) se consulta cuando hace falta.
+ /// Pensado para llamarse una vez al arrancar, antes de conectar salidas.
+ pub fn set_config(&mut self, config: Config) {
+ let params = config.layout_params();
+ for ws in &mut self.workspaces {
+ ws.set_params(params);
+ }
+ self.config = config;
+ }
+
+ /// La config general vigente — para un HUD o un editor de ajustes.
+ pub fn config(&self) -> &Config {
+ &self.config
+ }
+
+ /// Recarga la config en caliente: re-siembra los parámetros de teselado
+ /// (el archivo manda — un cambio de gap/modo/ratio se ve al guardar,
+ /// aunque pise un layout cambiado a mano) y devuelve el comando que
+ /// re-envía la decoración al Cuerpo. dropterm/foco se leen en vivo.
+ pub fn reload_config(&mut self, config: Config) -> Vec<BrainCommand> {
+ self.set_config(config);
+ vec![self.decorations()]
+ }
+
+ /// El comando que registra los atajos globales en el Cuerpo. La app
+ /// lo envía al conectar, y de nuevo tras cada recarga del keymap.
+ pub fn grab_keys(&self) -> BrainCommand {
+ BrainCommand::GrabKeys(self.keymap.grab_list())
+ }
+
+ /// El comando que fija la decoración de ventana (marco, …) en el
+ /// Cuerpo, según la config. La app lo envía al arrancar (junto a
+ /// [`grab_keys`](Desktop::grab_keys)) y tras recargar la config.
+ pub fn decorations(&self) -> BrainCommand {
+ BrainCommand::SetDecorations(self.config.decorations())
+ }
+
+ /// Reemplaza el keymap en caliente. Devuelve el [`BrainCommand`] que
+ /// el dueño debe enviar al Cuerpo para reajustar qué teclas intercepta.
+ pub fn set_keymap(&mut self, keymap: Keymap) -> BrainCommand {
+ self.keymap = keymap;
+ self.grab_keys()
+ }
+
+ /// El keymap vigente — para un HUD o un editor visual de atajos.
+ pub fn keymap(&self) -> &Keymap {
+ &self.keymap
+ }
+
+ /// Geometría de la salida enfocada, si hay alguna conectada.
+ pub fn screen(&self) -> Option<Rect> {
+ self.outputs.get(self.focused_output).map(|o| o.rect)
+ }
+
+ /// Procesa un evento del Cuerpo: muta el estado y devuelve los
+ /// comandos a enviar de vuelta.
+ pub fn on_event(&mut self, event: BodyEvent) -> Vec<BrainCommand> {
+ match event {
+ BodyEvent::OutputAdded { id, width, height } => {
+ // La salida nueva muestra el primer escritorio que no
+ // muestre ya otra salida.
+ let taken: Vec<usize> = self.outputs.iter().map(|o| o.workspace).collect();
+ let workspace = (0..self.workspaces.len())
+ .find(|n| !taken.contains(n))
+ .unwrap_or(0);
+ self.outputs.push(Output {
+ id,
+ rect: Rect::new(0, 0, width, height),
+ reserved: (0, 0, 0, 0),
+ workspace,
+ });
+ self.reflow_outputs();
+ self.relayout()
+ }
+ BodyEvent::OutputRemoved { id } => {
+ self.outputs.retain(|o| o.id != id);
+ if self.focused_output >= self.outputs.len() {
+ self.focused_output = self.outputs.len().saturating_sub(1);
+ }
+ self.reflow_outputs();
+ self.relayout()
+ }
+ BodyEvent::OutputResized { id, width, height } => {
+ // Sólo cambia el área útil; el escritorio que muestra la
+ // salida se conserva.
+ if let Some(o) = self.outputs.iter_mut().find(|o| o.id == id) {
+ o.rect.w = width;
+ o.rect.h = height;
+ self.reflow_outputs();
+ self.relayout()
+ } else {
+ Vec::new()
+ }
+ }
+ BodyEvent::OutputReserved {
+ id,
+ top,
+ bottom,
+ left,
+ right,
+ } => {
+ // El marco reservó/liberó franjas: cambia el área teselable
+ // sin tocar el tamaño físico ni el escritorio que muestra.
+ if let Some(o) = self.outputs.iter_mut().find(|o| o.id == id) {
+ o.reserved = (top.max(0), bottom.max(0), left.max(0), right.max(0));
+ self.relayout()
+ } else {
+ Vec::new()
+ }
+ }
+ BodyEvent::WindowOpened { id, app_id, title } => {
+ // La terminal dropdown se reconoce por su `app_id`: nace
+ // flotando anclada arriba y enfocada, lista para escribir.
+ let is_dropterm = app_id == DROPTERM_APP_ID;
+ // Las reglas pueden mandarla a otro escritorio o hacerla flotar.
+ let outcome = self.rules.resolve(&app_id, &title);
+ self.windows.insert(id, WindowInfo { app_id, title });
+ let ws = outcome
+ .workspace
+ .filter(|&n| n < self.workspaces.len())
+ .unwrap_or(self.active_index());
+ self.workspaces[ws].add(id);
+ if is_dropterm {
+ let pct = self.config.dropterm_height_pct();
+ let rect = self
+ .screen()
+ .map(|s| dropdown_rect(s, pct))
+ .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
+ self.workspaces[ws].set_floating(id, Some(rect));
+ } else if outcome.floating {
+ let rect = self
+ .screen()
+ .map(centered_float_rect)
+ .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
+ self.workspaces[ws].set_floating(id, Some(rect));
+ }
+ self.relayout()
+ }
+ BodyEvent::WindowClosed { id } => {
+ self.windows.remove(&id);
+ self.scratchpad.retain(|&w| w != id);
+ for ws in &mut self.workspaces {
+ ws.remove(id);
+ }
+ self.relayout()
+ }
+ BodyEvent::WindowRetitled { id, title } => {
+ if let Some(info) = self.windows.get_mut(&id) {
+ info.title = title;
+ }
+ // Un cambio de título no altera la geometría.
+ Vec::new()
+ }
+ BodyEvent::PointerEntered { id } => {
+ // Foco al pasar el puntero, si la config lo habilita y la
+ // ventana está en el escritorio activo.
+ if !self.config.focus_follows_mouse {
+ return Vec::new();
+ }
+ let active = self.active_index();
+ if self.workspaces[active].focus_window(id) {
+ self.relayout()
+ } else {
+ Vec::new()
+ }
+ }
+ BodyEvent::Clicked { id } => {
+ // Foco-al-click: enfoca la ventana clickeada, esté donde
+ // esté, sin depender del foco-sigue-ratón. El z-order
+ // (levantar la flotante clickeada) lo resuelve el Cuerpo al
+ // pintar la enfocada encima.
+ self.apply(DesktopAction::FocusWindow(id))
+ }
+ BodyEvent::WindowDragged { id, x, y } => {
+ // Arrastre de una ventana teselada: la intercambia con la
+ // teselada que haya bajo el puntero. Una flotante no entra
+ // aquí (usa WindowFloatTo) — si llega, la ignoramos.
+ let active = self.active_index();
+ if self.workspaces[active].is_floating(id)
+ || !self.workspaces[active].windows().contains(&id)
+ {
+ return Vec::new();
+ }
+ let Some(o) = self.outputs.get(self.focused_output).copied() else {
+ return Vec::new();
+ };
+ let target = self.workspaces[active]
+ .layout(o.work_rect())
+ .into_iter()
+ .find(|(wid, rect)| {
+ *wid != id
+ && !self.workspaces[active].is_floating(*wid)
+ && rect.contains(x, y)
+ })
+ .map(|(wid, _)| wid);
+ match target {
+ Some(t) if self.workspaces[active].swap(id, t) => self.relayout(),
+ _ => Vec::new(),
+ }
+ }
+ BodyEvent::Keybind(key) => match self.keymap.lookup(&key) {
+ Some(action) => self.apply(action),
+ None => Vec::new(),
+ },
+ BodyEvent::FullscreenRequest { id, fullscreen } => {
+ // El cliente (un reproductor, un juego) pidió pantalla
+ // completa: la fijamos en el escritorio que tiene la ventana.
+ let mut changed = false;
+ for ws in &mut self.workspaces {
+ if ws.windows().contains(&id) {
+ if fullscreen {
+ ws.set_fullscreen(Some(id));
+ } else if ws.fullscreen() == Some(id) {
+ ws.set_fullscreen(None);
+ }
+ changed = true;
+ break;
+ }
+ }
+ if changed {
+ self.relayout()
+ } else {
+ Vec::new()
+ }
+ }
+ BodyEvent::WindowFloatTo { id, rect } => {
+ // Arrastre interactivo: la ventana pasa a flotar en el
+ // rectángulo dado, en el escritorio donde viva.
+ let mut changed = false;
+ for ws in &mut self.workspaces {
+ if ws.windows().contains(&id) {
+ ws.set_floating(id, Some(rect));
+ changed = true;
+ break;
+ }
+ }
+ if changed {
+ self.relayout()
+ } else {
+ Vec::new()
+ }
+ }
+ }
+ }
+
+ /// Aplica una acción de escritorio directamente (sin pasar por una
+ /// tecla). Útil para disparar acciones desde un HUD.
+ pub fn apply(&mut self, action: DesktopAction) -> Vec<BrainCommand> {
+ let active = self.active_index();
+ match action {
+ DesktopAction::FocusNext => {
+ self.workspaces[active].focus_next();
+ self.relayout()
+ }
+ DesktopAction::FocusPrev => {
+ self.workspaces[active].focus_prev();
+ self.relayout()
+ }
+ DesktopAction::FocusDir(dir) => self.focus_in_direction(dir),
+ DesktopAction::MoveDir(dir) => self.move_in_direction(dir),
+ DesktopAction::FocusWindow(id) => {
+ // En el escritorio activo basta enfocar; si la ventana
+ // está en otro, lo traemos a la salida enfocada.
+ if self.workspaces[active].focus_window(id) {
+ return self.relayout();
+ }
+ for n in 0..self.workspaces.len() {
+ if n != active && self.workspaces[n].focus_window(id) {
+ self.show_workspace(n);
+ return self.relayout();
+ }
+ }
+ Vec::new()
+ }
+ DesktopAction::MoveForward => {
+ self.workspaces[active].move_focused_forward();
+ self.relayout()
+ }
+ DesktopAction::MoveBackward => {
+ self.workspaces[active].move_focused_backward();
+ self.relayout()
+ }
+ DesktopAction::CloseFocused => {
+ // Pedimos el cierre; el estado se actualiza al recibir el
+ // `WindowClosed` de vuelta, no antes.
+ match self.workspaces[active].focused() {
+ Some(id) => vec![BrainCommand::Close(id)],
+ None => Vec::new(),
+ }
+ }
+ DesktopAction::ToggleFloat => {
+ let Some(id) = self.workspaces[active].focused() else {
+ return Vec::new();
+ };
+ let screen = self.screen();
+ let ws = &mut self.workspaces[active];
+ if ws.is_floating(id) {
+ ws.set_floating(id, None);
+ } else {
+ let rect = screen
+ .map(centered_float_rect)
+ .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
+ ws.set_floating(id, Some(rect));
+ }
+ self.relayout()
+ }
+ DesktopAction::ToggleTiling => {
+ let screen = self.screen();
+ let ws = &mut self.workspaces[active];
+ // Las teseladas: las no flotantes que sí están en el orden.
+ let tiled: Vec<WindowId> = ws
+ .windows()
+ .iter()
+ .copied()
+ .filter(|&id| !ws.is_floating(id))
+ .collect();
+ if tiled.is_empty() {
+ // Todas flotando ya: devolverlas al teselado.
+ let floating: Vec<WindowId> = ws.windows().to_vec();
+ for id in floating {
+ ws.set_floating(id, None);
+ }
+ } else if let Some(base) = screen {
+ // Hacerlas flotar todas, en cascada desde una base.
+ let w = base.w * 3 / 5;
+ let h = base.h * 3 / 5;
+ for (i, id) in tiled.into_iter().enumerate() {
+ let off = (i as i32) * 32;
+ let rect = Rect::new(
+ base.x + (base.w - w) / 2 + off,
+ base.y + (base.h - h) / 2 + off,
+ w,
+ h,
+ );
+ ws.set_floating(id, Some(rect));
+ }
+ }
+ self.relayout()
+ }
+ DesktopAction::ToggleFullscreen => {
+ let Some(id) = self.workspaces[active].focused() else {
+ return Vec::new();
+ };
+ let ws = &mut self.workspaces[active];
+ if ws.fullscreen() == Some(id) {
+ ws.set_fullscreen(None);
+ } else {
+ ws.set_fullscreen(Some(id));
+ }
+ self.relayout()
+ }
+ DesktopAction::SendToScratchpad => {
+ let Some(id) = self.workspaces[active].focused() else {
+ return Vec::new();
+ };
+ for ws in &mut self.workspaces {
+ ws.remove(id);
+ }
+ if !self.scratchpad.contains(&id) {
+ self.scratchpad.push(id);
+ }
+ self.relayout()
+ }
+ DesktopAction::ToggleScratchpad => {
+ // ¿Hay alguna ventana del scratchpad en el escritorio activo?
+ let shown: Vec<WindowId> = self.workspaces[active]
+ .windows()
+ .iter()
+ .copied()
+ .filter(|id| self.scratchpad.contains(id))
+ .collect();
+ if !shown.is_empty() {
+ for id in shown {
+ self.workspaces[active].remove(id);
+ }
+ self.relayout()
+ } else if let Some(&id) = self.scratchpad.first() {
+ // La traemos de donde esté y la mostramos flotando.
+ for ws in &mut self.workspaces {
+ ws.remove(id);
+ }
+ let rect = self
+ .screen()
+ .map(centered_float_rect)
+ .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
+ self.workspaces[active].add(id);
+ self.workspaces[active].set_floating(id, Some(rect));
+ self.relayout()
+ } else {
+ Vec::new()
+ }
+ }
+ DesktopAction::ToggleDropterm => {
+ // Buscamos la terminal dropdown por su marca de `app_id`.
+ let existing = self
+ .windows
+ .iter()
+ .find(|(_, info)| info.app_id == DROPTERM_APP_ID)
+ .map(|(&id, _)| id);
+ match existing {
+ // Ya existe: si está a la vista, la guardamos; si está
+ // guardada, la bajamos flotando y enfocada.
+ Some(id) => {
+ if self.workspaces[active].windows().contains(&id) {
+ for ws in &mut self.workspaces {
+ ws.remove(id);
+ }
+ if !self.scratchpad.contains(&id) {
+ self.scratchpad.push(id);
+ }
+ } else {
+ for ws in &mut self.workspaces {
+ ws.remove(id);
+ }
+ self.scratchpad.retain(|&w| w != id);
+ let pct = self.config.dropterm_height_pct();
+ let rect = self
+ .screen()
+ .map(|s| dropdown_rect(s, pct))
+ .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
+ self.workspaces[active].add(id);
+ self.workspaces[active].set_floating(id, Some(rect));
+ }
+ self.relayout()
+ }
+ // Aún no existe: la creamos perezosamente con el comando
+ // de la config. Al abrirse, `WindowOpened` la reconoce
+ // (por su `app_id`) y la baja flotando+enfocada.
+ None => vec![BrainCommand::Spawn(self.config.dropterm_cmd.clone())],
+ }
+ }
+ DesktopAction::CycleLayout => {
+ let next = self.workspaces[active].params().mode.next();
+ self.workspaces[active].set_mode(next);
+ self.relayout()
+ }
+ DesktopAction::SetLayout(mode) => {
+ self.workspaces[active].set_mode(mode);
+ self.relayout()
+ }
+ DesktopAction::GrowMaster => self.nudge_master(self.config.master_step()),
+ DesktopAction::ShrinkMaster => self.nudge_master(-self.config.master_step()),
+ DesktopAction::IncMaster => self.nudge_master_count(1),
+ DesktopAction::DecMaster => self.nudge_master_count(-1),
+ DesktopAction::PromoteToMaster => {
+ self.workspaces[active].promote_focused();
+ self.relayout()
+ }
+ DesktopAction::SwapMaster => {
+ let ws = &mut self.workspaces[active];
+ let Some(focused) = ws.focused() else {
+ return Vec::new();
+ };
+ let Some(&master) = ws.windows().first() else {
+ return Vec::new();
+ };
+ // Sólo intercambia esas dos; el resto del orden no se mueve.
+ if focused != master && ws.swap(focused, master) {
+ self.relayout()
+ } else {
+ Vec::new()
+ }
+ }
+ DesktopAction::SwitchWorkspace(n) => {
+ if n < self.workspaces.len() && n != active {
+ self.show_workspace(n);
+ self.relayout()
+ } else {
+ Vec::new()
+ }
+ }
+ DesktopAction::SendToWorkspace(n) => {
+ if n >= self.workspaces.len() || n == active {
+ return Vec::new();
+ }
+ match self.workspaces[active].focused() {
+ Some(id) => {
+ self.workspaces[active].remove(id);
+ self.workspaces[n].add(id);
+ self.relayout()
+ }
+ None => Vec::new(),
+ }
+ }
+ DesktopAction::MoveToWorkspace(n) => {
+ if n >= self.workspaces.len() || n == active {
+ return Vec::new();
+ }
+ match self.workspaces[active].focused() {
+ Some(id) => {
+ self.workspaces[active].remove(id);
+ self.workspaces[n].add(id);
+ // …y salta con ella al escritorio destino.
+ self.show_workspace(n);
+ self.relayout()
+ }
+ None => Vec::new(),
+ }
+ }
+ DesktopAction::FocusOutputNext => {
+ if self.outputs.len() > 1 {
+ self.focused_output = (self.focused_output + 1) % self.outputs.len();
+ self.relayout()
+ } else {
+ Vec::new()
+ }
+ }
+ DesktopAction::FocusOutputDir(dir) => {
+ let Some(oid) = self.output_in_direction(dir) else {
+ return Vec::new();
+ };
+ match self.outputs.iter().position(|o| o.id == oid) {
+ Some(i) => {
+ self.focused_output = i;
+ self.relayout()
+ }
+ None => Vec::new(),
+ }
+ }
+ DesktopAction::SendToOutputDir(dir) => {
+ let Some(id) = self.workspaces[active].focused() else {
+ return Vec::new();
+ };
+ let Some(oid) = self.output_in_direction(dir) else {
+ return Vec::new();
+ };
+ let Some(ti) = self.outputs.iter().position(|o| o.id == oid) else {
+ return Vec::new();
+ };
+ let target_ws = self.outputs[ti].workspace;
+ self.workspaces[active].remove(id);
+ self.workspaces[target_ws].add(id);
+ self.relayout()
+ }
+ DesktopAction::ResizeFloatDir(dir) => self.resize_float(dir),
+ DesktopAction::Spawn(cmd) => vec![BrainCommand::Spawn(cmd)],
+ DesktopAction::Quit => vec![BrainCommand::Shutdown],
+ }
+ }
+
+ /// El índice del escritorio activo — el que muestra la salida
+ /// enfocada. `0` si todavía no hay ninguna salida.
+ pub fn active_index(&self) -> usize {
+ self.outputs
+ .get(self.focused_output)
+ .map(|o| o.workspace)
+ .unwrap_or(0)
+ }
+
+ /// Hace que la salida enfocada muestre el escritorio `n`. Si otra
+ /// salida ya lo mostraba, intercambian — así ningún escritorio se
+ /// ve en dos sitios a la vez.
+ fn show_workspace(&mut self, n: usize) {
+ if n >= self.workspaces.len() || self.focused_output >= self.outputs.len() {
+ return;
+ }
+ let current = self.outputs[self.focused_output].workspace;
+ if current == n {
+ return;
+ }
+ if let Some(other) = self.outputs.iter().position(|o| o.workspace == n) {
+ self.outputs[other].workspace = current;
+ }
+ self.outputs[self.focused_output].workspace = n;
+ }
+
+ /// Recoloca las salidas en fila horizontal, en su orden de aparición.
+ fn reflow_outputs(&mut self) {
+ let mut x = 0;
+ for o in &mut self.outputs {
+ o.rect.x = x;
+ o.rect.y = 0;
+ x += o.rect.w;
+ }
+ }
+
+ /// Ajusta la fracción del área maestra del escritorio activo (la usan
+ /// `MasterStack` y `CenteredMaster`), acotada a `0.05..=0.95`.
+ fn nudge_master(&mut self, delta: f32) -> Vec<BrainCommand> {
+ let active = self.active_index();
+ let ws = &mut self.workspaces[active];
+ let ratio = (ws.params().master_ratio + delta).clamp(0.05, 0.95);
+ ws.set_master_ratio(ratio);
+ self.relayout()
+ }
+
+ /// Ajusta `nmaster` del escritorio activo, acotado a `1..=9`.
+ fn nudge_master_count(&mut self, delta: i32) -> Vec<BrainCommand> {
+ let active = self.active_index();
+ let ws = &mut self.workspaces[active];
+ let n = (ws.params().master_count as i32 + delta).clamp(1, 9) as usize;
+ ws.set_master_count(n);
+ self.relayout()
+ }
+
+ /// Mueve el foco a la ventana más cercana en una dirección cardinal,
+ /// según la geometría real del escritorio activo (no el orden de
+ /// teselado). Sin ventana enfocada, sin salida, o sin candidata en esa
+ /// dirección, no hace nada.
+ fn focus_in_direction(&mut self, dir: Direction) -> Vec<BrainCommand> {
+ let active = self.active_index();
+ let Some(focused) = self.workspaces[active].focused() else {
+ return Vec::new();
+ };
+ let Some(o) = self.outputs.get(self.focused_output).copied() else {
+ return Vec::new();
+ };
+ let layout = self.workspaces[active].layout(o.work_rect());
+ let Some(from) = layout.iter().find(|(id, _)| *id == focused).map(|(_, r)| *r) else {
+ return Vec::new();
+ };
+ match nearest_in_direction(from, &layout, focused, dir) {
+ Some(target) if self.workspaces[active].focus_window(target) => self.relayout(),
+ _ => Vec::new(),
+ }
+ }
+
+ /// La salida (monitor) vecina en una dirección cardinal, por geometría.
+ /// `None` si hay menos de dos salidas o ninguna en esa dirección.
+ fn output_in_direction(&self, dir: Direction) -> Option<OutputId> {
+ if self.outputs.len() < 2 {
+ return None;
+ }
+ let cur = self.outputs.get(self.focused_output)?;
+ let cands: Vec<(OutputId, Rect)> = self.outputs.iter().map(|o| (o.id, o.rect)).collect();
+ nearest_in_direction(cur.rect, &cands, cur.id, dir)
+ }
+
+ /// Redimensiona la ventana flotante enfocada hacia una dirección, por
+ /// `float_step` px (acotada a un mínimo). No hace nada sobre teseladas.
+ fn resize_float(&mut self, dir: Direction) -> Vec<BrainCommand> {
+ const MIN: i32 = 80;
+ let active = self.active_index();
+ let Some(id) = self.workspaces[active].focused() else {
+ return Vec::new();
+ };
+ let Some(mut rect) = self.workspaces[active].floating_rect(id) else {
+ return Vec::new(); // no flota
+ };
+ let step = self.config.float_step();
+ match dir {
+ Direction::Right => rect.w = (rect.w + step).max(MIN),
+ Direction::Left => rect.w = (rect.w - step).max(MIN),
+ Direction::Down => rect.h = (rect.h + step).max(MIN),
+ Direction::Up => rect.h = (rect.h - step).max(MIN),
+ }
+ self.workspaces[active].set_floating(id, Some(rect));
+ self.relayout()
+ }
+
+ /// Intercambia la ventana enfocada con su vecina **teselada** más
+ /// cercana en una dirección — mover la ventana por geometría. Una
+ /// ventana **flotante** se desplaza `float_step` px en esa dirección.
+ /// Sin vecina (teselada) en esa dirección, no hace nada. El foco
+ /// acompaña a la ventana movida.
+ fn move_in_direction(&mut self, dir: Direction) -> Vec<BrainCommand> {
+ let active = self.active_index();
+ let Some(focused) = self.workspaces[active].focused() else {
+ return Vec::new();
+ };
+ // Una flotante se mueve nudgeando su posición, no intercambiando.
+ if let Some(mut rect) = self.workspaces[active].floating_rect(focused) {
+ let step = self.config.float_step();
+ match dir {
+ Direction::Left => rect.x -= step,
+ Direction::Right => rect.x += step,
+ Direction::Up => rect.y -= step,
+ Direction::Down => rect.y += step,
+ }
+ self.workspaces[active].set_floating(focused, Some(rect));
+ return self.relayout();
+ }
+ let Some(o) = self.outputs.get(self.focused_output).copied() else {
+ return Vec::new();
+ };
+ let layout = self.workspaces[active].layout(o.work_rect());
+ let Some(from) = layout.iter().find(|(id, _)| *id == focused).map(|(_, r)| *r) else {
+ return Vec::new();
+ };
+ // Sólo teseladas son candidatas a intercambio.
+ let tiled: Vec<(WindowId, Rect)> = layout
+ .into_iter()
+ .filter(|(id, _)| !self.workspaces[active].is_floating(*id))
+ .collect();
+ match nearest_in_direction(from, &tiled, focused, dir) {
+ Some(target) if self.workspaces[active].swap(focused, target) => self.relayout(),
+ _ => Vec::new(),
+ }
+ }
+
+ /// Recalcula la geometría de **todas** las salidas y la empaqueta en
+ /// un único [`BrainCommand::Place`]. Sin salidas, no hay nada que
+ /// colocar.
+ fn relayout(&self) -> Vec<BrainCommand> {
+ if self.outputs.is_empty() {
+ return Vec::new();
+ }
+ let mut all = Vec::new();
+ for o in &self.outputs {
+ // El teselado usa el área útil (rect menos zonas reservadas), así las
+ // barras del marco en cualquier borde quedan libres de ventanas.
+ all.extend(placements(&self.workspaces[o.workspace], o.work_rect()));
+ }
+ // El foco del teclado es único: sólo la ventana enfocada de la
+ // salida enfocada. `placements` marca el foco por escritorio (lo
+ // necesita para la visibilidad en `Monocle`); aquí lo unificamos.
+ let global_focus = self.focused_window();
+ for p in &mut all {
+ p.focused = Some(p.id) == global_focus;
+ }
+ vec![BrainCommand::Place(all)]
+ }
+
+ // --- Accesores de sólo lectura, para el HUD de la app GPUI ---------
+
+ /// El escritorio activo — el de la salida enfocada.
+ pub fn active_workspace(&self) -> &Workspace {
+ &self.workspaces[self.active_index()]
+ }
+
+ /// Las salidas conectadas, en orden, con el escritorio que muestran.
+ pub fn outputs(&self) -> &[Output] {
+ &self.outputs
+ }
+
+ /// Índice (en [`outputs`](Desktop::outputs)) de la salida enfocada.
+ pub fn focused_output(&self) -> usize {
+ self.focused_output
+ }
+
+ /// Identidad de una ventana conocida.
+ pub fn window_info(&self, id: WindowId) -> Option<&WindowInfo> {
+ self.windows.get(&id)
+ }
+
+ /// La ventana con el foco del teclado: la enfocada del escritorio
+ /// activo — o su ventana en pantalla completa, si la hay.
+ pub fn focused_window(&self) -> Option<WindowId> {
+ let ws = &self.workspaces[self.active_index()];
+ ws.fullscreen().or_else(|| ws.focused())
+ }
+
+ /// Cuántas ventanas hay en cada escritorio virtual.
+ pub fn workspace_loads(&self) -> Vec<usize> {
+ self.workspaces.iter().map(Workspace::len).collect()
+ }
+
+ /// Una vista de todas las ventanas conocidas, en todos los
+ /// escritorios — la base de `mirada-ctl windows` y de una taskbar.
+ pub fn window_lines(&self) -> Vec<crate::ctl::WindowLine> {
+ let active = self.active_index();
+ let mut lines = Vec::new();
+ for (n, ws) in self.workspaces.iter().enumerate() {
+ let ws_focus = ws.focused();
+ for &id in ws.windows() {
+ let info = self.windows.get(&id);
+ lines.push(crate::ctl::WindowLine {
+ id,
+ app_id: info.map(|i| i.app_id.clone()).unwrap_or_default(),
+ title: info.map(|i| i.title.clone()).unwrap_or_default(),
+ workspace: n + 1,
+ focused: n == active && ws_focus == Some(id),
+ });
+ }
+ }
+ // Ventanas guardadas en el scratchpad — en ningún escritorio.
+ for &id in &self.scratchpad {
+ let stashed = !self.workspaces.iter().any(|ws| ws.windows().contains(&id));
+ if stashed {
+ let info = self.windows.get(&id);
+ lines.push(crate::ctl::WindowLine {
+ id,
+ app_id: info.map(|i| i.app_id.clone()).unwrap_or_default(),
+ title: info.map(|i| i.title.clone()).unwrap_or_default(),
+ workspace: 0, // 0 = guardada en el scratchpad
+ focused: false,
+ });
+ }
+ }
+ lines
+ }
+}
+
+/// El elemento de `candidates` (ventana o salida) más cercano a `from` en
+/// la dirección `dir`, excluyendo a `self_id`. Pura — la base del foco
+/// espacial entre ventanas y entre monitores.
+///
+/// Criterio (estilo i3/sway): sólo cuentan los candidatos cuyo centro cae
+/// en el semiplano de esa dirección respecto al centro de `from`; entre
+/// ellos gana el de menor distancia en el eje principal, penalizando el
+/// desvío en el eje perpendicular (`×2`) para preferir el que está
+/// «enfrente». Empates: el id menor, para ser determinista.
+fn nearest_in_direction<T: Copy + Ord>(
+ from: Rect,
+ candidates: &[(T, Rect)],
+ self_id: T,
+ dir: Direction,
+) -> Option<T> {
+ let center = |r: &Rect| (r.x + r.w / 2, r.y + r.h / 2);
+ let (fx, fy) = center(&from);
+ let mut best: Option<(i64, T)> = None;
+ for (id, rect) in candidates {
+ if *id == self_id {
+ continue;
+ }
+ let (cx, cy) = center(rect);
+ let (dx, dy) = ((cx - fx) as i64, (cy - fy) as i64);
+ // ¿Está en el semiplano de la dirección? (`primary` > 0) y, si sí,
+ // el coste = primary + 2·|perpendicular|.
+ let (primary, perp) = match dir {
+ Direction::Left => (-dx, dy),
+ Direction::Right => (dx, dy),
+ Direction::Up => (-dy, dx),
+ Direction::Down => (dy, dx),
+ };
+ if primary <= 0 {
+ continue;
+ }
+ let cost = primary + 2 * perp.abs();
+ let better = match best {
+ None => true,
+ Some((c, bid)) => cost < c || (cost == c && *id < bid),
+ };
+ if better {
+ best = Some((cost, *id));
+ }
+ }
+ best.map(|(_, id)| id)
+}
+
+/// El rectángulo de la terminal dropdown: anclada arriba, a todo el ancho,
+/// `pct` % del alto — el gesto «quake» de bajar desde el borde superior.
+/// El porcentaje sale de la config ([`Config::dropterm_height_pct`]).
+fn dropdown_rect(screen: Rect, pct: i32) -> Rect {
+ Rect::new(screen.x, screen.y, screen.w, (screen.h * pct / 100).max(1))
+}
+
+/// El rectángulo flotante por defecto: 60 % de la pantalla, centrado.
+fn centered_float_rect(screen: Rect) -> Rect {
+ let w = screen.w * 3 / 5;
+ let h = screen.h * 3 / 5;
+ Rect::new(
+ screen.x + (screen.w - w) / 2,
+ screen.y + (screen.h - h) / 2,
+ w,
+ h,
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use mirada_layout::LayoutMode;
+
+ /// Un escritorio con una salida 1920×1080 ya conectada.
+ fn desktop_with_screen() -> Desktop {
+ let mut d = Desktop::new();
+ d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+ d
+ }
+
+ fn open(d: &mut Desktop, id: WindowId) -> Vec<BrainCommand> {
+ d.on_event(BodyEvent::WindowOpened {
+ id,
+ app_id: format!("app{id}"),
+ title: format!("win {id}"),
+ })
+ }
+
+ /// Extrae las colocaciones de un único `Place`.
+ fn places(cmds: &[BrainCommand]) -> &[mirada_protocol::WindowPlacement] {
+ match cmds {
+ [BrainCommand::Place(p)] => p,
+ other => panic!("se esperaba un solo Place, no {other:?}"),
+ }
+ }
+
+ #[test]
+ fn grab_keys_lists_the_whole_keymap() {
+ let d = Desktop::new();
+ match d.grab_keys() {
+ BrainCommand::GrabKeys(keys) => {
+ assert!(keys.contains(&"Super+j".to_string()));
+ assert!(keys.contains(&"Super+Shift+e".to_string()));
+ }
+ other => panic!("se esperaba GrabKeys, no {other:?}"),
+ }
+ }
+
+ #[test]
+ fn set_keymap_swaps_the_bindings_and_regrabs() {
+ let mut d = desktop_with_screen();
+ for id in [1, 2, 3] {
+ open(&mut d, id);
+ }
+ // El keymap por defecto no usa Alt.
+ assert!(d.on_event(BodyEvent::Keybind("Alt+x".into())).is_empty());
+ // Cargamos un keymap a medida; el comando devuelto re-registra grabs.
+ let custom = crate::Keymap::from_ron(r#"( bindings: { "Alt+x": "focus-prev" } )"#).unwrap();
+ match d.set_keymap(custom) {
+ BrainCommand::GrabKeys(keys) => assert_eq!(keys, vec!["Alt+x".to_string()]),
+ other => panic!("se esperaba GrabKeys, no {other:?}"),
+ }
+ // Ahora «Alt+x» sí mueve el foco, y «Super+j» ya no.
+ assert_eq!(d.focused_window(), Some(3));
+ d.on_event(BodyEvent::Keybind("Alt+x".into()));
+ assert_eq!(d.focused_window(), Some(2));
+ assert!(d.on_event(BodyEvent::Keybind("Super+j".into())).is_empty());
+ }
+
+ #[test]
+ fn focus_window_addresses_a_specific_window() {
+ let mut d = desktop_with_screen();
+ for id in [1, 2, 3] {
+ open(&mut d, id);
+ }
+ assert_eq!(d.focused_window(), Some(3));
+ d.apply(DesktopAction::FocusWindow(1));
+ assert_eq!(d.focused_window(), Some(1));
+ }
+
+ #[test]
+ fn focus_window_jumps_to_the_workspace_that_holds_it() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2); // enfocada
+ // Manda la 2 al escritorio 3; seguimos en el 1.
+ d.on_event(BodyEvent::Keybind("Super+Shift+3".into()));
+ assert_eq!(d.active_index(), 0);
+ // Enfocar la 2 nos lleva a su escritorio.
+ d.apply(DesktopAction::FocusWindow(2));
+ assert_eq!(d.active_index(), 2);
+ assert_eq!(d.focused_window(), Some(2));
+ }
+
+ #[test]
+ fn window_lines_cover_every_window_with_its_workspace() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2);
+ d.on_event(BodyEvent::Keybind("Super+Shift+3".into())); // la 2 al esc. 3
+ let lines = d.window_lines();
+ assert_eq!(lines.len(), 2);
+ let w1 = lines.iter().find(|l| l.id == 1).unwrap();
+ let w2 = lines.iter().find(|l| l.id == 2).unwrap();
+ assert_eq!(w1.workspace, 1);
+ assert_eq!(w2.workspace, 3);
+ // La 1 quedó enfocada en el escritorio activo (el 1).
+ assert!(w1.focused);
+ assert!(!w2.focused);
+ }
+
+ #[test]
+ fn toggle_float_marks_the_focused_window_and_floats_it_last() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2); // enfocada
+ let cmds = d.apply(DesktopAction::ToggleFloat);
+ let p = places(&cmds);
+ assert!(p.iter().find(|x| x.id == 2).unwrap().floating);
+ // La flotante va al final de la lista — orden de pintado.
+ assert_eq!(p.last().unwrap().id, 2);
+ // Alternar de nuevo la devuelve al teselado.
+ let cmds = d.apply(DesktopAction::ToggleFloat);
+ assert!(!places(&cmds).iter().find(|x| x.id == 2).unwrap().floating);
+ }
+
+ #[test]
+ fn toggle_fullscreen_covers_the_screen_and_hides_the_rest() {
+ let mut d = desktop_with_screen();
+ for id in [1, 2, 3] {
+ open(&mut d, id);
+ }
+ let cmds = d.apply(DesktopAction::ToggleFullscreen); // sobre la 3
+ let p = places(&cmds);
+ let fs = p.iter().find(|x| x.id == 3).unwrap();
+ assert!(fs.fullscreen && fs.visible);
+ assert_eq!(fs.rect, d.screen().unwrap());
+ assert!(p.iter().filter(|x| x.id != 3).all(|x| !x.visible));
+ // Alternar de nuevo restaura el teselado: las tres visibles.
+ let cmds = d.apply(DesktopAction::ToggleFullscreen);
+ assert_eq!(places(&cmds).iter().filter(|x| x.visible).count(), 3);
+ }
+
+ #[test]
+ fn a_rule_sends_a_new_window_to_its_workspace() {
+ let mut d = desktop_with_screen();
+ d.set_rules(Rules::from_ron(r#"( rules: [ (app_id: "app2", workspace: 3) ] )"#).unwrap());
+ open(&mut d, 1); // app1 → sin regla → escritorio activo (1)
+ open(&mut d, 2); // app2 → regla → escritorio 3
+ assert_eq!(d.workspace_loads()[0], 1);
+ assert_eq!(d.workspace_loads()[2], 1);
+ }
+
+ #[test]
+ fn a_rule_can_open_a_window_floating() {
+ let mut d = desktop_with_screen();
+ d.set_rules(Rules::from_ron(r#"( rules: [ (app_id: "app1", floating: true) ] )"#).unwrap());
+ let cmds = open(&mut d, 1);
+ assert!(places(&cmds).iter().find(|p| p.id == 1).unwrap().floating);
+ }
+
+ #[test]
+ fn without_a_screen_nothing_is_placed() {
+ let mut d = Desktop::new();
+ assert!(open(&mut d, 1).is_empty());
+ }
+
+ #[test]
+ fn opening_a_window_places_it() {
+ let mut d = desktop_with_screen();
+ let cmds = open(&mut d, 1);
+ assert_eq!(places(&cmds).len(), 1);
+ assert_eq!(d.focused_window(), Some(1));
+ }
+
+ #[test]
+ fn closing_a_window_removes_it_everywhere() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2);
+ let cmds = d.on_event(BodyEvent::WindowClosed { id: 1 });
+ assert_eq!(places(&cmds).len(), 1);
+ assert!(d.window_info(1).is_none());
+ assert_eq!(d.focused_window(), Some(2));
+ }
+
+ #[test]
+ fn focus_keybind_cycles_within_the_active_workspace() {
+ let mut d = desktop_with_screen();
+ for id in [1, 2, 3] {
+ open(&mut d, id);
+ }
+ assert_eq!(d.focused_window(), Some(3));
+ d.on_event(BodyEvent::Keybind("Super+j".into())); // next, da la vuelta
+ assert_eq!(d.focused_window(), Some(1));
+ d.on_event(BodyEvent::Keybind("Super+k".into())); // prev
+ assert_eq!(d.focused_window(), Some(3));
+ }
+
+ #[test]
+ fn close_focused_keybind_asks_to_close_the_focused_window() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 7);
+ let cmds = d.on_event(BodyEvent::Keybind("Super+q".into()));
+ assert_eq!(cmds, vec![BrainCommand::Close(7)]);
+ // No se elimina hasta que el Cuerpo confirme con WindowClosed.
+ assert!(d.window_info(7).is_some());
+ }
+
+ #[test]
+ fn cycle_layout_walks_every_mode_and_returns() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ let start = d.active_workspace().params().mode;
+ for _ in 0..LayoutMode::ALL.len() {
+ let before = d.active_workspace().params().mode;
+ d.on_event(BodyEvent::Keybind("Super+space".into()));
+ assert_eq!(d.active_workspace().params().mode, before.next());
+ }
+ // Una vuelta completa devuelve al modo inicial.
+ assert_eq!(d.active_workspace().params().mode, start);
+ }
+
+ #[test]
+ fn grow_and_shrink_master_adjust_the_ratio() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ let r0 = d.active_workspace().params().master_ratio;
+ d.apply(DesktopAction::GrowMaster);
+ assert!(d.active_workspace().params().master_ratio > r0);
+ d.apply(DesktopAction::ShrinkMaster);
+ assert!((d.active_workspace().params().master_ratio - r0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn inc_and_dec_master_adjust_nmaster() {
+ let mut d = desktop_with_screen();
+ for id in [1, 2, 3] {
+ open(&mut d, id);
+ }
+ assert_eq!(d.active_workspace().params().master_count, 1);
+ d.apply(DesktopAction::IncMaster);
+ assert_eq!(d.active_workspace().params().master_count, 2);
+ d.apply(DesktopAction::DecMaster);
+ d.apply(DesktopAction::DecMaster); // no baja de 1
+ assert_eq!(d.active_workspace().params().master_count, 1);
+ }
+
+ #[test]
+ fn swap_master_exchanges_only_the_focused_and_the_master() {
+ let mut d = desktop_with_screen();
+ for id in [1, 2, 3, 4] {
+ open(&mut d, id);
+ }
+ d.apply(DesktopAction::FocusWindow(3));
+ d.apply(DesktopAction::SwapMaster);
+ // 3 pasa al puesto maestro, 1 a donde estaba 3; el resto intacto.
+ assert_eq!(d.active_workspace().windows(), &[3, 2, 1, 4]);
+ assert_eq!(d.focused_window(), Some(3));
+ // A diferencia de promote-to-master, que rota: promover la 4…
+ d.apply(DesktopAction::FocusWindow(4));
+ d.apply(DesktopAction::PromoteToMaster);
+ assert_eq!(d.active_workspace().windows(), &[4, 3, 2, 1]);
+ }
+
+ #[test]
+ fn move_to_workspace_sends_the_window_and_follows_it() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2); // enfocada
+ d.apply(DesktopAction::MoveToWorkspace(2)); // índice 2 = escritorio 3
+ // La 2 viajó y el foco saltó con ella.
+ assert_eq!(d.active_index(), 2);
+ assert_eq!(d.focused_window(), Some(2));
+ assert_eq!(d.workspace_loads()[2], 1);
+ // El escritorio original conserva sólo la 1.
+ assert_eq!(d.workspace_loads()[0], 1);
+ }
+
+ #[test]
+ fn promote_to_master_brings_the_focused_window_to_the_front() {
+ let mut d = desktop_with_screen();
+ for id in [1, 2, 3] {
+ open(&mut d, id);
+ }
+ d.apply(DesktopAction::FocusWindow(3));
+ d.apply(DesktopAction::PromoteToMaster);
+ assert_eq!(d.active_workspace().windows()[0], 3);
+ assert_eq!(d.focused_window(), Some(3));
+ }
+
+ #[test]
+ fn master_ratio_stays_within_bounds() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ for _ in 0..50 {
+ d.apply(DesktopAction::GrowMaster);
+ }
+ assert!(d.active_workspace().params().master_ratio <= 0.95);
+ for _ in 0..50 {
+ d.apply(DesktopAction::ShrinkMaster);
+ }
+ assert!(d.active_workspace().params().master_ratio >= 0.05);
+ }
+
+ #[test]
+ fn monocle_keybind_hides_all_but_the_focused_window() {
+ let mut d = desktop_with_screen();
+ for id in [1, 2, 3] {
+ open(&mut d, id);
+ }
+ let cmds = d.on_event(BodyEvent::Keybind("Super+m".into()));
+ let visible = places(&cmds).iter().filter(|p| p.visible).count();
+ assert_eq!(visible, 1);
+ }
+
+ #[test]
+ fn switching_workspace_changes_what_is_placed() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2);
+ // Escritorio 2 (índice 1) está vacío.
+ let cmds = d.on_event(BodyEvent::Keybind("Super+2".into()));
+ assert!(places(&cmds).is_empty());
+ assert_eq!(d.active_index(), 1);
+ // Volver al 1 reaparece las dos ventanas.
+ let cmds = d.on_event(BodyEvent::Keybind("Super+1".into()));
+ assert_eq!(places(&cmds).len(), 2);
+ }
+
+ #[test]
+ fn send_to_workspace_moves_the_focused_window() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2); // enfocada
+ d.on_event(BodyEvent::Keybind("Super+Shift+3".into()));
+ assert_eq!(d.workspace_loads()[0], 1); // sólo queda la 1
+ assert_eq!(d.workspace_loads()[2], 1); // la 2 viajó al escritorio 3
+ // La ventana 2 sigue registrada — sólo cambió de escritorio.
+ assert!(d.window_info(2).is_some());
+ }
+
+ #[test]
+ fn pointer_focuses_a_window_in_the_active_workspace() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2); // enfocada
+ d.on_event(BodyEvent::PointerEntered { id: 1 });
+ assert_eq!(d.focused_window(), Some(1));
+ }
+
+ #[test]
+ fn dragging_floats_a_window_at_the_given_rect() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2);
+ assert!(!d.active_workspace().is_floating(2));
+ let target = Rect::new(300, 200, 640, 480);
+ let cmds = d.on_event(BodyEvent::WindowFloatTo { id: 2, rect: target });
+ // La 2 ahora flota exactamente en el rectángulo pedido.
+ assert!(d.active_workspace().is_floating(2));
+ let p = places(&cmds).iter().find(|p| p.id == 2).unwrap();
+ assert!(p.floating);
+ assert_eq!(p.rect, target);
+ }
+
+ #[test]
+ fn resizing_an_output_retiles_without_losing_the_workspace() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ d.on_event(BodyEvent::Keybind("Super+2".into())); // escritorio activo → 2
+ assert_eq!(d.active_index(), 1);
+ let cmds = d.on_event(BodyEvent::OutputResized {
+ id: 0,
+ width: 1920,
+ height: 1040,
+ });
+ // A diferencia de quitar y volver a añadir la salida, el
+ // escritorio activo se conserva.
+ assert_eq!(d.active_index(), 1);
+ assert!(matches!(cmds.as_slice(), [BrainCommand::Place(_)]));
+ }
+
+ #[test]
+ fn reservar_franja_desplaza_y_encoge_el_teselado() {
+ let mut d = desktop_with_screen(); // 1920×1080
+ open(&mut d, 1);
+ // Una sola ventana ocupa toda el área útil (smart gaps).
+ let cmds = open(&mut d, 1); // re-relayout
+ let p0 = places(&cmds)[0].rect;
+ assert_eq!(p0, Rect::new(0, 0, 1920, 1080));
+
+ // Reserva 40px arriba: la ventana arranca en y=40 y pierde 40 de alto.
+ let cmds = d.on_event(BodyEvent::OutputReserved {
+ id: 0,
+ top: 40,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ });
+ let p = places(&cmds)[0].rect;
+ assert_eq!(p, Rect::new(0, 40, 1920, 1040));
+
+ // Reserva izquierda en vez de arriba: desplaza en x y encoge el ancho.
+ let cmds = d.on_event(BodyEvent::OutputReserved {
+ id: 0,
+ top: 0,
+ bottom: 0,
+ left: 48,
+ right: 0,
+ });
+ let p = places(&cmds)[0].rect;
+ assert_eq!(p, Rect::new(48, 0, 1872, 1080));
+
+ // Liberar (cero en los cuatro) restaura el monitor entero.
+ let cmds = d.on_event(BodyEvent::OutputReserved {
+ id: 0,
+ top: 0,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ });
+ assert_eq!(places(&cmds)[0].rect, Rect::new(0, 0, 1920, 1080));
+ }
+
+ #[test]
+ fn a_spawn_keybind_becomes_a_spawn_command() {
+ let mut d = desktop_with_screen();
+ let cmds = d.on_event(BodyEvent::Keybind("Super+Shift+Return".into()));
+ assert_eq!(cmds, vec![BrainCommand::Spawn("foot".into())]);
+ }
+
+ #[test]
+ fn dragging_an_unknown_window_does_nothing() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ let cmds = d.on_event(BodyEvent::WindowFloatTo {
+ id: 99,
+ rect: Rect::new(0, 0, 100, 100),
+ });
+ assert!(cmds.is_empty());
+ }
+
+ #[test]
+ fn retitling_updates_the_registry_without_relayout() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ let cmds = d.on_event(BodyEvent::WindowRetitled {
+ id: 1,
+ title: "nuevo".into(),
+ });
+ assert!(cmds.is_empty());
+ assert_eq!(d.window_info(1).unwrap().title, "nuevo");
+ }
+
+ #[test]
+ fn an_unknown_keybind_does_nothing() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ assert!(d.on_event(BodyEvent::Keybind("Super+F12".into())).is_empty());
+ }
+
+ #[test]
+ fn quit_emits_a_shutdown() {
+ let mut d = desktop_with_screen();
+ assert_eq!(
+ d.on_event(BodyEvent::Keybind("Super+Shift+e".into())),
+ vec![BrainCommand::Shutdown]
+ );
+ }
+
+ // --- Multi-monitor -------------------------------------------------
+
+ /// Un escritorio con dos salidas 1920×1080.
+ fn desktop_with_two_outputs() -> Desktop {
+ let mut d = Desktop::new();
+ d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+ d.on_event(BodyEvent::OutputAdded { id: 1, width: 1920, height: 1080 });
+ d
+ }
+
+ #[test]
+ fn outputs_lay_side_by_side() {
+ let mut d = Desktop::new();
+ d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+ d.on_event(BodyEvent::OutputAdded { id: 1, width: 2560, height: 1440 });
+ assert_eq!(d.outputs().len(), 2);
+ // La segunda salida arranca donde acaba la primera.
+ assert_eq!(d.outputs()[1].rect.x, 1920);
+ }
+
+ #[test]
+ fn each_output_shows_a_distinct_workspace() {
+ let d = desktop_with_two_outputs();
+ assert_eq!(d.outputs()[0].workspace, 0);
+ assert_eq!(d.outputs()[1].workspace, 1);
+ }
+
+ #[test]
+ fn switching_to_a_workspace_shown_on_another_output_swaps_them() {
+ let mut d = desktop_with_two_outputs();
+ // La salida enfocada (0, ws 0) pide el ws 1, que muestra la 1 → swap.
+ d.apply(DesktopAction::SwitchWorkspace(1));
+ assert_eq!(d.outputs()[0].workspace, 1);
+ assert_eq!(d.outputs()[1].workspace, 0);
+ }
+
+ #[test]
+ fn focus_output_next_moves_the_focus_between_outputs() {
+ let mut d = desktop_with_two_outputs();
+ assert_eq!(d.active_index(), 0); // salida 0 → ws 0
+ d.apply(DesktopAction::FocusOutputNext);
+ assert_eq!(d.active_index(), 1); // salida 1 → ws 1
+ d.apply(DesktopAction::FocusOutputNext); // envuelve
+ assert_eq!(d.active_index(), 0);
+ }
+
+ #[test]
+ fn relayout_places_windows_on_every_output() {
+ let mut d = Desktop::new();
+ d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+ d.on_event(BodyEvent::OutputAdded { id: 1, width: 1280, height: 720 });
+ open(&mut d, 1); // en la salida 0 (ws 0)
+ d.apply(DesktopAction::FocusOutputNext);
+ let cmds = open(&mut d, 2); // en la salida 1 (ws 1)
+ let p = places(&cmds);
+ assert_eq!(p.len(), 2);
+ // Cada ventana cae en el rectángulo de su salida.
+ assert_eq!(p.iter().find(|x| x.id == 1).unwrap().rect.x, 0);
+ assert_eq!(p.iter().find(|x| x.id == 2).unwrap().rect.x, 1920);
+ }
+
+ #[test]
+ fn keyboard_focus_is_unique_across_outputs() {
+ let mut d = desktop_with_two_outputs();
+ open(&mut d, 1);
+ d.apply(DesktopAction::FocusOutputNext);
+ let cmds = open(&mut d, 2);
+ // Sólo una ventana con foco de teclado en todo el Place.
+ assert_eq!(places(&cmds).iter().filter(|p| p.focused).count(), 1);
+ }
+
+ #[test]
+ fn removing_an_output_keeps_its_windows_in_their_workspace() {
+ let mut d = desktop_with_two_outputs();
+ d.apply(DesktopAction::FocusOutputNext); // foco en la salida 1 (ws 1)
+ open(&mut d, 1); // en ws 1
+ d.on_event(BodyEvent::OutputRemoved { id: 1 });
+ // La ventana sigue registrada, en el ws 1.
+ assert!(d.window_info(1).is_some());
+ assert_eq!(d.workspace_loads()[1], 1);
+ assert_eq!(d.outputs().len(), 1);
+ }
+
+ // --- Scratchpad ----------------------------------------------------
+
+ #[test]
+ fn send_to_scratchpad_hides_the_focused_window() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2); // enfocada
+ d.apply(DesktopAction::SendToScratchpad);
+ assert_eq!(d.workspace_loads()[0], 1); // sólo queda la 1
+ assert!(d.window_info(2).is_some()); // sigue registrada
+ }
+
+ #[test]
+ fn toggle_scratchpad_shows_then_hides_the_stashed_window() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2);
+ d.apply(DesktopAction::SendToScratchpad); // guarda la 2
+ assert_eq!(d.workspace_loads()[0], 1);
+ // Toggle la invoca, flotando.
+ let cmds = d.apply(DesktopAction::ToggleScratchpad);
+ assert!(places(&cmds).iter().find(|x| x.id == 2).unwrap().floating);
+ assert_eq!(d.workspace_loads()[0], 2);
+ // Toggle de nuevo la oculta.
+ d.apply(DesktopAction::ToggleScratchpad);
+ assert_eq!(d.workspace_loads()[0], 1);
+ }
+
+ #[test]
+ fn a_scratchpad_window_follows_you_across_workspaces() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ d.apply(DesktopAction::SendToScratchpad);
+ d.apply(DesktopAction::ToggleScratchpad); // mostrada en el escritorio 1
+ assert_eq!(d.workspace_loads()[0], 1);
+ d.apply(DesktopAction::SwitchWorkspace(1)); // al escritorio 2
+ d.apply(DesktopAction::ToggleScratchpad); // estaba en el 1 → la trae al 2
+ assert_eq!(d.workspace_loads()[1], 1);
+ assert_eq!(d.workspace_loads()[0], 0);
+ }
+
+ #[test]
+ fn closing_a_stashed_window_drops_it_from_the_scratchpad() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ d.apply(DesktopAction::SendToScratchpad);
+ d.on_event(BodyEvent::WindowClosed { id: 1 });
+ // Ya no hay nada que invocar.
+ assert!(d.apply(DesktopAction::ToggleScratchpad).is_empty());
+ }
+
+ // --- Terminal dropdown (quake) ------------------------------------
+
+ #[test]
+ fn dropterm_lazy_spawns_when_absent() {
+ let mut d = desktop_with_screen();
+ // Sin terminal dropdown todavía: el toggle la crea con el comando
+ // de la config (por defecto, kitty con el app_id de la dropterm).
+ let cmds = d.apply(DesktopAction::ToggleDropterm);
+ let cmd = crate::config::Config::default().dropterm_cmd;
+ assert_eq!(cmds, vec![BrainCommand::Spawn(cmd)]);
+ }
+
+ #[test]
+ fn dropterm_opens_floating_top_anchored_and_focused() {
+ let mut d = desktop_with_screen(); // 1920×1080
+ let cmds = d.on_event(BodyEvent::WindowOpened {
+ id: 5,
+ app_id: super::DROPTERM_APP_ID.into(),
+ title: "dropterm".into(),
+ });
+ let p = places(&cmds).iter().find(|x| x.id == 5).unwrap();
+ assert!(p.floating);
+ assert_eq!(p.rect.x, 0);
+ assert_eq!(p.rect.w, 1920); // a todo el ancho
+ assert!(p.rect.h < 1080); // anclada arriba, no a pantalla completa
+ assert_eq!(d.focused_window(), Some(5));
+ }
+
+ #[test]
+ fn dropterm_toggles_hide_then_show_keeping_focus() {
+ let mut d = desktop_with_screen();
+ // Ya abierta (spawn + WindowOpened).
+ d.on_event(BodyEvent::WindowOpened {
+ id: 5,
+ app_id: super::DROPTERM_APP_ID.into(),
+ title: "t".into(),
+ });
+ assert_eq!(d.workspace_loads()[0], 1);
+ // Toggle la guarda.
+ d.apply(DesktopAction::ToggleDropterm);
+ assert_eq!(d.workspace_loads()[0], 0);
+ assert!(d.window_info(5).is_some()); // sigue registrada
+ // Toggle la baja de nuevo, flotando y enfocada.
+ let cmds = d.apply(DesktopAction::ToggleDropterm);
+ assert_eq!(d.workspace_loads()[0], 1);
+ assert!(places(&cmds).iter().find(|x| x.id == 5).unwrap().floating);
+ assert_eq!(d.focused_window(), Some(5));
+ }
+
+ #[test]
+ fn nearest_in_direction_picks_the_window_in_front() {
+ let from = Rect::new(0, 0, 100, 100); // centro (50,50)
+ let cands = vec![
+ (1, Rect::new(0, 0, 100, 100)), // la propia
+ (2, Rect::new(200, 0, 100, 100)), // a la derecha, enfrente
+ (3, Rect::new(200, 400, 100, 100)), // a la derecha pero muy abajo
+ (4, Rect::new(-200, 0, 100, 100)), // a la izquierda
+ ];
+ assert_eq!(nearest_in_direction(from, &cands, 1, Direction::Right), Some(2));
+ assert_eq!(nearest_in_direction(from, &cands, 1, Direction::Left), Some(4));
+ // Hacia arriba no hay nada (todas a la misma altura o abajo).
+ assert_eq!(nearest_in_direction(from, &cands, 1, Direction::Up), None);
+ }
+
+ #[test]
+ fn focus_dir_moves_focus_spatially_in_columns() {
+ let mut d = desktop_with_screen();
+ // Tres columnas: la 1 a la izquierda, la 3 a la derecha.
+ d.apply(DesktopAction::SetLayout(LayoutMode::Columns));
+ for id in [1, 2, 3] {
+ open(&mut d, id);
+ }
+ // La última abierta (3) queda enfocada, en la columna derecha.
+ assert_eq!(d.focused_window(), Some(3));
+ // Foco a la izquierda → la del medio, luego la primera.
+ d.apply(DesktopAction::FocusDir(Direction::Left));
+ assert_eq!(d.focused_window(), Some(2));
+ d.apply(DesktopAction::FocusDir(Direction::Left));
+ assert_eq!(d.focused_window(), Some(1));
+ // Más a la izquierda no hay nada: el foco no se mueve.
+ d.apply(DesktopAction::FocusDir(Direction::Left));
+ assert_eq!(d.focused_window(), Some(1));
+ // Y de vuelta a la derecha.
+ d.apply(DesktopAction::FocusDir(Direction::Right));
+ assert_eq!(d.focused_window(), Some(2));
+ }
+
+ #[test]
+ fn move_dir_swaps_the_focused_tile_with_its_neighbor() {
+ let mut d = desktop_with_screen();
+ d.apply(DesktopAction::SetLayout(LayoutMode::Columns));
+ for id in [1, 2, 3] {
+ open(&mut d, id);
+ }
+ assert_eq!(d.active_workspace().windows(), &[1, 2, 3]);
+ assert_eq!(d.focused_window(), Some(3)); // columna derecha
+ // Mover a la izquierda intercambia la 3 con su vecina (la 2).
+ d.apply(DesktopAction::MoveDir(Direction::Left));
+ assert_eq!(d.active_workspace().windows(), &[1, 3, 2]);
+ assert_eq!(d.focused_window(), Some(3)); // el foco acompaña a la movida
+ // Y a la derecha la devuelve.
+ d.apply(DesktopAction::MoveDir(Direction::Right));
+ assert_eq!(d.active_workspace().windows(), &[1, 2, 3]);
+ assert_eq!(d.focused_window(), Some(3));
+ }
+
+ #[test]
+ fn move_dir_nudges_a_floating_window() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2); // enfocada → flotar
+ d.apply(DesktopAction::ToggleFloat);
+ let r0 = d.active_workspace().floating_rect(2).unwrap();
+ d.apply(DesktopAction::MoveDir(Direction::Right));
+ let r1 = d.active_workspace().floating_rect(2).unwrap();
+ // Se desplazó float_step px a la derecha, sin cambiar tamaño.
+ assert_eq!(r1.x, r0.x + d.config().float_step());
+ assert_eq!((r1.w, r1.h), (r0.w, r0.h));
+ }
+
+ #[test]
+ fn resize_float_grows_and_shrinks_the_focused_floating_window() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1); // enfocada → flotar
+ d.apply(DesktopAction::ToggleFloat);
+ let r0 = d.active_workspace().floating_rect(1).unwrap();
+ let step = d.config().float_step();
+ d.apply(DesktopAction::ResizeFloatDir(Direction::Right));
+ assert_eq!(d.active_workspace().floating_rect(1).unwrap().w, r0.w + step);
+ d.apply(DesktopAction::ResizeFloatDir(Direction::Down));
+ assert_eq!(d.active_workspace().floating_rect(1).unwrap().h, r0.h + step);
+ // Sobre una teselada no hace nada.
+ open(&mut d, 2); // teselada, enfocada
+ assert!(d.apply(DesktopAction::ResizeFloatDir(Direction::Right)).is_empty());
+ }
+
+ #[test]
+ fn focus_and_send_to_output_dir_cross_monitors() {
+ let mut d = desktop_with_two_outputs(); // salida 0 a la izq, 1 a la der
+ open(&mut d, 1); // en la salida 0 (ws 0)
+ assert_eq!(d.active_index(), 0);
+ // Foco a la salida de la derecha → su escritorio (ws 1).
+ d.apply(DesktopAction::FocusOutputDir(Direction::Right));
+ assert_eq!(d.active_index(), 1);
+ // Volver a la izquierda.
+ d.apply(DesktopAction::FocusOutputDir(Direction::Left));
+ assert_eq!(d.active_index(), 0);
+ // Mandar la ventana 1 a la salida derecha → viaja al ws 1.
+ d.apply(DesktopAction::SendToOutputDir(Direction::Right));
+ assert_eq!(d.workspace_loads()[0], 0);
+ assert_eq!(d.workspace_loads()[1], 1);
+ }
+
+ #[test]
+ fn master_step_from_config_drives_grow_master() {
+ use crate::config::Config;
+ let mut d = desktop_with_screen();
+ d.set_config(Config::from_ron("( master_step: 0.1 )").unwrap());
+ open(&mut d, 1);
+ let r0 = d.active_workspace().params().master_ratio;
+ d.apply(DesktopAction::GrowMaster);
+ assert!((d.active_workspace().params().master_ratio - (r0 + 0.1)).abs() < 1e-6);
+ }
+
+ #[test]
+ fn clicked_focuses_even_with_focus_follows_mouse_off() {
+ use crate::config::Config;
+ let mut d = desktop_with_screen();
+ d.set_config(Config::from_ron("( focus_follows_mouse: false )").unwrap());
+ open(&mut d, 1);
+ open(&mut d, 2); // enfocada
+ // El hover ya no enfoca…
+ d.on_event(BodyEvent::PointerEntered { id: 1 });
+ assert_eq!(d.focused_window(), Some(2));
+ // …pero el click sí.
+ d.on_event(BodyEvent::Clicked { id: 1 });
+ assert_eq!(d.focused_window(), Some(1));
+ }
+
+ #[test]
+ fn clicked_jumps_to_the_workspace_holding_the_window() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2);
+ d.on_event(BodyEvent::Keybind("Super+Shift+3".into())); // la 2 al esc. 3
+ assert_eq!(d.active_index(), 0);
+ d.on_event(BodyEvent::Clicked { id: 2 });
+ assert_eq!(d.active_index(), 2);
+ assert_eq!(d.focused_window(), Some(2));
+ }
+
+ #[test]
+ fn dragging_a_tiled_window_swaps_with_the_window_under_the_pointer() {
+ let mut d = desktop_with_screen();
+ for id in [1, 2, 3] {
+ open(&mut d, id);
+ }
+ assert_eq!(d.active_workspace().windows(), &[1, 2, 3]);
+ // El centro de la tesela de la 3.
+ let o = d.outputs()[d.focused_output()];
+ let layout = d.active_workspace().layout(o.work_rect());
+ let r3 = layout.iter().find(|(id, _)| *id == 3).unwrap().1;
+ let (cx, cy) = (r3.x + r3.w / 2, r3.y + r3.h / 2);
+ // Arrastrar la 1 sobre la 3 las intercambia, el foco sigue a la 1.
+ d.on_event(BodyEvent::WindowDragged { id: 1, x: cx, y: cy });
+ assert_eq!(d.active_workspace().windows(), &[3, 2, 1]);
+ assert_eq!(d.focused_window(), Some(1));
+ }
+
+ #[test]
+ fn window_dragged_ignores_a_floating_window() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2); // enfocada → la flotamos
+ d.apply(DesktopAction::ToggleFloat);
+ let before = d.active_workspace().windows().to_vec();
+ let cmds = d.on_event(BodyEvent::WindowDragged { id: 2, x: 10, y: 10 });
+ assert!(cmds.is_empty());
+ assert_eq!(d.active_workspace().windows(), &before[..]);
+ }
+
+ #[test]
+ fn toggle_tiling_floats_all_then_restores() {
+ let mut d = desktop_with_screen();
+ for id in [1, 2, 3] {
+ open(&mut d, id);
+ }
+ assert!([1, 2, 3]
+ .iter()
+ .all(|&id| !d.active_workspace().is_floating(id)));
+ // Todo flota.
+ d.apply(DesktopAction::ToggleTiling);
+ assert!([1, 2, 3]
+ .iter()
+ .all(|&id| d.active_workspace().is_floating(id)));
+ // Y vuelve al teselado.
+ d.apply(DesktopAction::ToggleTiling);
+ assert!([1, 2, 3]
+ .iter()
+ .all(|&id| !d.active_workspace().is_floating(id)));
+ }
+
+ #[test]
+ fn reload_config_reseeds_params_and_re_sends_decorations() {
+ use crate::config::Config;
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ let cfg = Config::from_ron("( gap: 30, border_width: 5 )").unwrap();
+ let cmds = d.reload_config(cfg);
+ // El gap nuevo se sembró (el archivo manda).
+ assert_eq!(d.active_workspace().params().gap, 30);
+ // Y se devuelve un SetDecorations con el marco nuevo.
+ match cmds.as_slice() {
+ [BrainCommand::SetDecorations(dec)] => assert_eq!(dec.border_width, 5),
+ other => panic!("se esperaba un SetDecorations, no {other:?}"),
+ }
+ }
+
+ #[test]
+ fn reload_config_preserves_open_windows_and_focus() {
+ use crate::config::Config;
+ let mut d = desktop_with_screen();
+ for id in [1, 2, 3] {
+ open(&mut d, id);
+ }
+ d.apply(DesktopAction::FocusWindow(2));
+ d.reload_config(Config::from_ron("( gap: 14 )").unwrap());
+ // Las ventanas siguen ahí, el foco intacto — recargar no las pierde.
+ assert_eq!(d.active_workspace().windows(), &[1, 2, 3]);
+ assert_eq!(d.focused_window(), Some(2));
+ assert_eq!(d.active_workspace().params().gap, 14);
+ }
+
+ #[test]
+ fn reload_config_applies_focus_follows_mouse_live() {
+ use crate::config::Config;
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2); // enfocada
+ // Por defecto el hover enfoca.
+ d.on_event(BodyEvent::PointerEntered { id: 1 });
+ assert_eq!(d.focused_window(), Some(1));
+ // Recargar con foco-sigue-ratón apagado lo desactiva en vivo.
+ d.reload_config(Config::from_ron("( focus_follows_mouse: false )").unwrap());
+ d.on_event(BodyEvent::PointerEntered { id: 2 });
+ assert_eq!(d.focused_window(), Some(1)); // el hover ya no mueve el foco
+ }
+
+ #[test]
+ fn reload_config_applies_the_dropterm_command_live() {
+ use crate::config::Config;
+ let mut d = desktop_with_screen();
+ d.reload_config(
+ Config::from_ron("( dropterm_cmd: \"foot --app-id mirada.dropterm\" )").unwrap(),
+ );
+ let cmds = d.apply(DesktopAction::ToggleDropterm);
+ assert_eq!(
+ cmds,
+ vec![BrainCommand::Spawn("foot --app-id mirada.dropterm".into())]
+ );
+ }
+
+ #[test]
+ fn set_config_seeds_the_layout_params_of_every_workspace() {
+ use crate::config::Config;
+ let mut d = Desktop::new();
+ let cfg = Config::from_ron(r#"( gap: 20, master_ratio: 0.4, layout: "grid" )"#).unwrap();
+ d.set_config(cfg);
+ d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+ let p = d.active_workspace().params();
+ assert_eq!(p.gap, 20);
+ assert_eq!(p.mode, LayoutMode::Grid);
+ assert!((p.master_ratio - 0.4).abs() < 1e-6);
+ }
+
+ #[test]
+ fn focus_follows_mouse_can_be_disabled_by_config() {
+ use crate::config::Config;
+ let mut d = desktop_with_screen();
+ d.set_config(Config::from_ron("( focus_follows_mouse: false )").unwrap());
+ open(&mut d, 1);
+ open(&mut d, 2); // enfocada
+ // Con el foco-sigue-ratón apagado, pasar el puntero no cambia el foco.
+ d.on_event(BodyEvent::PointerEntered { id: 1 });
+ assert_eq!(d.focused_window(), Some(2));
+ }
+
+ #[test]
+ fn config_sets_the_dropterm_command_and_height() {
+ use crate::config::Config;
+ let mut d = desktop_with_screen(); // 1920×1080
+ d.set_config(
+ Config::from_ron("( dropterm_cmd: \"foot --app-id mirada.dropterm\", dropterm_height_pct: 30 )")
+ .unwrap(),
+ );
+ // El spawn perezoso usa el comando de la config.
+ let cmds = d.apply(DesktopAction::ToggleDropterm);
+ assert_eq!(
+ cmds,
+ vec![BrainCommand::Spawn("foot --app-id mirada.dropterm".into())]
+ );
+ // Y al abrirse, baja al 30 % del alto.
+ let cmds = d.on_event(BodyEvent::WindowOpened {
+ id: 9,
+ app_id: super::DROPTERM_APP_ID.into(),
+ title: "t".into(),
+ });
+ let p = places(&cmds).iter().find(|x| x.id == 9).unwrap();
+ assert_eq!(p.rect.h, 1080 * 30 / 100);
+ }
+
+ #[test]
+ fn a_client_fullscreen_request_is_honoured() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ open(&mut d, 2);
+ let cmds = d.on_event(BodyEvent::FullscreenRequest { id: 1, fullscreen: true });
+ assert!(places(&cmds).iter().find(|x| x.id == 1).unwrap().fullscreen);
+ // El cliente la suelta.
+ let cmds = d.on_event(BodyEvent::FullscreenRequest { id: 1, fullscreen: false });
+ assert!(!places(&cmds).iter().find(|x| x.id == 1).unwrap().fullscreen);
+ }
+
+ #[test]
+ fn a_fullscreen_request_for_an_unknown_window_does_nothing() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ assert!(d
+ .on_event(BodyEvent::FullscreenRequest { id: 99, fullscreen: true })
+ .is_empty());
+ }
+
+ #[test]
+ fn window_lines_show_a_stashed_window_as_workspace_zero() {
+ let mut d = desktop_with_screen();
+ open(&mut d, 1);
+ d.apply(DesktopAction::SendToScratchpad);
+ let line = d.window_lines().into_iter().find(|l| l.id == 1).unwrap();
+ assert_eq!(line.workspace, 0);
+ }
+}
diff --git a/02_ruway/mirada/mirada-brain/src/keymap.rs b/02_ruway/mirada/mirada-brain/src/keymap.rs
new file mode 100644
index 0000000..443134a
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/keymap.rs
@@ -0,0 +1,332 @@
+//! El keymap configurable — atajos del escritorio en RON, recargables en
+//! caliente.
+//!
+//! # Dónde vive el keymap
+//!
+//! Sólo en el Cerebro. El Cuerpo (`mirada-compositor`) **nunca** ve este
+//! mapa: lo único que recibe es la lista de cadenas a interceptar
+//! ([`grab_list`](Keymap::grab_list)) dentro de un
+//! [`BrainCommand::GrabKeys`](mirada_protocol::BrainCommand::GrabKeys). El
+//! Cuerpo hace un `Vec::contains` ciego y devuelve la combinación pulsada
+//! como [`BodyEvent::Keybind`](mirada_protocol::BodyEvent::Keybind); es el
+//! [`Desktop`](crate::Desktop) quien la traduce a una
+//! [`DesktopAction`]. Esa separación —*qué* interceptar vs. *qué
+//! significa*— es la que hace innecesario cualquier candado o `Arc`:
+//! el mapa es monohilo aquí y la lista viaja de golpe en un solo mensaje.
+//!
+//! # Persistencia
+//!
+//! En disco es RON de texto (`~/.config/mirada/keymap.ron`), editable a
+//! mano y versionable. El cable sólo lleva la lista de cadenas; no hay
+//! format binario de configuración. Hay un único ejecutable que hace de
+//! "configurador": la app `mirada`, que carga este archivo al arrancar.
+//!
+//! # Recarga en caliente
+//!
+//! [`Keymap::watch`] devuelve un [`KeymapWatch`] que vigila el archivo;
+//! cuando cambia, el dueño del [`Desktop`](crate::Desktop) recarga el
+//! keymap, llama a [`Desktop::set_keymap`](crate::Desktop::set_keymap) y
+//! reenvía el `GrabKeys` resultante. Sin reiniciar nada.
+
+use std::collections::BTreeMap;
+use std::fmt;
+use std::io;
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+use crate::action::{default_keymap, DesktopAction};
+use crate::watch::FileWatch;
+
+/// Atajos del escritorio: combinación canónica → acción.
+///
+/// La combinación es la cadena que canoniza el Cuerpo (`"Super+Shift+j"`,
+/// `"Super+space"`…). El keymap es lo único que la traduce a una
+/// [`DesktopAction`].
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Keymap {
+ bindings: BTreeMap<String, DesktopAction>,
+}
+
+impl Default for Keymap {
+ /// El keymap por defecto, estilo *tiling WM* (ver [`default_keymap`]).
+ fn default() -> Self {
+ Self {
+ bindings: default_keymap().into_iter().collect(),
+ }
+ }
+}
+
+impl Keymap {
+ /// Construye un keymap a partir de pares `(combinación, acción)`.
+ pub fn from_pairs(pairs: impl IntoIterator<Item = (String, DesktopAction)>) -> Self {
+ Self {
+ bindings: pairs.into_iter().collect(),
+ }
+ }
+
+ /// La acción asociada a una combinación, si la hay.
+ pub fn lookup(&self, combo: &str) -> Option<DesktopAction> {
+ self.bindings.get(combo).cloned()
+ }
+
+ /// Las combinaciones a interceptar — el contenido de un `GrabKeys`.
+ pub fn grab_list(&self) -> Vec<String> {
+ self.bindings.keys().cloned().collect()
+ }
+
+ /// Todos los atajos, en orden de combinación.
+ pub fn bindings(&self) -> &BTreeMap<String, DesktopAction> {
+ &self.bindings
+ }
+
+ /// Cuántos atajos hay.
+ pub fn len(&self) -> usize {
+ self.bindings.len()
+ }
+
+ /// `true` si no hay ningún atajo.
+ pub fn is_empty(&self) -> bool {
+ self.bindings.is_empty()
+ }
+
+ // --- RON ----------------------------------------------------------
+
+ /// Parsea un keymap desde el texto RON de un archivo de configuración.
+ pub fn from_ron(text: &str) -> Result<Keymap, KeymapError> {
+ let file: KeymapFile = ron::from_str(text)
+ .map_err(|e| KeymapError::Parse(format!("RON inválido: {e}")))?;
+ let mut bindings = BTreeMap::new();
+ for (combo, action) in file.bindings {
+ let parsed = action
+ .parse::<DesktopAction>()
+ .map_err(|e| KeymapError::Parse(format!("atajo \"{combo}\": {e}")))?;
+ bindings.insert(combo, parsed);
+ }
+ Ok(Keymap { bindings })
+ }
+
+ /// Serializa el keymap a RON (sin la cabecera de documentación).
+ pub fn to_ron(&self) -> String {
+ let file = KeymapFile {
+ bindings: self
+ .bindings
+ .iter()
+ .map(|(k, v)| (k.clone(), v.to_string()))
+ .collect(),
+ };
+ ron::ser::to_string_pretty(&file, ron::ser::PrettyConfig::default())
+ .expect("un KeymapFile de cadenas siempre serializa")
+ }
+
+ // --- Disco --------------------------------------------------------
+
+ /// La ruta canónica del keymap del usuario: `~/.config/mirada/keymap.ron`.
+ /// `None` si no se puede determinar el directorio de configuración.
+ pub fn default_path() -> Option<PathBuf> {
+ directories::ProjectDirs::from("", "", "mirada")
+ .map(|d| d.config_dir().join("keymap.ron"))
+ }
+
+ /// Carga un keymap desde un archivo RON.
+ pub fn load(path: &Path) -> Result<Keymap, KeymapError> {
+ let text = std::fs::read_to_string(path)?;
+ Keymap::from_ron(&text)
+ }
+
+ /// El keymap como RON con la cabecera de documentación — exactamente
+ /// lo que [`save`](Keymap::save) escribe en disco.
+ pub fn documented_ron(&self) -> String {
+ format!("{KEYMAP_HEADER}\n{}", self.to_ron())
+ }
+
+ /// Escribe el keymap a `path` como RON documentado (con cabecera de
+ /// comentarios), creando el directorio padre si falta.
+ pub fn save(&self, path: &Path) -> Result<(), KeymapError> {
+ if let Some(dir) = path.parent() {
+ std::fs::create_dir_all(dir)?;
+ }
+ std::fs::write(path, self.documented_ron())?;
+ Ok(())
+ }
+
+ /// Carga el keymap del usuario con un fallback amable:
+ ///
+ /// - si el archivo no existe, escribe uno por defecto documentado y lo
+ /// devuelve (así el usuario lo descubre y lo puede editar);
+ /// - si existe pero está corrupto, avisa por `stderr` y devuelve el
+ /// keymap por defecto **sin tocar el archivo** (no se pierde el
+ /// trabajo del usuario por un error de sintaxis).
+ pub fn load_or_init(path: &Path) -> Keymap {
+ if path.exists() {
+ match Keymap::load(path) {
+ Ok(km) => km,
+ Err(e) => {
+ eprintln!(
+ "mirada · keymap «{}» inválido ({e}); uso el de por defecto.",
+ path.display()
+ );
+ Keymap::default()
+ }
+ }
+ } else {
+ let km = Keymap::default();
+ match km.save(path) {
+ Ok(()) => eprintln!("mirada · keymap inicial escrito en {}", path.display()),
+ Err(e) => eprintln!("mirada · no pude escribir el keymap inicial: {e}"),
+ }
+ km
+ }
+ }
+
+ /// Vigila el archivo del keymap para recargarlo en caliente — un
+ /// [`FileWatch`] genérico, igual que la config y las reglas.
+ pub fn watch(path: &Path) -> notify::Result<KeymapWatch> {
+ FileWatch::new(path)
+ }
+}
+
+/// Vigía del archivo de keymap para la recarga en caliente. Hoy es un
+/// alias del [`FileWatch`] genérico — se conserva el nombre por
+/// compatibilidad con quien lo nombra (`mirada-compositor`).
+pub type KeymapWatch = FileWatch;
+
+/// La forma en disco del keymap — un mapa de cadenas. Las acciones van
+/// como texto (`"layout:grid"`) y no como enum, para que el RON sea
+/// trivial y los errores se reporten atajo a atajo.
+#[derive(Serialize, Deserialize)]
+struct KeymapFile {
+ bindings: BTreeMap<String, String>,
+}
+
+/// La cabecera de comentarios del archivo que escribe [`Keymap::save`].
+const KEYMAP_HEADER: &str = "\
+// keymap de mirada — atajos del escritorio (carmen).
+//
+// Formato: \"Combinación\": \"acción\"
+// La combinación la canoniza el compositor: Super, Ctrl, Shift, Alt y la
+// tecla, en ese orden (p. ej. \"Super+Shift+j\", \"Super+space\").
+//
+// Acciones:
+// focus-next / focus-prev mueve el foco (cíclico)
+// focus-left/right/up/down mueve el foco espacial (Super+flechas)
+// move-forward / move-backward reordena la ventana enfocada (orden)
+// move-left/right/up/down mueve la ventana por geometría (Super+Shift+flechas)
+// close-focused cierra la enfocada
+// toggle-float alterna flotante / teselada (una)
+// toggle-tiling alterna todo el escritorio teselado/flotante
+// toggle-fullscreen alterna pantalla completa
+// send-to-scratchpad guarda la enfocada en el scratchpad
+// toggle-scratchpad invoca / oculta la del scratchpad
+// toggle-dropterm baja / sube la terminal dropdown (quake)
+// cycle-layout siguiente modo de teselado
+// layout:<modo> master-stack | centered-master | spiral
+// grid | columns | rows | monocle
+// grow-master / shrink-master redimensiona el área maestra
+// inc-master / dec-master nº de ventanas maestras (nmaster)
+// promote-to-master la enfocada al puesto maestro (rota)
+// swap-master intercambia la enfocada con la maestra (sólo esas dos)
+// resize-float-left/right/up/down redimensiona la flotante enfocada
+// focus-output-next pasa el foco al siguiente monitor
+// focus-output-left/right/up/down foco al monitor vecino (por geometría)
+// send-to-output-left/right/up/down manda la enfocada al monitor vecino
+// workspace:N activa el escritorio N (1..9)
+// send-to-workspace:N manda la enfocada al escritorio N (sin saltar)
+// move-to-workspace:N manda la enfocada al escritorio N y salta allí
+// spawn:<comando> lanza un programa (p. ej. spawn:foot)
+// quit apaga el compositor
+//
+// Edita y guarda: mirada recarga el keymap en caliente, sin reiniciar.";
+
+/// Un fallo al cargar o guardar un keymap.
+#[derive(Debug)]
+pub enum KeymapError {
+ /// El RON no parsea, o una acción no se reconoce. El mensaje ya está
+ /// formateado para mostrarse al usuario.
+ Parse(String),
+ /// Fallo de E/S al leer o escribir el archivo.
+ Io(io::Error),
+}
+
+impl fmt::Display for KeymapError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ KeymapError::Parse(msg) => f.write_str(msg),
+ KeymapError::Io(e) => write!(f, "E/S: {e}"),
+ }
+ }
+}
+
+impl std::error::Error for KeymapError {}
+
+impl From<io::Error> for KeymapError {
+ fn from(e: io::Error) -> Self {
+ KeymapError::Io(e)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use mirada_layout::LayoutMode;
+
+ #[test]
+ fn the_default_keymap_round_trips_through_ron() {
+ let km = Keymap::default();
+ let back = Keymap::from_ron(&km.to_ron()).unwrap();
+ assert_eq!(km, back);
+ }
+
+ #[test]
+ fn the_saved_file_carries_the_documentation_header() {
+ let km = Keymap::default();
+ let written = km.documented_ron();
+ // La cabecera son comentarios — RON los ignora al reparsear.
+ assert!(written.starts_with("// keymap de mirada"));
+ assert_eq!(Keymap::from_ron(&written).unwrap(), km);
+ }
+
+ #[test]
+ fn grab_list_is_exactly_the_set_of_bound_combos() {
+ let km = Keymap::default();
+ let grabs = km.grab_list();
+ assert_eq!(grabs.len(), km.len());
+ assert!(grabs.contains(&"Super+j".to_string()));
+ assert!(grabs.contains(&"Super+Shift+e".to_string()));
+ }
+
+ #[test]
+ fn lookup_resolves_a_default_binding() {
+ let km = Keymap::default();
+ assert_eq!(km.lookup("Super+q"), Some(DesktopAction::CloseFocused));
+ assert_eq!(km.lookup("Super+t"), Some(DesktopAction::SetLayout(LayoutMode::MasterStack)));
+ assert_eq!(km.lookup("Super+sin-asignar"), None);
+ }
+
+ #[test]
+ fn a_custom_keymap_parses_from_ron() {
+ let ron = r#"(
+ bindings: {
+ "Alt+Return": "cycle-layout",
+ "Alt+x": "close-focused",
+ "Alt+3": "workspace:3",
+ },
+ )"#;
+ let km = Keymap::from_ron(ron).unwrap();
+ assert_eq!(km.len(), 3);
+ assert_eq!(km.lookup("Alt+Return"), Some(DesktopAction::CycleLayout));
+ assert_eq!(km.lookup("Alt+3"), Some(DesktopAction::SwitchWorkspace(2)));
+ }
+
+ #[test]
+ fn an_unknown_action_names_the_offending_binding() {
+ let ron = r#"( bindings: { "Super+z": "fly-away" } )"#;
+ let err = Keymap::from_ron(ron).unwrap_err().to_string();
+ assert!(err.contains("Super+z"), "el error debe nombrar el atajo: {err}");
+ }
+
+ #[test]
+ fn malformed_ron_is_rejected() {
+ assert!(Keymap::from_ron("esto no es ron").is_err());
+ }
+}
diff --git a/02_ruway/mirada/mirada-brain/src/lib.rs b/02_ruway/mirada/mirada-brain/src/lib.rs
new file mode 100644
index 0000000..5b69ca8
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/lib.rs
@@ -0,0 +1,42 @@
+//! `mirada-brain` — el orquestador de escritorio del compositor.
+//!
+//! Es el "Cerebro" de mirada sin pantalla: mantiene el estado del
+//! escritorio (salidas, escritorios virtuales, ventanas, foco), consume
+//! los [`BodyEvent`]s que reporta el Cuerpo y produce los
+//! [`BrainCommand`]s que el Cuerpo aplica.
+//!
+//! Es agnóstico de GPUI y de `smithay`: una app GPUI sólo lo *envuelve*
+//! para pintar un HUD y para mover los bytes por el cable de
+//! [`mirada_protocol`]. Toda la lógica vive aquí y es determinista —
+//! la misma secuencia de eventos da siempre el mismo estado.
+//!
+//! - [`action`] — las acciones de escritorio y el mapa de teclas.
+//! - [`config`] — la [`Config`] general del WM (dropterm, teselado, foco).
+//! - [`desktop`] — el [`Desktop`]: el estado y el bucle `evento → comandos`.
+//! - [`keymap`] — el [`Keymap`] configurable en RON, recargable en caliente.
+//! - [`rules`] — las [`Rules`] de ventana (escritorio/flotante por `app_id`).
+//! - [`ctl`] — el API de control externo (`mirada-ctl`, taskbars, scripts).
+
+#![forbid(unsafe_code)]
+
+pub mod action;
+pub mod config;
+pub mod ctl;
+pub mod desktop;
+pub mod keymap;
+pub mod rules;
+pub mod watch;
+
+pub use action::{default_keymap, DesktopAction, WORKSPACE_COUNT};
+pub use config::{Config, MenuEntry, OutputOverride, ZoneCfg, DROPTERM_APP_ID};
+pub use ctl::{CtlConn, CtlReply, CtlRequest, CtlServer, WindowLine};
+pub use desktop::{Desktop, Output, WindowInfo};
+pub use keymap::{Keymap, KeymapError, KeymapWatch};
+pub use rules::{Rule, RuleOutcome, Rules};
+pub use watch::FileWatch;
+
+pub use mirada_layout::{
+ disponer, envolvente, wallpaper_dst_rect, Disposicion, LayoutMode, LayoutParams, Rect,
+ WallpaperFit, WindowId, Workspace, ZoneFrac,
+};
+pub use mirada_protocol::{BodyEvent, BrainCommand, Decorations, OutputId, WindowPlacement};
diff --git a/02_ruway/mirada/mirada-brain/src/rules.rs b/02_ruway/mirada/mirada-brain/src/rules.rs
new file mode 100644
index 0000000..ce62f8d
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/rules.rs
@@ -0,0 +1,216 @@
+//! Reglas de ventana — config declarativa que decide, al abrirse una
+//! ventana, a qué escritorio va y si flota.
+//!
+//! Mismo patrón que [`crate::keymap`]: RON de texto en
+//! `~/.config/mirada/rules.ron`, que el [`Desktop`](crate::Desktop)
+//! consulta en cada `WindowOpened` — el evento ya trae `app_id` y
+//! `title`. Una regla casa por subcadena (sin distinguir mayúsculas);
+//! gana la primera que case.
+
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+/// Una regla: criterio de coincidencia + qué aplicar.
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct Rule {
+ /// Subcadena que debe contener el `app_id`; vacía = casa con cualquiera.
+ #[serde(default)]
+ pub app_id: String,
+ /// Subcadena que debe contener el título; vacía = cualquiera.
+ #[serde(default)]
+ pub title: String,
+ /// Escritorio de destino (1-based); `0` = no moverla.
+ #[serde(default)]
+ pub workspace: usize,
+ /// Abrir la ventana flotando.
+ #[serde(default)]
+ pub floating: bool,
+}
+
+impl Rule {
+ /// `true` si la regla casa con una ventana de este `app_id`/`title`.
+ fn matches(&self, app_id: &str, title: &str) -> bool {
+ let app_ok = self.app_id.is_empty() || contains_ci(app_id, &self.app_id);
+ let title_ok = self.title.is_empty() || contains_ci(title, &self.title);
+ app_ok && title_ok
+ }
+}
+
+/// `true` si `haystack` contiene `needle`, sin distinguir mayúsculas.
+fn contains_ci(haystack: &str, needle: &str) -> bool {
+ haystack.to_lowercase().contains(&needle.to_lowercase())
+}
+
+/// Qué hacer con una ventana recién abierta, según las reglas.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct RuleOutcome {
+ /// Escritorio de destino, ya como índice 0-based. `None` = el activo.
+ pub workspace: Option<usize>,
+ /// Abrir flotando.
+ pub floating: bool,
+}
+
+/// El conjunto de reglas de ventana del usuario.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Rules {
+ #[serde(default)]
+ rules: Vec<Rule>,
+}
+
+impl Rules {
+ /// Construye un conjunto de reglas a partir de una lista.
+ pub fn new(rules: Vec<Rule>) -> Self {
+ Self { rules }
+ }
+
+ /// Resuelve qué hacer con una ventana — gana la primera regla que case.
+ pub fn resolve(&self, app_id: &str, title: &str) -> RuleOutcome {
+ for r in &self.rules {
+ if r.matches(app_id, title) {
+ return RuleOutcome {
+ workspace: (r.workspace >= 1).then(|| r.workspace - 1),
+ floating: r.floating,
+ };
+ }
+ }
+ RuleOutcome::default()
+ }
+
+ /// Cuántas reglas hay.
+ pub fn len(&self) -> usize {
+ self.rules.len()
+ }
+
+ /// `true` si no hay ninguna regla.
+ pub fn is_empty(&self) -> bool {
+ self.rules.is_empty()
+ }
+
+ /// Parsea las reglas desde el texto RON de un archivo de config.
+ pub fn from_ron(text: &str) -> Result<Rules, String> {
+ ron::from_str(text).map_err(|e| format!("RON inválido: {e}"))
+ }
+
+ /// La ruta canónica de las reglas: `~/.config/mirada/rules.ron`.
+ pub fn default_path() -> Option<PathBuf> {
+ directories::ProjectDirs::from("", "", "mirada")
+ .map(|d| d.config_dir().join("rules.ron"))
+ }
+
+ /// Carga las reglas de un archivo RON.
+ pub fn load(path: &Path) -> Result<Rules, String> {
+ let text = std::fs::read_to_string(path).map_err(|e| format!("E/S: {e}"))?;
+ Rules::from_ron(&text)
+ }
+
+ /// Vigila el archivo de reglas para recargarlo en caliente.
+ pub fn watch(path: &Path) -> notify::Result<crate::watch::FileWatch> {
+ crate::watch::FileWatch::new(path)
+ }
+
+ /// Carga las reglas del usuario con un fallback amable: si el archivo
+ /// no existe, escribe una plantilla documentada y devuelve un
+ /// conjunto vacío; si está corrupto, avisa y devuelve vacío.
+ pub fn load_or_default(path: &Path) -> Rules {
+ if path.exists() {
+ match Rules::load(path) {
+ Ok(r) => r,
+ Err(e) => {
+ eprintln!(
+ "mirada · reglas «{}» inválidas ({e}); las ignoro.",
+ path.display()
+ );
+ Rules::default()
+ }
+ }
+ } else {
+ if let Some(dir) = path.parent() {
+ let _ = std::fs::create_dir_all(dir);
+ }
+ match std::fs::write(path, RULES_TEMPLATE) {
+ Ok(()) => eprintln!("mirada · plantilla de reglas escrita en {}", path.display()),
+ Err(e) => eprintln!("mirada · no pude escribir la plantilla de reglas: {e}"),
+ }
+ Rules::default()
+ }
+ }
+}
+
+/// La plantilla que se escribe la primera vez — sin reglas, con ejemplos
+/// comentados para que el usuario los descubra.
+const RULES_TEMPLATE: &str = "\
+// Reglas de ventana de mirada — qué hacer con una ventana al abrirse.
+//
+// Cada regla casa por subcadena de `app_id` y/o `title` (sin distinguir
+// mayúsculas; cadena vacía = cualquiera) y aplica un destino: `workspace`
+// (1..9; 0 = no mover) y/o `floating`. Gana la primera regla que case.
+//
+// Descomenta y edita los ejemplos:
+(
+ rules: [
+ // (app_id: \"pavucontrol\", floating: true),
+ // (app_id: \"firefox\", workspace: 2),
+ // (title: \"Picture-in-Picture\", floating: true),
+ ],
+)
+";
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_template_parses_to_an_empty_rule_set() {
+ assert!(Rules::from_ron(RULES_TEMPLATE).unwrap().is_empty());
+ }
+
+ #[test]
+ fn rules_parse_from_ron_with_omitted_fields() {
+ let ron = r#"(
+ rules: [
+ (app_id: "pavucontrol", floating: true),
+ (app_id: "firefox", workspace: 2),
+ ],
+ )"#;
+ assert_eq!(Rules::from_ron(ron).unwrap().len(), 2);
+ }
+
+ #[test]
+ fn resolve_sends_a_match_to_its_workspace() {
+ let r = Rules::from_ron(r#"( rules: [ (app_id: "firefox", workspace: 3) ] )"#).unwrap();
+ let out = r.resolve("org.mozilla.firefox", "");
+ assert_eq!(out.workspace, Some(2)); // 3 (1-based) -> índice 2
+ assert!(!out.floating);
+ }
+
+ #[test]
+ fn resolve_matches_app_id_case_insensitively_by_substring() {
+ let r = Rules::from_ron(r#"( rules: [ (app_id: "FIREFOX", floating: true) ] )"#).unwrap();
+ assert!(r.resolve("org.mozilla.firefox", "").floating);
+ }
+
+ #[test]
+ fn resolve_matches_by_title() {
+ let r =
+ Rules::from_ron(r#"( rules: [ (title: "Picture-in-Picture", floating: true) ] )"#)
+ .unwrap();
+ assert!(r.resolve("cualquiera", "YouTube — Picture-in-Picture").floating);
+ assert!(!r.resolve("cualquiera", "ventana normal").floating);
+ }
+
+ #[test]
+ fn the_first_matching_rule_wins() {
+ let r = Rules::from_ron(
+ r#"( rules: [ (app_id: "term", workspace: 1), (app_id: "term", workspace: 5) ] )"#,
+ )
+ .unwrap();
+ assert_eq!(r.resolve("term", "").workspace, Some(0));
+ }
+
+ #[test]
+ fn no_match_yields_the_default_outcome() {
+ let r = Rules::from_ron(r#"( rules: [ (app_id: "firefox", workspace: 2) ] )"#).unwrap();
+ assert_eq!(r.resolve("xterm", ""), RuleOutcome::default());
+ }
+}
diff --git a/02_ruway/mirada/mirada-brain/src/watch.rs b/02_ruway/mirada/mirada-brain/src/watch.rs
new file mode 100644
index 0000000..961626e
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/watch.rs
@@ -0,0 +1,87 @@
+//! Vigía genérico de un archivo de configuración, para la recarga en
+//! caliente. Lo comparten el keymap, la config y las reglas: los tres son
+//! RON en `~/.config/mirada/` que el usuario edita a mano y mirada
+//! recarga sin reiniciar.
+//!
+//! El patrón: se vigila el **directorio** (los editores reescriben el
+//! archivo por *rename*, no editándolo en sitio) y se filtra al archivo de
+//! interés. Una ráfaga de eventos de un solo guardado se *coalesce* en un
+//! único [`changed`](FileWatch::changed).
+
+use std::path::Path;
+use std::sync::mpsc;
+
+/// Vigía de un archivo para la recarga en caliente.
+///
+/// Mantenlo vivo mientras quieras recargas; al soltarlo, la vigilancia
+/// cesa. Consulta [`changed`](FileWatch::changed) en tu bucle de eventos.
+pub struct FileWatch {
+ _watcher: notify::RecommendedWatcher,
+ rx: mpsc::Receiver<()>,
+}
+
+impl FileWatch {
+ /// Empieza a vigilar `path`. Vigila su directorio padre (si existe) y
+ /// filtra los eventos al archivo concreto, así capta los guardados por
+ /// *rename* de los editores.
+ pub fn new(path: &Path) -> notify::Result<FileWatch> {
+ use notify::{RecursiveMode, Watcher};
+
+ let target = path.to_path_buf();
+ let (tx, rx) = mpsc::channel();
+ let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
+ if let Ok(event) = res {
+ if event.paths.iter().any(|p| p == &target) {
+ let _ = tx.send(());
+ }
+ }
+ })?;
+ let dir = path.parent().filter(|d| d.exists());
+ watcher.watch(dir.unwrap_or(path), RecursiveMode::NonRecursive)?;
+ Ok(FileWatch { _watcher: watcher, rx })
+ }
+
+ /// `true` si el archivo cambió desde la última consulta. Coalesce una
+ /// ráfaga de eventos (un guardado dispara varios) en un solo `true`.
+ pub fn changed(&self) -> bool {
+ self.rx.try_iter().count() > 0
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::time::Duration;
+
+ /// Un `FileWatch` detecta una escritura del archivo vigilado. Es un test
+ /// de integración con el SO (inotify): si el entorno no provee un backend
+ /// de vigilancia (algunos sandboxes), `FileWatch::new` falla y el test se
+ /// salta — no queremos un test frágil que rompa el smoke del workspace.
+ #[test]
+ fn detects_a_write_to_the_watched_file() {
+ let dir = std::env::temp_dir().join(format!("mirada-watch-{}", std::process::id()));
+ let _ = std::fs::create_dir_all(&dir);
+ let file = dir.join("config.ron");
+ std::fs::write(&file, b"(\n)\n").unwrap();
+
+ let Ok(watch) = FileWatch::new(&file) else {
+ eprintln!("watch: sin backend de vigilancia en este entorno; salto el test.");
+ let _ = std::fs::remove_dir_all(&dir);
+ return;
+ };
+ assert!(!watch.changed(), "recién creado: nada que reportar todavía");
+
+ // Reescribe el archivo y espera (acotado) a que el evento llegue.
+ std::fs::write(&file, b"( gap: 12 )\n").unwrap();
+ let mut seen = false;
+ for _ in 0..60 {
+ if watch.changed() {
+ seen = true;
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(50)); // hasta ~3 s
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+ assert!(seen, "el FileWatch no reportó la escritura en 3 s");
+ }
+}
diff --git a/02_ruway/mirada/mirada-compositor/Cargo.toml b/02_ruway/mirada/mirada-compositor/Cargo.toml
new file mode 100644
index 0000000..c72a596
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "mirada-compositor"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "mirada — el Cuerpo del compositor: un compositor Wayland teselante sobre smithay (backend winit, nested). Tesela con un Cerebro embebido o uno externo por mirada-link."
+
+[[bin]]
+name = "mirada-compositor"
+path = "src/main.rs"
+
+[dependencies]
+mirada-brain = { path = "../mirada-brain" }
+mirada-body = { path = "../mirada-body" }
+mirada-link = { path = "../mirada-link" }
+auth-core = { path = "../../../shared/auth/auth-core" }
+nix = { workspace = true }
+smithay = "0.7"
+# Rasterizado de texto sobre CPU (etiquetas: barra de título, menú). Es un
+# rasterizador puro-Rust ligero; smithay sube el búfer RGBA como textura.
+ab_glyph = "0.2"
+# Decodifica y escala el wallpaper (PNG/JPEG/WebP) para componerlo en el fondo.
+image = { workspace = true }
diff --git a/02_ruway/mirada/mirada-compositor/LEEME.md b/02_ruway/mirada/mirada-compositor/LEEME.md
new file mode 100644
index 0000000..ee1c11e
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/LEEME.md
@@ -0,0 +1,221 @@
+# mirada-compositor — el Cuerpo de carmen
+
+Un compositor Wayland teselante real, sobre [`smithay`]. Es el **Cuerpo**
+de la arquitectura Cerebro↔Cuerpo de `mirada` (ver
+`crates/modules/mirada/SDD.md`): habla el protocolo Wayland con los
+clientes, compone sus superficies y aplica la geometría que decide el
+Cerebro.
+
+Tiene **dos backends gráficos**:
+
+- **`winit`** — corre **anidado**, como una ventana dentro de tu sesión
+ gráfica actual (X11 o Wayland). Para desarrollar y probar sin dejar el
+ escritorio.
+- **`drm`** — corre **nativo** sobre una TTY, sin sesión anfitriona:
+ toma la GPU (DRM/KMS/GBM/EGL), el teclado (`libinput`) y la pantalla
+ entera. Es carmen como tu escritorio de verdad.
+
+Sin argumentos elige solo: con `DISPLAY`/`WAYLAND_DISPLAY` → `winit`;
+sin ellos → `drm`. O fuérzalo: `mirada-compositor --winit` / `--drm`.
+
+La bandera `--greeter` (ortogonal al backend) arranca el compositor como
+gestor de login — ver **Modo greeter (DM)** más abajo.
+
+## Backends
+
+### winit — anidado
+
+```sh
+cargo run -p mirada-compositor -- --winit
+```
+
+Necesita una sesión gráfica anfitriona (X11 o Wayland) donde dibujar su
+ventana; sin ella aborta con un mensaje que lo explica.
+
+### drm — nativo sobre TTY
+
+```sh
+cargo run -p mirada-compositor -- --drm
+```
+
+Corre directo sobre el hardware. Requiere una **TTY** (`Ctrl+Alt+F3`),
+una GPU con `/dev/dri`, y `seatd` o `logind` para la sesión. Toma la
+pantalla completa; sal con `Super+Shift+e` o `Ctrl+C`.
+
+Lleva teclado y ratón por `libinput`: el foco sigue al puntero y los
+clics y la rueda llegan a la ventana que tienes debajo. El cursor toma
+la forma que pide el cliente (la «I» sobre texto, una mano…) y cae a un
+cuadrado por defecto sobre el escritorio. **`Super`+arrastre** con el
+botón izquierdo mueve una ventana, con el derecho la redimensiona — al
+arrastrarla, la ventana pasa a flotar. Cada ventana lleva un marco
+fino: azul la que tiene el foco, gris las demás.
+
+- `MIRADA_STARTUP=<cmd>` — lanza una app al arrancar (`MIRADA_STARTUP=foot`).
+- `MIRADA_DRM_TIMEOUT=<s>` — cierra el compositor solo tras N segundos
+ (0 o sin definir = sin tope).
+
+## Como sesión de escritorio
+
+Para usar carmen como tu escritorio de verdad — entrar a una sesión, no
+sólo probarlo:
+
+1. Compila e instala los binarios en el `PATH`:
+
+ ```sh
+ cargo build --release -p mirada-compositor -p mirada-ctl -p mirada-launcher
+ sudo install -m755 target/release/mirada-compositor \
+ target/release/mirada-ctl target/release/mirada-launcher /usr/local/bin/
+ sudo install -m755 session/mirada-session /usr/local/bin/
+ ```
+
+2. Arranca desde una TTY:
+
+ ```sh
+ mirada-session
+ ```
+
+ O regístralo en un gestor de login copiando `session/carmen.desktop`
+ a `/usr/share/wayland-sessions/` — aparecerá carmen como sesión.
+
+3. **Autoarranque** — los programas que quieras al iniciar van en
+ `~/.config/mirada/autostart`, uno por línea (`#` comenta). Tienes un
+ ejemplo en `session/autostart.example`:
+
+ ```sh
+ mkdir -p ~/.config/mirada
+ cp 02_ruway/mirada/mirada-compositor/session/autostart.example \
+ ~/.config/mirada/autostart
+ ```
+
+Dentro de la sesión, `Ctrl+Alt+F1…F12` salta a otra TTY y vuelve sin
+romper carmen.
+
+## Modo greeter (DM)
+
+`mirada-compositor --greeter` arranca el compositor como **gestor de
+login**: en vez de la sesión, compone el greeter (`mirada-greeter`),
+que lanza como proceso hijo. El usuario teclea sus credenciales; cuando
+el login es válido el greeter emite un `SessionTicket` por su stdout y
+el compositor **muta a modo sesión sin reiniciar el servidor Wayland**
+— el mismo proceso, la misma GPU, las mismas ventanas («mutación
+atómica»). Desde ahí baja privilegios al usuario autenticado
+(`setuid`/`setgid` + grupos) para todo lo que lanza.
+
+La bandera es ortogonal al backend: `--greeter` solo (auto), o
+`--greeter --drm` / `--greeter --winit`.
+
+```sh
+# DM real, sobre una TTY — el compositor corre como root: PAM lo exige
+sudo mirada-compositor --greeter --drm
+
+# iterar el greeter anidado, con credenciales de prueba
+MIRADA_GREETER_MOCK=demo:demo \
+ cargo run -p mirada-compositor -- --greeter --winit
+```
+
+En modo greeter no se registran atajos (todas las teclas van al
+greeter — que el usuario no pueda lanzar nada ni cerrar el compositor),
+se rechaza `spawn:` y no corre el autoarranque; los atajos y la sesión
+arrancan sólo tras el traspaso. `MIRADA_GREETER_BIN` apunta a otro
+binario de greeter (cómodo para señalar a `target/…` en desarrollo).
+
+## Lanzador de aplicaciones
+
+`mirada-launcher` escanea los `.desktop` del sistema y lanza el que
+elijas. Es un programa de terminal sin dependencias: lo abres en una
+terminal pequeña y filtras escribiendo. El keymap por defecto ata
+`Super+p` a `spawn:foot -e mirada-launcher` — pulsa el atajo, escribe
+unas letras del nombre, Enter.
+
+Necesita `mirada-launcher` y `foot` en el `PATH` (ver la instalación de
+arriba). Suelto también vale: `mirada-launcher` en cualquier terminal.
+
+## Dos modos
+
+- **Autónomo** (por defecto) — lleva un `Desktop` (de `mirada-brain`)
+ embebido. Es un compositor teselante completo en un solo proceso.
+
+ ```sh
+ cargo run -p mirada-compositor
+ ```
+
+- **Enlazado** — el Cuerpo escucha en un socket y la app `mirada` (el
+ Cerebro GPUI) se conecta y decide la geometría.
+
+ ```sh
+ # terminal 1 — el Cuerpo
+ MIRADA_SOCKET=/tmp/mirada.sock cargo run -p mirada-compositor
+ # terminal 2 — el Cerebro
+ MIRADA_SOCKET=/tmp/mirada.sock cargo run -p mirada
+ ```
+
+## Probarlo
+
+Al arrancar imprime el `WAYLAND_DISPLAY` que abrió. Lanza cualquier
+cliente Wayland contra él:
+
+```sh
+WAYLAND_DISPLAY=wayland-1 foot # o weston-terminal, alacritty, …
+```
+
+Las ventanas se teselan solas. El teclado, con la ventana del compositor
+enfocada, maneja el escritorio con atajos `Super+…`: el lanzador de
+aplicaciones `Super+p`, una terminal `Super+Shift+Return`, foco
+`Super+j/k`, los 7 layouts en `Super+t/m/g/c/r/d/s` (o ciclar con
+`Super+space`), área maestra `Super+h/l`, `nmaster` `Super+,/.`,
+promover a maestra `Super+Return`, escritorios `Super+1..9`, cerrar
+`Super+q`. Cierra la ventana del compositor para salir.
+
+## Atajos de teclado
+
+Los atajos son configurables en RON: `~/.config/mirada/keymap.ron`. En
+modo autónomo, el Cuerpo lo carga al arrancar (si no existe, escribe uno
+por defecto documentado) y lo **recarga en caliente** — edita el archivo,
+guarda, y los atajos cambian sin reiniciar. En modo enlazado el keymap es
+asunto del Cerebro (la app `mirada`).
+
+```sh
+cargo run -p mirada-brain --example keymap-default # ver el formato
+```
+
+El compositor en sí no interpreta atajos: sólo intercepta las
+combinaciones que el Cerebro le pide (`GrabKeys`) y le devuelve la
+pulsada. *Qué significa* cada una lo decide `mirada-brain`. Ver el SDD.
+
+## Control externo
+
+En modo autónomo, el compositor abre un socket de control y `mirada-ctl`
+lo maneja desde la terminal — al estilo de `swaymsg`/`hyprctl`:
+
+```sh
+mirada-ctl focus-next # cambia el foco
+mirada-ctl focus-window 5 # enfoca una ventana concreta
+mirada-ctl workspace 3 # va al escritorio 3
+mirada-ctl windows # lista las ventanas
+```
+
+En modo enlazado el socket de control lo abre el Cerebro (la app
+`mirada`), no el compositor.
+
+## Qué implementa
+
+`wl_compositor`, `xdg_shell` (toplevels y popups), `wl_shm`, `wl_seat`
+(teclado, y ratón en el backend DRM), `wl_output`, `wl_data_device`
+(selección), `xdg-decoration` — fuerza decoración del servidor y no
+dibuja ninguna, así las ventanas van sin barra de título — y
+`zwp_linux_dmabuf`, que deja conectarse a los clientes que pintan por
+GPU (apps GPUI, navegadores acelerados). Composición con `GlesRenderer`
+— en `winit` sobre la ventana, en `drm` con un `DrmCompositor` por
+salida.
+
+Reusa `mirada-body` para la contabilidad de salidas y superficies, y
+`mirada-link` para el cable hacia un Cerebro externo. Toda la lógica
+espacial es agnóstica de Wayland y vive en los crates de
+`crates/modules/mirada/`.
+
+## Pendiente
+
+Del backend DRM: conmutación de VT, hotplug de monitores, multi-GPU.
+Puntero en el backend `winit`. Aislamiento de clientes. Ver el SDD.
+
+[`smithay`]: https://github.com/Smithay/smithay
diff --git a/02_ruway/mirada/mirada-compositor/README.md b/02_ruway/mirada/mirada-compositor/README.md
new file mode 100644
index 0000000..c0d60fd
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/README.md
@@ -0,0 +1,15 @@
+# mirada-compositor
+
+> Tiling Wayland compositor — the "Body" of [mirada](../README.md).
+
+A real tiling Wayland compositor built on [`smithay`]. It's the **Body** of `mirada`'s Brain↔Body architecture: it speaks the Wayland protocol with apps, owns surfaces and inputs, executes the layout decisions [`mirada-brain`](../mirada-brain/README.md) emits. Without `mirada-brain`, it still runs as a minimal default layout.
+
+## Usage
+
+```sh
+cargo run --release -p mirada-compositor
+```
+
+## Deps
+
+- `smithay`, [`mirada-protocol`](../mirada-protocol/README.md), [`mirada-body`](../mirada-body/README.md)
diff --git a/02_ruway/mirada/mirada-compositor/session/autostart.example b/02_ruway/mirada/mirada-compositor/session/autostart.example
new file mode 100644
index 0000000..422d294
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/session/autostart.example
@@ -0,0 +1,21 @@
+# Autoarranque de carmen — cópialo a ~/.config/mirada/autostart
+#
+# Un comando por línea; se lanza al arrancar el compositor, con
+# WAYLAND_DISPLAY ya puesto. Las líneas en blanco y las que empiezan
+# por # se ignoran. Cada línea se pasa a `sh -c`, así que valen las
+# variables, las tuberías y el `&` final no hace falta.
+
+# El marco pata — barra/dock acoplado a un borde; carmen lo reconoce por su
+# app_id y le reserva la franja. (Si no hay autostart, el compositor lo levanta
+# solo; acá lo ponemos explícito para combinarlo con shuma.)
+pata-llimphi
+# El shell de carmen propiamente dicho (shuma): barra superior con apps y
+# launcher. Es el binario que instala install-mirada-dm.sh.
+shuma-shell-llimphi
+
+# Una terminal para empezar.
+foot
+
+# Ejemplos (descoméntalos si los quieres):
+# mirada-ctl layout spiral
+# wbg ~/fondo.png
diff --git a/02_ruway/mirada/mirada-compositor/session/carmen.desktop b/02_ruway/mirada/mirada-compositor/session/carmen.desktop
new file mode 100644
index 0000000..f81b0e2
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/session/carmen.desktop
@@ -0,0 +1,6 @@
+[Desktop Entry]
+Name=carmen
+Comment=Compositor Wayland teselante (mirada)
+Exec=mirada-session
+Type=Application
+DesktopNames=carmen
diff --git a/02_ruway/mirada/mirada-compositor/session/mirada-pata.desktop b/02_ruway/mirada/mirada-compositor/session/mirada-pata.desktop
new file mode 100644
index 0000000..27a7a33
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/session/mirada-pata.desktop
@@ -0,0 +1,6 @@
+[Desktop Entry]
+Name=mirada · pata
+Comment=Compositor Wayland teselante (mirada) con el marco pata
+Exec=mirada-session-pata
+Type=Application
+DesktopNames=mirada
diff --git a/02_ruway/mirada/mirada-compositor/session/mirada-session b/02_ruway/mirada/mirada-compositor/session/mirada-session
new file mode 100755
index 0000000..31bf0f6
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/session/mirada-session
@@ -0,0 +1,30 @@
+#!/bin/sh
+# mirada-session — arranca carmen (el compositor mirada) como una sesión
+# de escritorio. Pensado para lanzarse desde una TTY o desde un gestor de
+# login (greetd, ly, …).
+#
+# Instálalo en el PATH (p. ej. /usr/local/bin/mirada-session) junto al
+# binario `mirada-compositor`.
+#
+# CAMINO CANÓNICO (recomendado): el Ente `mirada-session` declarado en la
+# Tarjeta Semilla de arje-zero (`03_ukupacha/arje/init/arje-zero/src/seed.rs::mirada_session_card`)
+# levanta el compositor con estas mismas variables de entorno pero bajo
+# supervisión del fractal — back-off automático ante crash y trazado por
+# el bus de eventos. Este script queda como FALLBACK para hosts con DM
+# externo (greetd, ly, sddm) o setups manuales que aún no migraron a
+# arje-zero como PID 1.
+
+# Carmen es un compositor Wayland.
+export XDG_SESSION_TYPE=wayland
+export XDG_CURRENT_DESKTOP=carmen
+export XDG_SESSION_DESKTOP=carmen
+
+# Que las apps GUI prefieran sus backends Wayland.
+export MOZ_ENABLE_WAYLAND=1
+export QT_QPA_PLATFORM="wayland;xcb"
+export SDL_VIDEODRIVER=wayland
+export _JAVA_AWT_WM_NONREPARENTING=1
+
+# El backend DRM toma la TTY entera. Los programas de arranque van en
+# ~/.config/mirada/autostart (uno por línea).
+exec mirada-compositor --drm
diff --git a/02_ruway/mirada/mirada-compositor/session/mirada-session-pata b/02_ruway/mirada/mirada-compositor/session/mirada-session-pata
new file mode 100755
index 0000000..0130374
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/session/mirada-session-pata
@@ -0,0 +1,26 @@
+#!/bin/sh
+# mirada-session-pata — sesión «mirada · pata» para gestores de login
+# EXTERNOS (sddm, greetd, ly…): arranca el compositor mirada y deja a pata
+# como marco del escritorio. Instálalo en /usr/local/bin junto a
+# `mirada-compositor` y `pata-llimphi`.
+#
+# (Cuando el DM es el propio mirada —`mirada-dm`—, no hace falta este
+# script: elegís «mirada · pata» en el greeter y el marco arranca como
+# cliente sin reiniciar el servidor.)
+set -eu
+
+export XDG_SESSION_TYPE=wayland
+export XDG_CURRENT_DESKTOP=mirada
+export XDG_SESSION_DESKTOP=mirada
+export MOZ_ENABLE_WAYLAND=1
+export QT_QPA_PLATFORM="wayland;xcb"
+export SDL_VIDEODRIVER=wayland
+
+# Asegura pata en el autoarranque del usuario (idempotente). pata ancla por
+# wlr-layer-shell (su backend nativo), que mirada ahora soporta.
+mkdir -p "${HOME}/.config/mirada"
+AUTO="${HOME}/.config/mirada/autostart"
+LINE='pata-llimphi'
+grep -qxF "$LINE" "$AUTO" 2>/dev/null || echo "$LINE" >> "$AUTO"
+
+exec mirada-compositor --drm
diff --git a/02_ruway/mirada/mirada-compositor/src/drm_backend.rs b/02_ruway/mirada/mirada-compositor/src/drm_backend.rs
new file mode 100644
index 0000000..49865db
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/src/drm_backend.rs
@@ -0,0 +1,2448 @@
+//! `drm_backend` — el Cuerpo del compositor sobre **DRM/KMS**, sin
+//! sesión gráfica anfitriona: corre directo sobre una TTY, como tu
+//! escritorio de verdad.
+//!
+//! Construido por fases para verificarlo en hardware paso a paso:
+//!
+//! - **Fase 1 — bring-up**: sesión (`libseat`), GPU, dispositivo DRM,
+//! enumerar salidas.
+//! - **Fase 2a — pipeline de render**: GBM, EGL y `GlesRenderer`, con un
+//! `DrmCompositor` para la salida conectada.
+//! - **Fase 2b — bucle Wayland** (esto): un bucle `calloop` que atiende
+//! a los clientes Wayland, el teclado (`libinput`) y el VBlank, y
+//! compone las ventanas de verdad. Aquí `mirada-compositor --drm` ya
+//! es un escritorio funcionando.
+//!
+//! Todo con logs para diagnosticar sin el hardware delante.
+
+use std::error::Error;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice};
+use smithay::backend::allocator::Fourcc;
+use smithay::backend::drm::compositor::{DrmCompositor, FrameFlags};
+use smithay::backend::drm::exporter::gbm::GbmFramebufferExporter;
+use smithay::backend::drm::{DrmDevice, DrmDeviceFd, DrmEvent};
+use smithay::backend::egl::{EGLContext, EGLDisplay};
+use smithay::backend::input::{
+ AbsolutePositionEvent, Axis, AxisSource, ButtonState, InputEvent, KeyState, KeyboardKeyEvent,
+ PointerAxisEvent, PointerButtonEvent, PointerMotionEvent,
+};
+use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface};
+use smithay::backend::renderer::element::memory::{
+ MemoryRenderBuffer, MemoryRenderBufferRenderElement,
+};
+use smithay::backend::renderer::element::solid::{SolidColorBuffer, SolidColorRenderElement};
+use smithay::backend::renderer::element::surface::{
+ render_elements_from_surface_tree, WaylandSurfaceRenderElement,
+};
+use smithay::backend::renderer::element::{render_elements, Id, Kind};
+use smithay::backend::renderer::gles::GlesRenderer;
+use smithay::backend::renderer::utils::CommitCounter;
+use smithay::backend::renderer::{ImportAll, ImportDma, ImportMem};
+use smithay::backend::session::libseat::LibSeatSession;
+use smithay::backend::session::{Event as SessionEvent, Session};
+use smithay::backend::udev;
+use smithay::input::keyboard::FilterResult;
+use smithay::input::pointer::{AxisFrame, ButtonEvent, CursorImageStatus, MotionEvent};
+use smithay::output::OutputModeSource;
+use smithay::reexports::calloop::channel::{channel as ticket_channel, Event as TicketEvent};
+use smithay::reexports::calloop::generic::Generic;
+use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
+use smithay::reexports::calloop::{EventLoop, Interest, Mode as CalloopMode, PostAction};
+use smithay::reexports::drm::control::connector::State as ConnectorState;
+use smithay::reexports::drm::control::{Device as ControlDevice, ModeTypeFlags};
+use smithay::reexports::input::Libinput;
+use smithay::reexports::rustix::fs::OFlags;
+use smithay::reexports::wayland_server::{Display, DisplayHandle, ListeningSocket};
+use smithay::utils::{
+ DeviceFd, IsAlive, Logical, Physical, Point, Rectangle, Scale, Size, Transform, SERIAL_COUNTER,
+};
+
+use auth_core::SessionTicket;
+use mirada_brain::{BodyEvent, CtlReply, CtlRequest, Rect, ZoneFrac};
+
+use crate::{
+ combo_string, send_frames_surface_tree, App, BodyMode, ClientState, DragGrab, DragMode,
+ Setup,
+};
+
+/// El `DrmCompositor` concreto para una salida (un solo GPU). Hay uno por
+/// cada conector activo en multi-monitor.
+type Compositor =
+ DrmCompositor<GbmAllocator<DrmDeviceFd>, GbmFramebufferExporter<DrmDeviceFd>, (), DrmDeviceFd>;
+
+/// Una salida física activa: su conector + CRTC + `DrmCompositor` propio +
+/// el [`smithay::output::Output`] anunciado en Wayland + su posición en el
+/// escritorio global (multi-monitor). La primera de la lista es la
+/// **primaria**: ahí van layer-shell, tiling, menú, zonas y HUD; las
+/// secundarias renderizan sólo wallpaper hasta tener distribución global.
+struct OutputCtx {
+ /// Nombre legible (`DP-1`, `HDMI-A-1`, …) — sale del conector DRM.
+ name: String,
+ /// El `Output` smithay (vive mientras el compositor corre).
+ output: smithay::output::Output,
+ /// El CRTC al que está atada esta salida — clave de routing del VBlank.
+ crtc: smithay::reexports::drm::control::crtc::Handle,
+ /// El `DrmCompositor` que pinta esta salida.
+ compositor: Compositor,
+ /// Rect en coordenadas del escritorio global. `(rect.x, rect.y)` es la
+ /// esquina superior-izquierda en el espacio común; `(rect.w, rect.h)`
+ /// es el tamaño nativo del modo.
+ rect: Rect,
+ /// Refresco en mHz (lo guardamos para futura reconfiguración / hotplug).
+ #[allow(dead_code)]
+ refresh_mhz: i32,
+ /// Wallpaper ya compuesto al tamaño de **esta** salida; `None` se rearma
+ /// perezosamente en el próximo render.
+ wallpaper: Option<(MemoryRenderBuffer, (i32, i32))>,
+ /// Ruta del wallpaper a usar en esta salida (`None` = fondo de color
+ /// sólido). Resuelta de la config: el override por nombre de
+ /// [`mirada_brain::OutputOverride`] gana; si no hay, cae al global.
+ wallpaper_path: Option<String>,
+ /// Modo de ajuste del wallpaper en esta salida — análogo a
+ /// [`Self::wallpaper_path`]: por-salida si hay override, global si no.
+ wallpaper_fit: mirada_brain::WallpaperFit,
+ /// `true` entre que esta salida encola un page-flip y llega su VBlank.
+ pending_flip: bool,
+}
+
+render_elements! {
+ /// Lo que el backend DRM compone en un cuadro: superficies de cliente,
+ /// rectángulos de color sólido (cursor, marcos) y etiquetas de texto
+ /// (búferes RGBA rasterizados — títulos, menú).
+ Frame<R> where R: ImportAll + ImportMem;
+ Window = WaylandSurfaceRenderElement<R>,
+ Solid = SolidColorRenderElement,
+ Text = MemoryRenderBufferRenderElement<R>,
+}
+
+/// Color de fondo del escritorio cuando no hay nada que lo tape.
+const CLEAR_COLOR: [f32; 4] = [0.05, 0.05, 0.08, 1.0];
+
+/// Lado del cursor de software, en píxeles.
+const CURSOR_SIZE: i32 = 12;
+
+/// Color del cursor — un cuadrado casi blanco, opaco.
+const CURSOR_COLOR: [f32; 4] = [0.95, 0.95, 0.97, 1.0];
+
+/// Alto de las etiquetas de título, en píxeles.
+const TITLE_PX: f32 = 16.0;
+
+/// Alto del texto de las filas del menú raíz, en píxeles.
+const MENU_TEXT_PX: f32 = 14.0;
+
+/// Color de fondo del menú raíz (RGBA `0..=1`, casi opaco).
+const MENU_BG: [f32; 4] = [0.11, 0.11, 0.14, 0.97];
+
+/// Color del texto de las filas del menú raíz (RGBA `0..=255`).
+const MENU_TEXT_COLOR: [u8; 4] = [228, 228, 234, 255];
+
+
+/// Color RGBA de las etiquetas de título — casi blanco.
+const TITLE_COLOR: [u8; 4] = [230, 230, 235, 255];
+
+/// Alto del texto del HUD de presets de zonas, en píxeles.
+const HUD_TEXT_PX: f32 = 18.0;
+
+/// Color RGBA (`0..=255`) del texto del HUD de presets.
+const HUD_TEXT_COLOR: [u8; 4] = [240, 240, 246, 255];
+
+/// Color del fondo del HUD de presets (RGBA `0..=1`, casi opaco).
+const HUD_BG: [f32; 4] = [0.10, 0.10, 0.14, 0.85];
+
+/// Margen interno del HUD entre el texto y el borde de su panel, en píxeles.
+const HUD_PAD: i32 = 12;
+
+/// Distancia del HUD al borde superior de la salida, en píxeles.
+const HUD_TOP: i32 = 40;
+
+/// Duración del HUD del preset activo al ciclar zonas.
+const HUD_DURATION: Duration = Duration::from_millis(1500);
+
+/// Lado mínimo de una ventana al redimensionarla con el ratón.
+const MIN_WINDOW: i32 = 120;
+
+/// Convierte un color RGBA en `0..=255` (como viaja por el protocolo y la
+/// config) al `[f32; 4]` normalizado que consume el renderer.
+fn rgba_f32(c: [u8; 4]) -> [f32; 4] {
+ [
+ c[0] as f32 / 255.0,
+ c[1] as f32 / 255.0,
+ c[2] as f32 / 255.0,
+ c[3] as f32 / 255.0,
+ ]
+}
+
+/// Los 4 rectángulos `(x, y, w, h)` del marco de grosor `bw` de una ventana
+/// cuyo contenido ocupa `(sx, sy, sw, sh)`. El marco va *hacia adentro*
+/// (pisa el borde de la superficie), así nunca se solapa con el de la
+/// ventana vecina: arriba, abajo, izquierda, derecha.
+fn border_rects(sx: i32, sy: i32, sw: i32, sh: i32, bw: i32) -> [(i32, i32, i32, i32); 4] {
+ let side_h = (sh - 2 * bw).max(0);
+ [
+ (sx, sy, sw, bw),
+ (sx, sy + sh - bw, sw, bw),
+ (sx, sy + bw, bw, side_h),
+ (sx + sw - bw, sy + bw, bw, side_h),
+ ]
+}
+
+/// Decodifica el wallpaper de `path` y lo compone en un buffer del tamaño
+/// de la salida (`w` × `h`) según `fit`. El resto del buffer queda en negro
+/// opaco (BGRA `[0, 0, 0, 255]`). Devuelve `None` si la imagen no abre o el
+/// tamaño es degenerado. El formato es `Argb8888` (little-endian → bytes
+/// `[B, G, R, A]` en memoria); el wallpaper es opaco, así que la
+/// premultiplicación por alfa es identidad.
+fn load_wallpaper(
+ path: &str,
+ fit: mirada_brain::WallpaperFit,
+ w: i32,
+ h: i32,
+) -> Option<MemoryRenderBuffer> {
+ use mirada_brain::WallpaperFit;
+ if w <= 0 || h <= 0 {
+ return None;
+ }
+ let img = match image::open(path) {
+ Ok(i) => i,
+ Err(e) => {
+ eprintln!("mirada-compositor · wallpaper «{path}» no carga ({e}); fondo sólido.");
+ return None;
+ }
+ };
+ let rgba = img.to_rgba8();
+ let sw = rgba.width() as i32;
+ let sh = rgba.height() as i32;
+ if sw <= 0 || sh <= 0 {
+ return None;
+ }
+
+ // Lienzo negro opaco del tamaño de la salida.
+ let mut bgra = vec![0u8; (w as usize) * (h as usize) * 4];
+ for px in bgra.chunks_exact_mut(4) {
+ px[3] = 255;
+ }
+
+ match fit {
+ WallpaperFit::Tile => {
+ // Repetir desde la esquina superior-izquierda en tamaño nativo.
+ let src = rgba.as_raw();
+ let sw_u = sw as usize;
+ let sh_u = sh as usize;
+ for y in 0..(h as usize) {
+ let sy = y % sh_u;
+ let src_row = &src[sy * sw_u * 4..(sy + 1) * sw_u * 4];
+ let dst_row = &mut bgra[y * (w as usize) * 4..(y + 1) * (w as usize) * 4];
+ for x in 0..(w as usize) {
+ let sx = x % sw_u;
+ let si = sx * 4;
+ let di = x * 4;
+ dst_row[di] = src_row[si + 2]; // B
+ dst_row[di + 1] = src_row[si + 1]; // G
+ dst_row[di + 2] = src_row[si]; // R
+ dst_row[di + 3] = 255;
+ }
+ }
+ }
+ _ => {
+ let (dx, dy, dw, dh) = mirada_brain::wallpaper_dst_rect(fit, sw, sh, w, h);
+ // Para Stretch/Fit/Fill escalamos la imagen al rect que cae en
+ // pantalla; para Center la dejamos a tamaño nativo. En todos los
+ // casos el "lienzo" donde se pintará es de tamaño `(dw, dh)` y
+ // luego se composita con offset `(dx, dy)` clipeando al destino.
+ let (img_pixels, iw, ih) = if matches!(fit, WallpaperFit::Center) {
+ (rgba.as_raw().clone(), sw, sh)
+ } else if dw <= 0 || dh <= 0 {
+ return None;
+ } else {
+ let scaled = image::imageops::resize(
+ &rgba,
+ dw as u32,
+ dh as u32,
+ image::imageops::FilterType::Triangle,
+ );
+ (scaled.into_raw(), dw, dh)
+ };
+ paste_rgba_into_bgra(&mut bgra, w, h, &img_pixels, iw, ih, dx, dy);
+ }
+ }
+
+ Some(MemoryRenderBuffer::from_slice(
+ &bgra,
+ Fourcc::Argb8888,
+ (w, h),
+ 1,
+ Transform::Normal,
+ None,
+ ))
+}
+
+/// Pega un bloque RGBA de `(iw, ih)` en un lienzo BGRA `(dw, dh)` con esquina
+/// superior-izquierda en `(dx, dy)`. Filas y columnas fuera del destino se
+/// clipean. Alfa se fuerza opaco.
+fn paste_rgba_into_bgra(
+ dst: &mut [u8],
+ dw: i32,
+ dh: i32,
+ src: &[u8],
+ iw: i32,
+ ih: i32,
+ dx: i32,
+ dy: i32,
+) {
+ if iw <= 0 || ih <= 0 || dw <= 0 || dh <= 0 {
+ return;
+ }
+ let row_dst = (dw as usize) * 4;
+ let row_src = (iw as usize) * 4;
+ // Recorta horizontalmente: rango de columnas de la fuente que cae en pantalla.
+ let x0 = dx.max(0);
+ let x1 = (dx + iw).min(dw);
+ if x1 <= x0 {
+ return;
+ }
+ let src_x0 = (x0 - dx) as usize;
+ let copy_w = (x1 - x0) as usize;
+ for y in 0..ih {
+ let ty = dy + y;
+ if ty < 0 || ty >= dh {
+ continue;
+ }
+ let src_row = &src[(y as usize) * row_src..(y as usize + 1) * row_src];
+ let dst_row = &mut dst[(ty as usize) * row_dst..(ty as usize + 1) * row_dst];
+ for k in 0..copy_w {
+ let si = (src_x0 + k) * 4;
+ let di = (x0 as usize + k) * 4;
+ dst_row[di] = src_row[si + 2]; // B
+ dst_row[di + 1] = src_row[si + 1]; // G
+ dst_row[di + 2] = src_row[si]; // R
+ dst_row[di + 3] = 255;
+ }
+ }
+}
+
+/// Rasteriza un título a un `MemoryRenderBuffer` (Argb8888); si no rasteriza
+/// nada, devuelve un búfer 1×1 transparente. Lo cachea el llamador.
+fn title_buffer(tr: &crate::text::TextRenderer, title: &str) -> MemoryRenderBuffer {
+ match tr.rasterize(title, TITLE_PX, TITLE_COLOR) {
+ Some(r) => MemoryRenderBuffer::from_slice(
+ &r.rgba,
+ Fourcc::Argb8888,
+ (r.width, r.height),
+ 1,
+ Transform::Normal,
+ None,
+ ),
+ None => MemoryRenderBuffer::from_slice(
+ &[0u8; 4],
+ Fourcc::Argb8888,
+ (1, 1),
+ 1,
+ Transform::Normal,
+ None,
+ ),
+ }
+}
+
+/// Códigos de botón de `<linux/input-event-codes.h>`.
+const BTN_LEFT: u32 = 0x110;
+const BTN_RIGHT: u32 = 0x111;
+
+/// El estado del bucle DRM — lo comparten todos los callbacks de `calloop`.
+struct DrmState {
+ app: App,
+ /// La sesión libseat — se conserva para conmutar de VT (`Ctrl+Alt+Fn`).
+ session: LibSeatSession,
+ display: Display<App>,
+ /// El dispositivo DRM — se conserva para pausarlo y reactivarlo al
+ /// conmutar de VT.
+ drm: DrmDevice,
+ /// Recursos para crear `OutputCtx` nuevos en caliente (hotplug). Se
+ /// guardan acá porque la creación inicial los necesita y el handler de
+ /// `UdevEvent::Changed` también, dentro del bucle de eventos.
+ gbm: GbmDevice<DrmDeviceFd>,
+ allocator: GbmAllocator<DrmDeviceFd>,
+ exporter: GbmFramebufferExporter<DrmDeviceFd>,
+ renderer_formats: smithay::backend::allocator::format::FormatSet,
+ /// Handle al display Wayland — `announce_output` lo necesita al crear
+ /// el `wl_output` global de cada monitor nuevo.
+ dh: DisplayHandle,
+ /// Salidas físicas activas: una por conector. `outputs[0]` es la
+ /// primaria — soporta layer-shell, tiling, menú, zonas, HUD.
+ outputs: Vec<OutputCtx>,
+ renderer: GlesRenderer,
+ /// Contexto `libinput` — se suspende y reanuda al conmutar de VT.
+ libinput: Libinput,
+ /// `false` mientras la sesión está cedida a otra VT — no se compone.
+ active: bool,
+ /// Vigías de los archivos de config recargables en caliente.
+ watches: crate::ConfigWatches,
+ ctl: Option<crate::CtlServer>,
+ /// Inicio del compositor — base de tiempos para los frame-callbacks.
+ start: Instant,
+ /// Nº de ventanas en el último `tick` — para registrar los cambios.
+ last_windows: usize,
+ /// Identidad estable del cursor de software — el seguimiento de daño
+ /// la usa para no recomponer todo cuando el cursor sólo se mueve.
+ cursor_id: Id,
+ /// Ventana sobre la que estaba el puntero — para el foco-sigue-ratón.
+ last_pointer_window: Option<u64>,
+ /// Tamaño de la salida, en píxeles — los topes del puntero.
+ output_size: (f64, f64),
+ /// Renderizador de texto (etiquetas de título/menú). `None` si no se
+ /// encontró ninguna fuente — entonces no se pintan etiquetas.
+ text: Option<crate::text::TextRenderer>,
+ /// Caché de etiquetas ya rasterizadas, por (texto, color) → búfer subido.
+ /// Evita re-rasterizar y re-subir la textura en cada cuadro.
+ text_cache: std::collections::HashMap<(String, [u8; 4]), MemoryRenderBuffer>,
+ /// Árbol del menú raíz (de la config), con submenús anidados.
+ menu_entries: Vec<crate::menu::MenuNode>,
+ /// Menú raíz abierto, si lo hay (click derecho sobre el fondo). Sus
+ /// coordenadas son **locales** a la salida [`Self::menu_output_idx`]:
+ /// el menú se abre en el monitor donde se hizo right-click.
+ root_menu: Option<crate::menu::RootMenu>,
+ /// Índice de la salida en la que vive el menú raíz abierto. `None` =
+ /// no hay menú.
+ menu_output_idx: Option<usize>,
+ /// Zonas de arrastre activas (fracciones del área útil) — el preset actual.
+ zones: Vec<ZoneFrac>,
+ /// Todos los presets de zonas (preset 0 = `config.zones`, luego
+ /// `config.zone_presets`). `mirada-ctl cycle-zones` avanza entre ellos.
+ zone_presets: Vec<Vec<ZoneFrac>>,
+ /// Índice del preset activo dentro de [`Self::zone_presets`].
+ active_preset: usize,
+ /// Índice de la zona resaltada bajo el puntero durante un arrastre.
+ drag_zone: Option<usize>,
+ /// Instante hasta el que pintar el HUD del preset activo (al ciclar
+ /// zonas). `None` = sin HUD. Se setea en cada ciclo y se respeta hasta
+ /// que el reloj de `start` lo supera; el siguiente tick (~60 Hz) lo
+ /// borra y damasca su área para el siguiente flip.
+ preset_hud_until: Option<Instant>,
+ /// Etiqueta cacheada del HUD (la última que se rasterizó). Permite
+ /// reusar `text_cache` sin recomputar el texto en cada cuadro.
+ preset_hud_label: String,
+}
+
+impl DrmState {
+ /// Índice de la salida primaria en [`Self::outputs`]. Hoy hard-coded a 0
+ /// (la primera descubierta); a futuro será configurable.
+ const PRIMARY: usize = 0;
+
+ /// Encuentra el índice de la salida cuyo CRTC es `crtc`, si existe.
+ fn output_index_by_crtc(
+ &self,
+ crtc: smithay::reexports::drm::control::crtc::Handle,
+ ) -> Option<usize> {
+ self.outputs.iter().position(|o| o.crtc == crtc)
+ }
+
+ /// Índice de la salida que contiene el punto global `(gx, gy)`. Si el
+ /// punto cae en zona muerta entre rects (puede pasar con salidas de
+ /// distinto tamaño dispuestas side-by-side), devuelve la primaria.
+ fn output_at_point(&self, gx: i32, gy: i32) -> usize {
+ self.outputs
+ .iter()
+ .position(|o| {
+ gx >= o.rect.x
+ && gy >= o.rect.y
+ && gx < o.rect.x + o.rect.w
+ && gy < o.rect.y + o.rect.h
+ })
+ .unwrap_or(Self::PRIMARY)
+ }
+
+ /// Acota un punto al **interior de algún output**, respetando la geometría
+ /// real. Si `(x, y)` ya cae sobre un output, se devuelve tal cual; si no
+ /// (zona muerta entre rects de distinto tamaño), se proyecta al borde
+ /// del output euclídeamente más cercano. Sin esto, el cursor podía
+ /// quedar atrapado en zonas que ninguna salida pinta — el usuario lo ve
+ /// como un cursor fantasma sobre fondo negro.
+ fn clamp_to_outputs(&self, x: f64, y: f64) -> (f64, f64) {
+ let xi = x.round() as i32;
+ let yi = y.round() as i32;
+ if self.outputs.iter().any(|o| {
+ xi >= o.rect.x
+ && yi >= o.rect.y
+ && xi < o.rect.x + o.rect.w
+ && yi < o.rect.y + o.rect.h
+ }) {
+ return (x, y);
+ }
+ // El menor cuadrado-distancia al rect proyecta `(x, y)` al borde.
+ let mut best = (self.outputs[Self::PRIMARY].rect, f64::INFINITY);
+ for o in &self.outputs {
+ let r = o.rect;
+ if r.w <= 0 || r.h <= 0 {
+ continue;
+ }
+ let cx = x.clamp(r.x as f64, (r.x + r.w - 1) as f64);
+ let cy = y.clamp(r.y as f64, (r.y + r.h - 1) as f64);
+ let d = (x - cx).powi(2) + (y - cy).powi(2);
+ if d < best.1 {
+ best = (r, d);
+ }
+ }
+ let r = best.0;
+ (
+ x.clamp(r.x as f64, (r.x + r.w - 1) as f64),
+ y.clamp(r.y as f64, (r.y + r.h - 1) as f64),
+ )
+ }
+
+ /// El área útil (rect menos reservas) **de una salida concreta**: a las
+ /// layers exclusivas de su `layer_map` se le suma, sólo en la primaria,
+ /// la franja del shell (pata). Devuelve rect en coords globales — el
+ /// teselado y las zonas de arrastre lo usan como dominio efectivo.
+ fn output_work_rect(&self, idx: usize) -> Rect {
+ let o = &self.outputs[idx];
+ // Layers exclusivas de ESTA salida: la zona "no exclusiva" da los
+ // insets directos.
+ let z = smithay::desktop::layer_map_for_output(&o.output).non_exclusive_zone();
+ let mut top = z.loc.y.max(0);
+ let mut left = z.loc.x.max(0);
+ let mut right = (o.rect.w - (z.loc.x + z.size.w)).max(0);
+ let mut bottom = (o.rect.h - (z.loc.y + z.size.h)).max(0);
+ // El dock del shell (pata) sólo se descuenta en la primaria.
+ if idx == Self::PRIMARY {
+ let (rt, rb, rl, rr) = self.app.reserved;
+ // Las reservas del shell ya están sumadas en `self.app.reserved`
+ // (recompute_reservations las publica), pero `app.reserved`
+ // incluye también las de layer-shell (no podemos restarlas
+ // limpiamente). Tomamos `max` para no doble-contar: la mayor
+ // gana, y el shell (que es la suma) cubre los dos casos.
+ top = top.max(rt);
+ bottom = bottom.max(rb);
+ left = left.max(rl);
+ right = right.max(rr);
+ }
+ Rect::new(
+ o.rect.x + left,
+ o.rect.y + top,
+ (o.rect.w - left - right).max(1),
+ (o.rect.h - top - bottom).max(1),
+ )
+ }
+
+ /// Compone un cuadro por cada salida y avisa a los clientes una sola vez.
+ /// Si una salida tiene su `pending_flip` puesto, se saltea hasta el
+ /// próximo VBlank. Refresca los búferes de marco una vez al principio.
+ fn render(&mut self) {
+ if !self.active {
+ return; // la sesión está en otra VT — no tocamos la GPU
+ }
+ self.refresh_window_borders();
+ for i in 0..self.outputs.len() {
+ self.render_output(i);
+ }
+ self.send_frames_to_clients();
+ }
+
+ /// Si el puntero global cae sobre `rect`, emite el cursor en coordenadas
+ /// **locales** a `rect`. Si el cliente publicó una superficie de cursor,
+ /// usa esa; si no, el cuadrado por defecto. `Hidden` o puntero fuera del
+ /// rect no emiten nada.
+ fn emit_cursor(&mut self, rect: Rect, into: &mut Vec<Frame<GlesRenderer>>) {
+ let (cx, cy) = self.app.pointer_loc;
+ let (cxi, cyi) = (cx.round() as i32, cy.round() as i32);
+ if cxi < rect.x || cyi < rect.y || cxi >= rect.x + rect.w || cyi >= rect.y + rect.h {
+ return;
+ }
+ match &self.app.cursor_status {
+ CursorImageStatus::Hidden => {}
+ CursorImageStatus::Surface(surface) if surface.alive() => {
+ let (hx, hy) = crate::cursor_hotspot(surface);
+ let loc = (cxi - rect.x - hx, cyi - rect.y - hy);
+ for el in render_elements_from_surface_tree(
+ &mut self.renderer,
+ surface,
+ loc,
+ 1.0,
+ 1.0,
+ Kind::Cursor,
+ ) {
+ into.push(Frame::Window(el));
+ }
+ }
+ _ => {
+ let cursor_rect = Rectangle::new(
+ Point::<i32, Physical>::from((cxi - rect.x, cyi - rect.y)),
+ Size::<i32, Physical>::from((CURSOR_SIZE, CURSOR_SIZE)),
+ );
+ into.push(Frame::Solid(SolidColorRenderElement::new(
+ self.cursor_id.clone(),
+ cursor_rect,
+ CommitCounter::default(),
+ CURSOR_COLOR,
+ Kind::Cursor,
+ )));
+ }
+ }
+ }
+
+ /// Emite todas las ventanas visibles cuya posición global intersecta `rect`,
+ /// traducidas a coordenadas locales a `rect`. Incluye marcos, barras de
+ /// título y el árbol de superficie del cliente, en orden front-to-back
+ /// (`shell` arriba > flotantes > teseladas). Se saltea ventanas que no
+ /// caen sobre `rect` para no malgastar trabajo del compositor.
+ fn emit_windows(&mut self, rect: Rect, into: &mut Vec<Frame<GlesRenderer>>) {
+ let mut shown: Vec<_> = self.app.windows.iter().filter(|w| w.visible).collect();
+ shown.sort_by_key(|w| (!w.is_shell, !w.floating, !w.focused));
+ let tbh = self.app.decorations.titlebar_height;
+ // `render_loc` necesita el alto de la "salida lógica" sólo para anclar
+ // el shell al borde inferior. En multi-monitor esa salida es la
+ // primaria (output 0); el shell vive ahí.
+ let primary_h = self.outputs[Self::PRIMARY].rect.h;
+ for w in &shown {
+ let tb = crate::titlebar_for(w, tbh);
+ let (gx, gy) = crate::render_loc(w, primary_h, tbh);
+ let (sw, sh) = crate::surface_px_size(w).unwrap_or((w.size.0, (w.size.1 - tb).max(1)));
+ // Rect decorado en coords globales (incluye barra + superficie).
+ let gxd = gx;
+ let gyd = gy - tb;
+ let gwd = sw;
+ let ghd = sh + tb;
+ // Filtrar por intersección con `rect`.
+ if gxd + gwd <= rect.x
+ || gyd + ghd <= rect.y
+ || gxd >= rect.x + rect.w
+ || gyd >= rect.y + rect.h
+ {
+ continue;
+ }
+ // Posición local de la superficie y de la decoración.
+ let x = gx - rect.x;
+ let y = gy - rect.y;
+ let dec_y = y - tb;
+ let dec_h = sh + tb;
+
+ if tb > 0 {
+ if let Some(tr) = &self.text {
+ if !w.title.is_empty() {
+ if self.text_cache.len() > 256 {
+ self.text_cache.clear();
+ }
+ let buf = self
+ .text_cache
+ .entry((w.title.clone(), TITLE_COLOR))
+ .or_insert_with(|| title_buffer(tr, &w.title));
+ let ty = dec_y + (tb - TITLE_PX as i32) / 2;
+ if let Ok(el) = MemoryRenderBufferRenderElement::from_buffer(
+ &mut self.renderer,
+ ((x + 8) as f64, ty as f64),
+ buf,
+ None,
+ None,
+ None,
+ Kind::Unspecified,
+ ) {
+ into.push(Frame::Text(el));
+ }
+ }
+ }
+ let color = rgba_f32(if w.focused {
+ self.app.decorations.border_focus
+ } else {
+ self.app.decorations.border_normal
+ });
+ let mut bar = SolidColorBuffer::default();
+ bar.update((sw, tb), color);
+ into.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+ &bar,
+ (x, dec_y),
+ 1.0,
+ 1.0,
+ Kind::Unspecified,
+ )));
+ } else if w.focused && !w.is_shell && !w.title.is_empty() {
+ if let Some(tr) = &self.text {
+ if self.text_cache.len() > 256 {
+ self.text_cache.clear();
+ }
+ let buf = self
+ .text_cache
+ .entry((w.title.clone(), TITLE_COLOR))
+ .or_insert_with(|| title_buffer(tr, &w.title));
+ if let Ok(el) = MemoryRenderBufferRenderElement::from_buffer(
+ &mut self.renderer,
+ ((x + 6) as f64, (y + 4) as f64),
+ buf,
+ None,
+ None,
+ None,
+ Kind::Unspecified,
+ ) {
+ into.push(Frame::Text(el));
+ }
+ }
+ }
+ if !w.is_shell && self.app.decorations.border_width > 0 {
+ let rects = border_rects(x, dec_y, sw, dec_h, self.app.decorations.border_width);
+ for (buf, (bx, by, _, _)) in w.borders.iter().zip(rects) {
+ into.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+ buf,
+ (bx, by),
+ 1.0,
+ 1.0,
+ Kind::Unspecified,
+ )));
+ }
+ }
+ for el in render_elements_from_surface_tree(
+ &mut self.renderer,
+ &w.surface,
+ (x, y),
+ 1.0,
+ 1.0,
+ Kind::Unspecified,
+ ) {
+ into.push(Frame::Window(el));
+ }
+ }
+ }
+
+ /// Emite el HUD del preset activo en la salida `rect` — un panel discreto
+ /// arriba al centro de la salida (no del escritorio global) mientras dure
+ /// la ventana de feedback. Si el deadline pasó, limpia el estado. Llamar
+ /// sólo en la salida dueña del HUD (hoy la primaria).
+ fn emit_hud(&mut self, rect: Rect, into: &mut Vec<Frame<GlesRenderer>>) {
+ let Some(deadline) = self.preset_hud_until else {
+ return;
+ };
+ if Instant::now() >= deadline {
+ self.preset_hud_until = None;
+ return;
+ }
+ let Some(tr) = &self.text else { return };
+ if self.preset_hud_label.is_empty() {
+ return;
+ }
+ let Some(r) = tr.rasterize(&self.preset_hud_label, HUD_TEXT_PX, HUD_TEXT_COLOR) else {
+ return;
+ };
+ let tw = r.width;
+ let th = r.height;
+ let panel_w = tw + 2 * HUD_PAD;
+ let panel_h = th.max(HUD_TEXT_PX as i32) + 2 * HUD_PAD;
+ // Centra el panel en el ancho de la salida (no del escritorio total),
+ // en coords locales — el frame de esta salida arranca en (0,0).
+ let panel_x = ((rect.w - panel_w) / 2).max(0);
+ let panel_y = HUD_TOP;
+ let tx = panel_x + (panel_w - tw) / 2;
+ let ty = panel_y + (panel_h - th) / 2;
+ let buf = MemoryRenderBuffer::from_slice(
+ &r.rgba,
+ Fourcc::Argb8888,
+ (tw, th),
+ 1,
+ Transform::Normal,
+ None,
+ );
+ if let Ok(el) = MemoryRenderBufferRenderElement::from_buffer(
+ &mut self.renderer,
+ (tx as f64, ty as f64),
+ &buf,
+ None,
+ None,
+ None,
+ Kind::Unspecified,
+ ) {
+ into.push(Frame::Text(el));
+ }
+ let mut bg = SolidColorBuffer::default();
+ bg.update((panel_w, panel_h), HUD_BG);
+ into.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+ &bg,
+ (panel_x, panel_y),
+ 1.0,
+ 1.0,
+ Kind::Unspecified,
+ )));
+ }
+
+ /// Emite el overlay de zonas de arrastre (drag-to-zone) — visible sólo
+ /// durante un arrastre Move/Tile. Las zonas se escalan al monitor bajo
+ /// el puntero y se emiten traducidas a coords locales de `rect`. Si las
+ /// zonas no caen sobre `rect` (drag en otro monitor), no emite nada.
+ fn emit_zone_overlay(&mut self, rect: Rect, into: &mut Vec<Frame<GlesRenderer>>) {
+ let drag_mode = self.app.drag.as_ref().map(|d| d.mode);
+ if !matches!(drag_mode, Some(DragMode::Move) | Some(DragMode::Tile)) {
+ return;
+ }
+ if self.zones.is_empty() {
+ return;
+ }
+ let wr = self.work_rect();
+ // Las zonas son del monitor bajo el puntero — si ese no es esta
+ // salida, no las pintamos acá.
+ if wr.x + wr.w <= rect.x
+ || wr.y + wr.h <= rect.y
+ || wr.x >= rect.x + rect.w
+ || wr.y >= rect.y + rect.h
+ {
+ return;
+ }
+ let acc = self.app.decorations.border_focus;
+ let fill = |a: f32| {
+ [
+ acc[0] as f32 / 255.0,
+ acc[1] as f32 / 255.0,
+ acc[2] as f32 / 255.0,
+ a,
+ ]
+ };
+ for (i, z) in self.zones.iter().enumerate() {
+ let r = z.to_rect(wr);
+ let color = if Some(i) == self.drag_zone { fill(0.40) } else { fill(0.16) };
+ let mut buf = SolidColorBuffer::default();
+ buf.update((r.w, r.h), color);
+ into.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+ &buf,
+ (r.x - rect.x, r.y - rect.y),
+ 1.0,
+ 1.0,
+ Kind::Unspecified,
+ )));
+ }
+ }
+
+ /// Emite el menú raíz en `rect` si esta salida es la dueña del menú.
+ /// El menú vive en **coords locales** de su salida (se abrió ahí), así
+ /// que las posiciones de las columnas no necesitan traducción.
+ fn emit_menu(&mut self, rect: Rect, into: &mut Vec<Frame<GlesRenderer>>) {
+ let Some(m) = self.root_menu.as_ref() else { return };
+ // El menú se rasteriza con el puntero **local** a su salida — así
+ // resaltado y hover apuntan a la fila correcta.
+ let (px, py) = self.app.pointer_loc;
+ let cols = m.render(px.round() as i32 - rect.x, py.round() as i32 - rect.y);
+ let menu_hl_color = rgba_f32(self.app.decorations.border_focus);
+ if self.text_cache.len() > 256 {
+ self.text_cache.clear();
+ }
+ for col in cols.iter().rev() {
+ // Texto (caché).
+ if let Some(tr) = &self.text {
+ for row in &col.rows {
+ let label = if row.submenu {
+ format!("{} ›", row.label)
+ } else {
+ row.label.clone()
+ };
+ let buf = self
+ .text_cache
+ .entry((label.clone(), MENU_TEXT_COLOR))
+ .or_insert_with(|| {
+ match tr.rasterize(&label, MENU_TEXT_PX, MENU_TEXT_COLOR) {
+ Some(r) => MemoryRenderBuffer::from_slice(
+ &r.rgba,
+ Fourcc::Argb8888,
+ (r.width, r.height),
+ 1,
+ Transform::Normal,
+ None,
+ ),
+ None => MemoryRenderBuffer::from_slice(
+ &[0u8; 4],
+ Fourcc::Argb8888,
+ (1, 1),
+ 1,
+ Transform::Normal,
+ None,
+ ),
+ }
+ });
+ let ty = row.y + (crate::menu::ITEM_H - MENU_TEXT_PX as i32) / 2;
+ if let Ok(el) = MemoryRenderBufferRenderElement::from_buffer(
+ &mut self.renderer,
+ ((row.x + 10) as f64, ty as f64),
+ buf,
+ None,
+ None,
+ None,
+ Kind::Unspecified,
+ ) {
+ into.push(Frame::Text(el));
+ }
+ }
+ }
+ // Resaltado.
+ for row in &col.rows {
+ if row.highlighted {
+ let mut hl = SolidColorBuffer::default();
+ hl.update((col.w, crate::menu::ITEM_H), menu_hl_color);
+ into.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+ &hl,
+ (row.x, row.y),
+ 1.0,
+ 1.0,
+ Kind::Unspecified,
+ )));
+ }
+ }
+ // Fondo.
+ let mut bg = SolidColorBuffer::default();
+ bg.update((col.w, col.h), MENU_BG);
+ into.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+ &bg,
+ (col.x, col.y),
+ 1.0,
+ 1.0,
+ Kind::Unspecified,
+ )));
+ }
+ }
+
+ /// Emite la pista de revelado del dock autoescondido — una franja fina en
+ /// el borde anclado mientras está oculto. Sólo en la salida donde vive
+ /// el shell (primaria).
+ fn emit_reveal_band(&mut self, rect: Rect, into: &mut Vec<Frame<GlesRenderer>>) {
+ if !(crate::shell_dock().autohide && self.app.shell_hidden) {
+ return;
+ }
+ let (ow, oh) = (rect.w, rect.h);
+ if ow <= 0 || oh <= 0 {
+ return;
+ }
+ let dock = crate::shell_dock();
+ let limite = if dock.anchor.es_horizontal() { oh } else { ow };
+ let t = dock.thickness.clamp(1, limite.max(1));
+ let (bx, by, bw, bh) =
+ crate::shell_reveal_band(dock.anchor, ow, oh, t, crate::SHELL_REVEAL_BAND);
+ let menu_hl_color = rgba_f32(self.app.decorations.border_focus);
+ let mut band = SolidColorBuffer::default();
+ band.update((bw, bh), menu_hl_color);
+ into.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+ &band,
+ (bx, by),
+ 1.0,
+ 1.0,
+ Kind::Unspecified,
+ )));
+ }
+
+ /// Emite el wallpaper de la salida `idx` al fondo (rearmándolo si quedó
+ /// stale). Cada salida tiene su propio búfer escalado, su propia ruta y
+ /// su propio modo de ajuste — un override por nombre puede pintarle un
+ /// fondo distinto a cada monitor.
+ fn emit_wallpaper(&mut self, idx: usize, into: &mut Vec<Frame<GlesRenderer>>) {
+ let ctx = &mut self.outputs[idx];
+ let Some(path) = ctx.wallpaper_path.clone() else {
+ return;
+ };
+ let fit = ctx.wallpaper_fit;
+ let size = (ctx.rect.w, ctx.rect.h);
+ let stale = ctx
+ .wallpaper
+ .as_ref()
+ .map(|(_, s)| *s != size)
+ .unwrap_or(true);
+ if stale && size.0 > 0 && size.1 > 0 {
+ ctx.wallpaper = load_wallpaper(&path, fit, size.0, size.1).map(|b| (b, size));
+ }
+ let Some((buf, _)) = &ctx.wallpaper else {
+ return;
+ };
+ if let Ok(el) = MemoryRenderBufferRenderElement::from_buffer(
+ &mut self.renderer,
+ (0.0, 0.0),
+ buf,
+ None,
+ None,
+ None,
+ Kind::Unspecified,
+ ) {
+ into.push(Frame::Text(el));
+ }
+ }
+
+ /// Refresca los búferes de marco (color por foco) de todas las ventanas
+ /// visibles. Es estado global que no depende de cuál salida se está
+ /// rindiendo — se hace una vez al inicio de [`Self::render`].
+ fn refresh_window_borders(&mut self) {
+ let dec = self.app.decorations;
+ let output_h = self.outputs[Self::PRIMARY].rect.h;
+ for w in &mut self.app.windows {
+ if !w.visible || w.is_shell {
+ continue;
+ }
+ let tb = crate::titlebar_for(w, dec.titlebar_height);
+ let (x, y) = crate::render_loc(w, output_h, dec.titlebar_height);
+ let (sw, sh) = crate::surface_px_size(w).unwrap_or((w.size.0, w.size.1 - tb));
+ let (x, y, sh) = (x, y - tb, sh + tb);
+ let color = rgba_f32(if w.focused {
+ dec.border_focus
+ } else {
+ dec.border_normal
+ });
+ let rects = border_rects(x, y, sw, sh, dec.border_width);
+ for (buf, (_, _, bw, bh)) in w.borders.iter_mut().zip(rects) {
+ buf.update((bw, bh), color);
+ }
+ }
+ }
+
+ /// Avisa a cada cliente (ventanas, layers de la primaria, cursor) de
+ /// que puede dibujar el siguiente cuadro. Se llama una sola vez por
+ /// `render`, no por salida.
+ fn send_frames_to_clients(&mut self) {
+ let time = self.start.elapsed().as_millis() as u32;
+ for w in &self.app.windows {
+ send_frames_surface_tree(&w.surface, time);
+ }
+ // Layers de TODAS las salidas — un cliente puede tener barras en
+ // distintos monitores, cada una con su frame-callback propio.
+ for output in self.app.outputs.clone() {
+ for layer in smithay::desktop::layer_map_for_output(&output).layers() {
+ send_frames_surface_tree(layer.wl_surface(), time);
+ }
+ }
+ if let CursorImageStatus::Surface(surface) = &self.app.cursor_status {
+ if surface.alive() {
+ send_frames_surface_tree(surface, time);
+ }
+ }
+ }
+
+ /// Render unificado de una salida. Cada feature decide si pertenece o
+ /// no a esta salida (gates por dueño) — el cursor en la del puntero,
+ /// HUD/layer-shell/reveal-band en primaria, menú y zonas en la salida
+ /// donde se inició la acción, ventanas y wallpaper en todas.
+ fn render_output(&mut self, idx: usize) {
+ if self.outputs[idx].pending_flip {
+ return;
+ }
+ let rect = self.outputs[idx].rect;
+ let is_primary = idx == Self::PRIMARY;
+ let owns_menu = self.menu_output_idx == Some(idx);
+
+ let elements: Vec<Frame<GlesRenderer>> = {
+ let mut out: Vec<Frame<GlesRenderer>> = Vec::new();
+
+ // 1. Cursor (si el puntero cae sobre esta salida).
+ self.emit_cursor(rect, &mut out);
+
+ // 2. HUD del preset (primaria por ahora; centrado en su rect).
+ if is_primary {
+ self.emit_hud(rect, &mut out);
+ }
+
+ // 3. Zonas de arrastre — en la salida bajo el puntero durante
+ // un drag (helper filtra por intersección de work-rect).
+ self.emit_zone_overlay(rect, &mut out);
+
+ // 4. Menú raíz — sólo en la salida donde se abrió.
+ if owns_menu {
+ self.emit_menu(rect, &mut out);
+ }
+
+ // 5. Pista de revelado del dock autoescondido — primaria, el
+ // shell vive ahí.
+ if is_primary {
+ self.emit_reveal_band(rect, &mut out);
+ }
+
+ // 6. Layer surfaces (waybar, swaybg…) de **esta** salida —
+ // smithay las cuelga por output, así que un layer mapeado a
+ // una secundaria con `output_hint` aparece donde toca.
+ let output_for_layers = self.outputs[idx].output.clone();
+ let (over_layers, under_layers) =
+ crate::layer_render_elements(Some(&output_for_layers), &mut self.renderer);
+ for el in over_layers {
+ out.push(Frame::Window(el));
+ }
+ // 7. Ventanas entre layers Overlay/Top y Bottom/Background.
+ self.emit_windows(rect, &mut out);
+ for el in under_layers {
+ out.push(Frame::Window(el));
+ }
+
+ // 8. Wallpaper al fondo (por salida).
+ self.emit_wallpaper(idx, &mut out);
+
+ out
+ };
+
+ let ctx = &mut self.outputs[idx];
+ match ctx.compositor.render_frame::<_, _>(
+ &mut self.renderer,
+ &elements,
+ CLEAR_COLOR,
+ FrameFlags::DEFAULT,
+ ) {
+ Ok(result) => {
+ if !result.is_empty {
+ match ctx.compositor.queue_frame(()) {
+ Ok(()) => ctx.pending_flip = true,
+ Err(e) => eprintln!(
+ "mirada-compositor · queue_frame[{}]: {e}",
+ ctx.name
+ ),
+ }
+ }
+ }
+ Err(e) => eprintln!(
+ "mirada-compositor · render_frame[{}]: {e}",
+ ctx.name
+ ),
+ }
+ }
+
+ /// La sesión se cede a otra VT (`Ctrl+Alt+Fn`): suelta la GPU y deja
+ /// de leer el ratón y el teclado, para no chocar con quien ahora
+ /// manda en la pantalla.
+ fn pause_session(&mut self) {
+ self.active = false;
+ self.drm.pause();
+ self.libinput.suspend();
+ println!("mirada-compositor · sesión cedida a otra VT.");
+ }
+
+ /// La sesión vuelve a esta VT: recupera la GPU y la entrada, reinicia
+ /// el estado de cada compositor y repinta.
+ fn resume_session(&mut self) {
+ if self.libinput.resume().is_err() {
+ eprintln!("mirada-compositor · libinput.resume falló.");
+ }
+ if let Err(e) = self.drm.activate(false) {
+ eprintln!("mirada-compositor · drm.activate falló: {e}");
+ }
+ for ctx in &mut self.outputs {
+ if let Err(e) = ctx.compositor.reset_state() {
+ eprintln!(
+ "mirada-compositor · compositor.reset_state[{}]: {e}",
+ ctx.name
+ );
+ }
+ ctx.pending_flip = false;
+ }
+ self.active = true;
+ self.render();
+ println!("mirada-compositor · sesión recuperada.");
+ }
+
+ /// Tarea periódica: Cerebro enlazado, recarga del keymap, API de
+ /// control, composición y vaciado hacia los clientes.
+ fn tick(&mut self) {
+ self.app.brain_poll();
+
+ let n = self.app.windows.len();
+ if n != self.last_windows {
+ eprintln!("mirada-compositor · ventanas en pantalla: {n}");
+ self.last_windows = n;
+ }
+
+ // Recarga en caliente de keymap/config/reglas si cambiaron en disco.
+ // Si la config general cambió, refresca las cachés que el Cuerpo deriva
+ // de ella (menú raíz, wallpaper, fuente de etiquetas) — el Cerebro ya
+ // aplicó teselado/decoración/foco.
+ if self.watches.poll(&mut self.app) {
+ self.menu_entries = self.app.config_menu();
+ // Reconstruye los presets de zonas y reacota el activo.
+ let mut presets = vec![self.app.config_zones()];
+ presets.extend(self.app.config_zone_presets());
+ self.zone_presets = presets;
+ if self.active_preset >= self.zone_presets.len() {
+ self.active_preset = 0;
+ }
+ self.zones = self.zone_presets.get(self.active_preset).cloned().unwrap_or_default();
+ self.root_menu = None; // un menú abierto puede quedar obsoleto
+ self.menu_output_idx = None;
+ // Refresca el wallpaper por salida: cada `OutputCtx` resuelve su
+ // ruta y su `fit` por nombre del conector (override o global).
+ for ctx in &mut self.outputs {
+ let new_wp = self.app.config_wallpaper_path_for(&ctx.name);
+ let new_fit = self.app.config_wallpaper_fit_for(&ctx.name);
+ if new_wp != ctx.wallpaper_path || new_fit != ctx.wallpaper_fit {
+ ctx.wallpaper_path = new_wp;
+ ctx.wallpaper_fit = new_fit;
+ ctx.wallpaper = None; // se rearma en el próximo render
+ }
+ }
+ self.text = crate::text::TextRenderer::system(self.app.config_font_path().as_deref());
+ }
+
+ if let Some(ctl) = &self.ctl {
+ while let Some(mut conn) = ctl.poll() {
+ let reply = match conn.read_request() {
+ // El ciclo de zonas es estado del Cuerpo (DRM): lo atendemos
+ // acá, no en el Cerebro. Avanza al siguiente preset.
+ Ok(Some(CtlRequest::CycleZones)) => {
+ if !self.zone_presets.is_empty() {
+ self.active_preset =
+ (self.active_preset + 1) % self.zone_presets.len();
+ self.zones = self.zone_presets[self.active_preset].clone();
+ self.preset_hud_label = format!(
+ "Zonas · {}/{}",
+ self.active_preset + 1,
+ self.zone_presets.len()
+ );
+ self.preset_hud_until = Some(Instant::now() + HUD_DURATION);
+ }
+ CtlReply::Ok
+ }
+ Ok(Some(req)) => self.app.serve_ctl(req),
+ Ok(None) => continue,
+ Err(e) => CtlReply::Error(format!("{e}")),
+ };
+ let _ = conn.reply(&reply);
+ }
+ }
+
+ self.render();
+ let _ = self.display.flush_clients();
+ }
+
+ /// Procesa un evento de `libinput`: teclado y puntero.
+ fn handle_input(&mut self, event: InputEvent<LibinputInputBackend>) {
+ let time = self.start.elapsed().as_millis() as u32;
+ match event {
+ // --- Teclado: intercepta los atajos del Cerebro --------------
+ InputEvent::Keyboard { event } => {
+ let Some(keyboard) = self.app.keyboard.clone() else {
+ return;
+ };
+ let code = event.key_code();
+ let key_state = event.state();
+ let pressed = key_state == KeyState::Pressed;
+ keyboard.input::<(), _>(
+ &mut self.app,
+ code,
+ key_state,
+ SERIAL_COUNTER.next_serial(),
+ time,
+ |st, mods, handle| {
+ if !pressed {
+ return FilterResult::Forward;
+ }
+ let sym = handle.modified_sym();
+ // Conmutar de VT (Ctrl+Alt+Fn o XF86Switch_VT_n). Lo
+ // aplica el backend tras el evento (sólo él tiene la
+ // sesión). Se chequea a nivel de keysym, antes del
+ // combo, porque según el keymap no llega como «Fn».
+ if let Some(vt) = crate::vt_target(mods, sym) {
+ st.pending_vt = Some(vt);
+ return FilterResult::Intercept(());
+ }
+ if let Some(combo) = combo_string(mods, sym) {
+ if crate::is_escape_hatch(&combo) {
+ eprintln!(
+ "mirada-compositor · salida de emergencia ({combo})."
+ );
+ st.running = false;
+ return FilterResult::Intercept(());
+ }
+ if st.grabs.contains(&combo) {
+ st.pending_keybind = Some(combo);
+ return FilterResult::Intercept(());
+ }
+ }
+ FilterResult::Forward
+ },
+ );
+ if let Some(combo) = self.app.pending_keybind.take() {
+ let ev = self.app.body.keybind(combo);
+ self.app.brain_feed(ev);
+ }
+ if let Some(vt) = self.app.pending_vt.take() {
+ if let Err(e) = self.session.change_vt(vt) {
+ eprintln!("mirada-compositor · no pude conmutar a VT{vt}: {e}");
+ }
+ }
+ }
+
+ // --- Puntero: movimiento relativo (ratón, touchpad) ----------
+ InputEvent::PointerMotion { event } => {
+ let (x0, y0) = self.app.pointer_loc;
+ // Pre-acotado al bounding box: descarta los outliers extremos
+ // sin hacer rondas innecesarias en `clamp_to_outputs`.
+ let x = (x0 + event.delta_x()).clamp(0.0, self.output_size.0);
+ let y = (y0 + event.delta_y()).clamp(0.0, self.output_size.1);
+ // Proyectado al output más cercano si cayó en zona muerta.
+ let (x, y) = self.clamp_to_outputs(x, y);
+ self.app.pointer_loc = (x, y);
+ if self.root_menu.is_some() {
+ // El menú vive en coords locales a su salida.
+ let idx = self.menu_output_idx.unwrap_or(Self::PRIMARY);
+ let r = self.outputs[idx].rect;
+ let lx = x.round() as i32 - r.x;
+ let ly = y.round() as i32 - r.y;
+ self.root_menu.as_mut().unwrap().update_hover(lx, ly);
+ return; // con el menú abierto, el puntero lo navega
+ }
+ self.app.update_shell_autohide(x, y);
+ if !self.drag_update() {
+ self.pointer_motion(time);
+ }
+ }
+
+ // --- Puntero: movimiento absoluto (táctil, tableta) ----------
+ InputEvent::PointerMotionAbsolute { event } => {
+ let space = Size::<i32, Logical>::from((
+ self.output_size.0 as i32,
+ self.output_size.1 as i32,
+ ));
+ let pos = event.position_transformed(space);
+ let x = pos.x.clamp(0.0, self.output_size.0);
+ let y = pos.y.clamp(0.0, self.output_size.1);
+ self.app.pointer_loc = self.clamp_to_outputs(x, y);
+ if self.root_menu.is_some() {
+ let (x, y) = self.app.pointer_loc;
+ let idx = self.menu_output_idx.unwrap_or(Self::PRIMARY);
+ let r = self.outputs[idx].rect;
+ let lx = x.round() as i32 - r.x;
+ let ly = y.round() as i32 - r.y;
+ self.root_menu.as_mut().unwrap().update_hover(lx, ly);
+ return; // con el menú abierto, el puntero lo navega
+ }
+ let (x, y) = self.app.pointer_loc;
+ self.app.update_shell_autohide(x, y);
+ if !self.drag_update() {
+ self.pointer_motion(time);
+ }
+ }
+
+ // --- Puntero: botones ----------------------------------------
+ InputEvent::PointerButton { event } => {
+ let pressed = event.state() == ButtonState::Pressed;
+ let button = event.button_code();
+
+ // Menú raíz abierto: el botón se lo come el menú. Click
+ // izquierdo sobre una hoja la lanza y cierra; sobre una
+ // fila-submenú la abre y sigue; click derecho o fuera cierra.
+ // (Sólo al apretar; soltar no hace nada.)
+ if pressed && self.root_menu.is_some() {
+ use crate::menu::ClickResult;
+ let (x, y) = self.app.pointer_loc;
+ let idx = self.menu_output_idx.unwrap_or(Self::PRIMARY);
+ let r = self.outputs[idx].rect;
+ let lx = x.round() as i32 - r.x;
+ let ly = y.round() as i32 - r.y;
+ let res = if button == BTN_LEFT {
+ self.root_menu.as_mut().unwrap().click(lx, ly)
+ } else {
+ ClickResult::Close
+ };
+ match res {
+ ClickResult::Launch(cmd) => {
+ self.root_menu = None;
+ self.menu_output_idx = None;
+ self.app.spawn_user(&cmd);
+ }
+ ClickResult::Stay => {}
+ ClickResult::Close => {
+ self.root_menu = None;
+ self.menu_output_idx = None;
+ }
+ }
+ return; // el menú captura el botón
+ }
+
+ // Click DERECHO sobre el fondo (sin ventana ni `Super`): abre el
+ // menú raíz, si hay entradas configuradas. No aplica en greeter.
+ if pressed
+ && button == BTN_RIGHT
+ && !self.menu_entries.is_empty()
+ && self.app.mode != BodyMode::Greeter
+ {
+ let super_held = self
+ .app
+ .keyboard
+ .as_ref()
+ .is_some_and(|kb| kb.modifier_state().logo);
+ let (x, y) = self.app.pointer_loc;
+ if !super_held && self.window_at(x, y).is_none() {
+ // El menú vive en el monitor donde se hizo el click; su
+ // origen y su rect de acotamiento son **locales** a ese
+ // monitor — así no se sale del borde de su pantalla.
+ let idx = self.output_at_point(x.round() as i32, y.round() as i32);
+ let r = self.outputs[idx].rect;
+ self.menu_output_idx = Some(idx);
+ self.root_menu = Some(crate::menu::RootMenu::open(
+ x.round() as i32 - r.x,
+ y.round() as i32 - r.y,
+ self.menu_entries.clone(),
+ r.w,
+ r.h,
+ ));
+ return; // el botón abrió el menú, no va al cliente
+ }
+ }
+
+ // ¿Empieza un arrastre? `Super`+botón sobre una ventana:
+ // izquierdo mueve, derecho redimensiona. En modo greeter no
+ // hay arrastre: el login está clavado a pantalla completa.
+ if pressed && self.app.drag.is_none() && self.app.mode != BodyMode::Greeter {
+ let super_held = self
+ .app
+ .keyboard
+ .as_ref()
+ .is_some_and(|kb| kb.modifier_state().logo);
+ // `Super`+izquierdo arrastra: una flotante se mueve, una
+ // teselada se reordena (swap). `Super`+derecho redimensiona
+ // (flotando la ventana si estaba teselada).
+ let (x, y) = self.app.pointer_loc;
+ let hit = self.window_at(x, y);
+ let mode = match (button, hit) {
+ (BTN_LEFT, Some(i)) if super_held => Some(if self.app.windows[i].floating {
+ DragMode::Move
+ } else {
+ DragMode::Tile
+ }),
+ (BTN_RIGHT, Some(_)) if super_held => Some(DragMode::Resize),
+ _ => None,
+ };
+ if let (Some(mode), Some(i)) = (mode, hit) {
+ let w = &self.app.windows[i];
+ let grab = DragGrab {
+ id: w.id,
+ mode,
+ start_pointer: (x, y),
+ start_rect: (w.loc.0, w.loc.1, w.size.0, w.size.1),
+ };
+ self.app.drag = Some(grab);
+ return; // el arrastre captura el botón
+ }
+ }
+
+ // Click izquierdo sobre la BARRA DE TÍTULO (sin `Super`): arranca
+ // un arrastre Move — saca la ventana de su tile y la lleva
+ // flotante, lista para aterrizar en una zona (drag-to-zone) o
+ // quedar overflow. La barra deja de ser chrome inerte.
+ if pressed
+ && button == BTN_LEFT
+ && self.app.drag.is_none()
+ && self.app.mode != BodyMode::Greeter
+ {
+ let (x, y) = self.app.pointer_loc;
+ if let Some(i) = self.titlebar_at(x, y) {
+ let (id, loc, size) = {
+ let w = &self.app.windows[i];
+ (w.id, w.loc, w.size)
+ };
+ self.app.drag = Some(DragGrab {
+ id,
+ mode: DragMode::Move,
+ start_pointer: (x, y),
+ start_rect: (loc.0, loc.1, size.0, size.1),
+ });
+ let ev = self.app.body.clicked(id); // enfoca la agarrada
+ self.app.brain_feed(ev);
+ return; // el arrastre captura el botón
+ }
+ }
+
+ // Durante un arrastre los botones no llegan al cliente;
+ // soltar cualquiera lo termina. Si se soltó sobre una zona
+ // (drag-to-zone), la ventana aterriza en ese rect (flotante);
+ // si no, queda flotando donde cayó (overflow, ya aplicado por
+ // el último drag_update).
+ if self.app.drag.is_some() {
+ if !pressed {
+ let mode = self.app.drag.as_ref().map(|d| d.mode);
+ let id = self.app.drag.as_ref().map(|d| d.id);
+ let zone = self.drag_zone.take();
+ self.app.drag = None;
+ if let (Some(mode), Some(id), Some(zi)) = (mode, id, zone) {
+ if matches!(mode, DragMode::Move | DragMode::Tile) {
+ if let Some(rect) = self.zone_rect(zi) {
+ self.app.brain_feed(BodyEvent::WindowFloatTo { id, rect });
+ }
+ }
+ }
+ }
+ return;
+ }
+
+ // Click sobre una barra que acepta teclado (cabezal de shuma):
+ // le damos el foco de teclado para poder escribir en el drawer.
+ // (El click en sí llega al cliente vía pointer.button de abajo,
+ // porque el motion ya enfocó el puntero en esa layer.)
+ if pressed {
+ let (x, y) = self.app.pointer_loc;
+ if let Some(surf) = self.app.keyboard_focusable_layer_under(x, y) {
+ if let Some(kb) = self.app.keyboard.clone() {
+ kb.set_focus(&mut self.app, Some(surf), SERIAL_COUNTER.next_serial());
+ }
+ } else if button == BTN_LEFT {
+ // Foco-al-click: la ventana clickeada pide el foco al
+ // Cerebro (que la pinta encima). Independiente del
+ // foco-sigue-ratón; el click sigue llegando al cliente.
+ if let Some(i) = self.window_at(x, y) {
+ if !self.app.windows[i].is_shell {
+ let id = self.app.windows[i].id;
+ let ev = self.app.body.clicked(id);
+ self.app.brain_feed(ev);
+ }
+ }
+ }
+ }
+
+ // Botón normal: a la ventana (o layer) bajo el puntero.
+ let Some(pointer) = self.app.pointer.clone() else {
+ return;
+ };
+ pointer.button(
+ &mut self.app,
+ &ButtonEvent {
+ serial: SERIAL_COUNTER.next_serial(),
+ time,
+ button,
+ state: event.state(),
+ },
+ );
+ pointer.frame(&mut self.app);
+ }
+
+ // --- Puntero: rueda / desplazamiento -------------------------
+ InputEvent::PointerAxis { event } => {
+ let Some(pointer) = self.app.pointer.clone() else {
+ return;
+ };
+ let source = event.source();
+ let mut frame = AxisFrame::new(time).source(source);
+ for axis in [Axis::Horizontal, Axis::Vertical] {
+ match event.amount(axis) {
+ Some(v) if v != 0.0 => frame = frame.value(axis, v),
+ Some(_) if source == AxisSource::Finger => {
+ frame = frame.stop(axis);
+ }
+ _ => {}
+ }
+ if let Some(d) = event.amount_v120(axis) {
+ frame = frame.v120(axis, d as i32);
+ }
+ }
+ pointer.axis(&mut self.app, frame);
+ pointer.frame(&mut self.app);
+ }
+
+ _ => {} // otros dispositivos: aún no
+ }
+ }
+
+ /// Reenvía el puntero a la ventana que tiene debajo y, si esa ventana
+ /// cambió, aplica el foco-sigue-ratón avisando al Cerebro.
+ fn pointer_motion(&mut self, time: u32) {
+ let Some(pointer) = self.app.pointer.clone() else {
+ return;
+ };
+ let (x, y) = self.app.pointer_loc;
+
+ // Las capas Overlay/Top (las barras de `pata`) están por encima de las
+ // ventanas: el puntero va ahí primero. Sin esto, los clicks sólo llegaban
+ // a las ventanas y las barras quedaban muertas al mouse.
+ if let Some((surface, loc)) = self.app.layer_under(x, y) {
+ pointer.motion(
+ &mut self.app,
+ Some((surface, loc)),
+ &MotionEvent {
+ location: Point::from((x, y)),
+ serial: SERIAL_COUNTER.next_serial(),
+ time,
+ },
+ );
+ pointer.frame(&mut self.app);
+ // El cliente del layer pondría su propio cursor; por ahora, el default.
+ self.app.cursor_status = CursorImageStatus::default_named();
+ // Dejamos de sobrevolar cualquier ventana.
+ self.last_pointer_window = None;
+ return;
+ }
+
+ let hit = self.window_at(x, y);
+ let focus = hit.map(|i| {
+ let w = &self.app.windows[i];
+ let (lx, ly) =
+ crate::render_loc(w, self.app.output_size.1, self.app.decorations.titlebar_height);
+ (
+ w.surface.clone(),
+ Point::<f64, Logical>::from((lx as f64, ly as f64)),
+ )
+ });
+ pointer.motion(
+ &mut self.app,
+ focus,
+ &MotionEvent {
+ location: Point::from((x, y)),
+ serial: SERIAL_COUNTER.next_serial(),
+ time,
+ },
+ );
+ pointer.frame(&mut self.app);
+
+ // Sobre el escritorio pelado no manda ningún cliente: el cursor
+ // vuelve al de por defecto (si no, se queda con la «I» del texto
+ // de la última ventana).
+ if hit.is_none() {
+ self.app.cursor_status = CursorImageStatus::default_named();
+ }
+
+ // Foco-sigue-ratón: al pasar a otra ventana, que la enfoque quien
+ // corresponda — el Cerebro para las teseladas, carmen mismo para
+ // el shell (que no vive en el Cerebro). PERO si una layer reclama teclado
+ // Exclusive (el drawer Quake de pata abierto), no le robamos el foco al
+ // mover el mouse sobre una ventana: seguís escribiendo en el drawer.
+ let exclusive_layer = self.app.exclusive_layer_surface().is_some();
+ let hovered = hit.map(|i| self.app.windows[i].id);
+ if hovered != self.last_pointer_window {
+ self.last_pointer_window = hovered;
+ match hit {
+ _ if exclusive_layer => {}
+ Some(i) if self.app.windows[i].is_shell => {
+ let surf = self.app.windows[i].surface.clone();
+ if let Some(kb) = self.app.keyboard.clone() {
+ kb.set_focus(&mut self.app, Some(surf), SERIAL_COUNTER.next_serial());
+ }
+ }
+ Some(i) => {
+ let id = self.app.windows[i].id;
+ let ev = self.app.body.pointer_enter(id);
+ self.app.brain_feed(ev);
+ }
+ None => {}
+ }
+ }
+ }
+
+ /// Si hay un arrastre en curso, recalcula el rectángulo de la ventana
+ /// y se lo manda al Cerebro (que la hace flotar ahí). Devuelve `true`
+ /// si consumió el movimiento — entonces el puntero no llega al cliente.
+ fn drag_update(&mut self) -> bool {
+ let Some(drag) = self.app.drag.as_ref() else {
+ return false;
+ };
+ let mode = drag.mode;
+ let (spx, spy) = drag.start_pointer;
+ let (sx, sy, sw, sh) = drag.start_rect;
+ let id = drag.id;
+
+ let (px, py) = self.app.pointer_loc;
+ // Drag-to-zone: resalta la zona bajo el puntero (Move/Tile, no Resize).
+ // Sobre una zona, la ventana aterrizará ahí al soltar.
+ self.drag_zone = if mode == DragMode::Resize { None } else { self.zone_at(px, py) };
+ // Arrastre de una teselada: el Cerebro la intercambia con la tesela
+ // bajo el puntero — no flota, sólo reordena el stack. Pero si está
+ // sobre una zona, suprimimos el swap (se resolverá al soltar).
+ if mode == DragMode::Tile {
+ if self.drag_zone.is_none() {
+ self.app
+ .brain_feed(BodyEvent::WindowDragged { id, x: px as i32, y: py as i32 });
+ }
+ return true;
+ }
+ let dx = (px - spx) as i32;
+ let dy = (py - spy) as i32;
+ let rect = match mode {
+ DragMode::Move => Rect::new(sx + dx, sy + dy, sw, sh),
+ DragMode::Resize => Rect::new(
+ sx,
+ sy,
+ (sw + dx).max(MIN_WINDOW),
+ (sh + dy).max(MIN_WINDOW),
+ ),
+ DragMode::Tile => unreachable!("Tile se maneja arriba"),
+ };
+ self.app.brain_feed(BodyEvent::WindowFloatTo { id, rect });
+ true
+ }
+
+ /// El índice de la ventana visible bajo el punto `(x, y)`, si la hay
+ /// — en orden front-to-back (el shell gana a las flotantes, y éstas a
+ /// las teseladas).
+ fn window_at(&self, x: f64, y: f64) -> Option<usize> {
+ let mut idx: Vec<usize> = (0..self.app.windows.len())
+ .filter(|&i| self.app.windows[i].visible)
+ .collect();
+ idx.sort_by_key(|&i| {
+ let w = &self.app.windows[i];
+ (!w.is_shell, !w.floating, !w.focused)
+ });
+ // `output_h` se usa para anclar el shell al borde inferior; el shell
+ // vive en la primaria, así que usamos su altura, no la total.
+ let output_h = self.outputs[Self::PRIMARY].rect.h;
+ let tbh = self.app.decorations.titlebar_height;
+ idx.into_iter().find(|&i| {
+ let w = &self.app.windows[i];
+ let tb = crate::titlebar_for(w, tbh);
+ let (lx, ly) = crate::render_loc(w, output_h, tbh);
+ let (sw, sh) = crate::surface_px_size(w).unwrap_or((w.size.0, (w.size.1 - tb).max(1)));
+ // Impacto sobre la SUPERFICIE (la barra de título es chrome inerte
+ // en este MVP: no captura el puntero hacia el cliente).
+ x >= lx as f64 && y >= ly as f64 && x < (lx + sw) as f64 && y < (ly + sh) as f64
+ })
+ }
+
+ /// El índice de la ventana cuya **barra de título** está bajo `(x, y)`, si
+ /// la hay (front-to-back). Permite agarrar la ventana por su barra para
+ /// arrastrarla (sin `Super`).
+ fn titlebar_at(&self, x: f64, y: f64) -> Option<usize> {
+ let tbh = self.app.decorations.titlebar_height;
+ if tbh <= 0 {
+ return None;
+ }
+ // `output_h` se usa para anclar el shell al borde inferior; el shell
+ // vive en la primaria, así que usamos su altura, no la total.
+ let output_h = self.outputs[Self::PRIMARY].rect.h;
+ let mut idx: Vec<usize> = (0..self.app.windows.len())
+ .filter(|&i| self.app.windows[i].visible)
+ .collect();
+ idx.sort_by_key(|&i| {
+ let w = &self.app.windows[i];
+ (!w.is_shell, !w.floating, !w.focused)
+ });
+ idx.into_iter().find(|&i| {
+ let w = &self.app.windows[i];
+ let tb = crate::titlebar_for(w, tbh);
+ if tb == 0 {
+ return false;
+ }
+ let (lx, ly) = crate::render_loc(w, output_h, tbh);
+ let (sw, _) = crate::surface_px_size(w).unwrap_or((w.size.0, (w.size.1 - tb).max(1)));
+ let top = ly - tb;
+ x >= lx as f64 && y >= top as f64 && x < (lx + sw) as f64 && y < (top + tb) as f64
+ })
+ }
+
+ /// Reenumera los conectores DRM y aplica las diferencias con
+ /// [`Self::outputs`]: monitores recién enchufados se agregan, los que
+ /// dejan de estar Connected se quitan. En cualquier cambio, se re-dispone
+ /// la geometría global, se notifica al Brain y se rearman las reservas.
+ fn detect_connector_changes(&mut self) {
+ use smithay::reexports::drm::control::{connector, crtc};
+ let resources = match self.drm.resource_handles() {
+ Ok(r) => r,
+ Err(e) => {
+ eprintln!("mirada-compositor · hotplug · no pude releer DRM: {e}");
+ return;
+ }
+ };
+ // Conectores Connected ahora, con su handle + nombre.
+ let mut live: Vec<(connector::Handle, String)> = Vec::new();
+ for &h in resources.connectors() {
+ let Ok(c) = self.drm.get_connector(h, false) else {
+ continue;
+ };
+ if c.state() == ConnectorState::Connected {
+ live.push((h, format!("{:?}-{}", c.interface(), c.interface_id())));
+ }
+ }
+ let live_names: std::collections::HashSet<&str> =
+ live.iter().map(|(_, n)| n.as_str()).collect();
+ let known_names: std::collections::HashSet<String> =
+ self.outputs.iter().map(|o| o.name.clone()).collect();
+
+ let mut changed = false;
+
+ // 1 · Desenchufes — drop OutputCtx + remove_output al Brain.
+ let to_remove: Vec<usize> = self
+ .outputs
+ .iter()
+ .enumerate()
+ .filter(|(_, o)| !live_names.contains(o.name.as_str()))
+ .map(|(i, _)| i)
+ .collect();
+ for &i in to_remove.iter().rev() {
+ let name = self.outputs[i].name.clone();
+ println!("mirada-compositor · hotplug · monitor «{name}» desenchufado");
+ let ev = self.app.body.remove_output(i as u32);
+ self.app.brain_feed(ev);
+ // Drop del compositor + smithay::Output: la GPU libera recursos.
+ let _ = self.outputs.remove(i);
+ changed = true;
+ }
+
+ // 2 · Enchufes — armar OutputCtx para cada conector nuevo.
+ let used_crtcs: Vec<crtc::Handle> = self.outputs.iter().map(|o| o.crtc).collect();
+ let mut taken: Vec<crtc::Handle> = used_crtcs.clone();
+ for (conn_handle, name) in &live {
+ if known_names.contains(name) {
+ continue;
+ }
+ let Ok(conn) = self.drm.get_connector(*conn_handle, false) else {
+ continue;
+ };
+ // Modo: el de mayor área (a igualdad, mayor refresco).
+ let Some(mode) = conn
+ .modes()
+ .iter()
+ .max_by_key(|m| {
+ let (w, h) = m.size();
+ (w as u32 * h as u32, m.vrefresh())
+ })
+ .copied()
+ else {
+ continue;
+ };
+ // CRTC libre compatible.
+ let crtc_choice = conn
+ .encoders()
+ .iter()
+ .filter_map(|enc| self.drm.get_encoder(*enc).ok())
+ .find_map(|enc| {
+ resources
+ .filter_crtcs(enc.possible_crtcs())
+ .into_iter()
+ .find(|c| !taken.contains(c))
+ });
+ let Some(crtc_h) = crtc_choice else {
+ eprintln!("mirada-compositor · hotplug · «{name}» sin CRTC libre — se ignora");
+ continue;
+ };
+ taken.push(crtc_h);
+ match self.armar_output_ctx(*conn_handle, crtc_h, mode, name.clone()) {
+ Ok(ctx) => {
+ println!("mirada-compositor · hotplug · monitor «{}» enchufado", ctx.name);
+ let (w, h) = mode.size();
+ let ev = self.app.body.add_output(
+ self.outputs.len() as u32,
+ w as i32,
+ h as i32,
+ );
+ self.app.brain_feed(ev);
+ self.outputs.push(ctx);
+ changed = true;
+ }
+ Err(e) => eprintln!("mirada-compositor · hotplug · falló «{name}»: {e}"),
+ }
+ }
+
+ if changed {
+ self.redisponer_outputs();
+ }
+ }
+
+ /// Crea un `OutputCtx` nuevo desde un conector recién enchufado: arma
+ /// `DrmSurface` + `DrmCompositor` + `smithay::Output` con la escala y
+ /// transformación que mande la config. Idéntica a la rama del discovery
+ /// inicial — el día que haya que tocar uno hay que tocar el otro.
+ fn armar_output_ctx(
+ &mut self,
+ conn_handle: smithay::reexports::drm::control::connector::Handle,
+ crtc_h: smithay::reexports::drm::control::crtc::Handle,
+ mode: smithay::reexports::drm::control::Mode,
+ name: String,
+ ) -> Result<OutputCtx, String> {
+ let (w, h) = mode.size();
+ let surface = self
+ .drm
+ .create_surface(crtc_h, mode, &[conn_handle])
+ .map_err(|e| format!("create_surface: {e}"))?;
+ let scale_120 = self.app.config_output_scale_120_for(&name);
+ let transform = self.app.config_output_transform_for(&name);
+ let scale_f64 = (if scale_120 > 0 { scale_120 } else { 120 }) as f64 / 120.0;
+ let mode_source = OutputModeSource::Static {
+ size: Size::from((w as i32, h as i32)),
+ scale: Scale::from(scale_f64),
+ transform,
+ };
+ let compositor: Compositor = DrmCompositor::new(
+ mode_source,
+ surface,
+ None,
+ self.allocator.clone(),
+ self.exporter.clone(),
+ [Fourcc::Argb8888, Fourcc::Xrgb8888],
+ self.renderer_formats.clone(),
+ self.drm.cursor_size(),
+ Some(self.gbm.clone()),
+ )
+ .map_err(|e| format!("DrmCompositor::new: {e}"))?;
+ let refresh_mhz = mode.vrefresh() as i32 * 1000;
+ let smithay_out = crate::announce_output(
+ &self.dh,
+ &name,
+ w as i32,
+ h as i32,
+ refresh_mhz,
+ scale_120,
+ transform,
+ );
+ let wp_path = self.app.config_wallpaper_path_for(&name);
+ let wp_fit = self.app.config_wallpaper_fit_for(&name);
+ Ok(OutputCtx {
+ name,
+ output: smithay_out,
+ crtc: crtc_h,
+ compositor,
+ // rect lo fija `redisponer_outputs` después de añadir.
+ rect: Rect::new(0, 0, w as i32, h as i32),
+ refresh_mhz,
+ wallpaper: None,
+ wallpaper_path: wp_path,
+ wallpaper_fit: wp_fit,
+ pending_flip: false,
+ })
+ }
+
+ /// Re-ordena las salidas por `(order, name)` de la config, recalcula sus
+ /// rects globales con `mirada-layout::disponer`, actualiza el espacio
+ /// total y resincroniza `app.outputs`/`app.output`/`app.output_size`,
+ /// invalida wallpapers (rearmados al próximo render) y re-emite las
+ /// reservas. Lo que NO toca: ventanas — el Brain decide a dónde van.
+ fn redisponer_outputs(&mut self) {
+ if self.outputs.is_empty() {
+ self.app.outputs.clear();
+ self.app.output = None;
+ self.app.output_size = (1, 1);
+ return;
+ }
+ // Sort por (order, name) — primaria queda en outputs[0].
+ let app_ref = &self.app;
+ self.outputs.sort_by(|a, b| {
+ let oa = app_ref.config_output_order_for(&a.name);
+ let ob = app_ref.config_output_order_for(&b.name);
+ oa.cmp(&ob).then_with(|| a.name.cmp(&b.name))
+ });
+ let tamanos: Vec<(i32, i32)> = self.outputs.iter().map(|o| (o.rect.w, o.rect.h)).collect();
+ let disp = self.app.config_output_disposition();
+ let rects = mirada_brain::disponer(&tamanos, disp);
+ for (ctx, r) in self.outputs.iter_mut().zip(rects.iter()) {
+ ctx.rect = *r;
+ ctx.wallpaper = None; // el tamaño global no cambia, pero la posición sí
+ }
+ let env = mirada_brain::envolvente(&rects);
+ let total_w = env.w.max(1);
+ let total_h = env.h.max(1);
+ self.app.output_size = (total_w, total_h);
+ self.output_size = (total_w as f64, total_h as f64);
+ // Resincronizar el registro Wayland.
+ self.app.outputs = self.outputs.iter().map(|c| c.output.clone()).collect();
+ self.app.output = self.outputs.first().map(|c| c.output.clone());
+ // Reposicionar el puntero al centro de la primaria si quedó fuera.
+ let (px, py) = self.app.pointer_loc;
+ let (px, py) = self.clamp_to_outputs(px, py);
+ self.app.pointer_loc = (px, py);
+ // Reservas y borders pueden cambiar con la nueva geometría.
+ self.app.recompute_reservations();
+ }
+
+ /// Work rect del monitor bajo el puntero — el "lienzo" de zonas para
+ /// arrastres. Multi-monitor: los zonas se escalan al monitor donde
+ /// está la acción, no al desktop global.
+ fn work_rect(&self) -> Rect {
+ let (px, py) = self.app.pointer_loc;
+ self.output_work_rect(self.output_at_point(px.round() as i32, py.round() as i32))
+ }
+
+ /// El rect en píxeles de la zona `i`, escalado al work-rect del
+ /// monitor bajo el puntero. Devuelve coords globales.
+ fn zone_rect(&self, i: usize) -> Option<Rect> {
+ let wr = self.work_rect();
+ self.zones.get(i).map(|z| z.to_rect(wr))
+ }
+
+ /// El índice de la zona de arrastre bajo `(x, y)`, si la hay. Las zonas
+ /// se hit-testean contra el work-rect del monitor que contiene `(x,y)`.
+ fn zone_at(&self, x: f64, y: f64) -> Option<usize> {
+ if self.zones.is_empty() {
+ return None;
+ }
+ let (xi, yi) = (x.round() as i32, y.round() as i32);
+ let wr = self.output_work_rect(self.output_at_point(xi, yi));
+ self.zones.iter().position(|z| {
+ let r = z.to_rect(wr);
+ xi >= r.x && yi >= r.y && xi < r.x + r.w && yi < r.y + r.h
+ })
+ }
+}
+
+/// Arranca el Cuerpo sobre DRM/KMS — fases 1, 2a y 2b. Con `greeter`,
+/// el compositor nace en modo DM: ver [`BodyMode`].
+pub fn run(greeter: bool) -> Result<(), Box<dyn Error>> {
+ println!("mirada-compositor · backend DRM.");
+ println!("──────────────────────────────────────────────────");
+
+ // 1 · Sesión.
+ println!("[1/8] abriendo la sesión (libseat) …");
+ let (mut session, session_notifier) = LibSeatSession::new().map_err(|e| {
+ format!(
+ "no pude abrir la sesión libseat: {e}\n \
+ ¿estás en una TTY de verdad (Ctrl+Alt+F3), con `seatd` o `logind`?"
+ )
+ })?;
+ let seat_name = session.seat();
+ println!(" sesión abierta · seat «{seat_name}»");
+
+ // 2 · GPU primaria.
+ println!("[2/8] buscando la GPU primaria …");
+ let gpu = udev::primary_gpu(&seat_name)
+ .map_err(|e| format!("error consultando udev: {e}"))?
+ .ok_or("no encontré ninguna GPU — ¿existe algún /dev/dri/card*?")?;
+ println!(" GPU primaria: {}", gpu.display());
+
+ // 3 · Dispositivo DRM.
+ println!("[3/8] abriendo el dispositivo DRM …");
+ let fd = session
+ .open(&gpu, OFlags::RDWR | OFlags::CLOEXEC | OFlags::NONBLOCK)
+ .map_err(|e| format!("no pude abrir {}: {e}", gpu.display()))?;
+ let drm_fd = DrmDeviceFd::new(DeviceFd::from(fd));
+ let (mut drm, drm_notifier) =
+ DrmDevice::new(drm_fd.clone(), true).map_err(|e| format!("DrmDevice::new falló: {e}"))?;
+ println!(" dispositivo DRM listo.");
+
+ // 4 · Enumerar TODAS las salidas conectadas: conector + CRTC + modo.
+ println!("[4/8] enumerando salidas …");
+ let resources = drm
+ .resource_handles()
+ .map_err(|e| format!("no pude leer los recursos DRM: {e}"))?;
+ use smithay::reexports::drm::control::{crtc, Mode as DrmMode};
+ let mut chosen: Vec<(
+ smithay::reexports::drm::control::connector::Handle,
+ crtc::Handle,
+ DrmMode,
+ String,
+ )> = Vec::new();
+ // Pool de CRTCs ya tomados — un CRTC no se reparte entre dos salidas.
+ let mut used_crtcs: Vec<crtc::Handle> = Vec::new();
+ for &conn_handle in resources.connectors() {
+ let conn = match drm.get_connector(conn_handle, false) {
+ Ok(c) => c,
+ Err(_) => continue,
+ };
+ if conn.state() != ConnectorState::Connected {
+ continue;
+ }
+ let name = format!("{:?}-{}", conn.interface(), conn.interface_id());
+ for m in conn.modes() {
+ let (mw, mh) = m.size();
+ let pref = if m.mode_type().contains(ModeTypeFlags::PREFERRED) {
+ " [PREFERRED]"
+ } else {
+ ""
+ };
+ eprintln!(" modo de «{name}»: {mw}×{mh} @ {} Hz{pref}", m.vrefresh());
+ }
+ // Elige el modo de mayor área (a igualdad, mayor refresco) — el
+ // nativo del panel.
+ let mode = conn
+ .modes()
+ .iter()
+ .max_by_key(|m| {
+ let (mw, mh) = m.size();
+ (mw as u32 * mh as u32, m.vrefresh())
+ })
+ .copied();
+ let Some(mode) = mode else {
+ continue;
+ };
+ // El primer CRTC compatible que no esté ya tomado.
+ let crtc_choice = conn
+ .encoders()
+ .iter()
+ .filter_map(|enc| drm.get_encoder(*enc).ok())
+ .find_map(|enc| {
+ resources
+ .filter_crtcs(enc.possible_crtcs())
+ .into_iter()
+ .find(|c| !used_crtcs.contains(c))
+ });
+ if let Some(crtc_h) = crtc_choice {
+ let (w, h) = mode.size();
+ println!(" salida «{name}» · {w}×{h} · CRTC {crtc_h:?}");
+ used_crtcs.push(crtc_h);
+ chosen.push((conn_handle, crtc_h, mode, name));
+ } else {
+ eprintln!(" salida «{name}» sin CRTC libre — se ignora");
+ }
+ }
+ if chosen.is_empty() {
+ return Err("ninguna salida conectada con CRTC disponible".into());
+ }
+ // El nombre de la primaria se decide tras ordenar por (order, name) más
+ // abajo — no por orden de discovery. Se captura justo después del sort.
+
+ // 5 · GBM + EGL + GlesRenderer.
+ println!("[5/8] inicializando GBM + EGL + GlesRenderer …");
+ let gbm = GbmDevice::new(drm_fd.clone()).map_err(|e| format!("GbmDevice::new falló: {e}"))?;
+ let egl_display =
+ unsafe { EGLDisplay::new(gbm.clone()) }.map_err(|e| format!("EGLDisplay::new falló: {e}"))?;
+ let egl_context =
+ EGLContext::new(&egl_display).map_err(|e| format!("EGLContext::new falló: {e}"))?;
+ let renderer =
+ unsafe { GlesRenderer::new(egl_context) }.map_err(|e| format!("GlesRenderer falló: {e}"))?;
+ println!(" renderer GLES listo.");
+
+ // 6 · Superficie DRM + DrmCompositor por cada salida descubierta.
+ // El renderer GLES se comparte (un solo EGLContext sobre la GPU); cada
+ // salida tiene su propio DrmSurface + DrmCompositor.
+ println!("[6/8] creando la superficie DRM y compositors ({}) …", chosen.len());
+ let allocator =
+ GbmAllocator::new(gbm.clone(), GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT);
+ let exporter = GbmFramebufferExporter::new(gbm.clone(), None);
+ let renderer_formats = renderer.dmabuf_formats();
+
+ // 7 · El estado Wayland (Cerebro, teclado, keymap, control).
+ println!("[7/8] armando el estado Wayland …");
+ let Setup { mut display, mut app, watches, ctl } =
+ crate::build_app(greeter)?;
+ // Con el renderer ya creado, anuncia dmabuf — sin esto las apps que
+ // pintan por GPU (GPUI, navegadores acelerados) no pueden conectarse.
+ crate::announce_dmabuf(&mut app, &display.handle(), &renderer);
+
+ // Ordenar las salidas según la config: `(order, name)` ascendente. La de
+ // menor `order` queda **primaria** (origen `(0,0)` y todas las features
+ // gated por primaria — layer-shell, tiling, HUD — viven ahí). Sin
+ // overrides todas son `order=0` y el desempate por nombre da un orden
+ // alfabético estable y reproducible (no el azar de discovery).
+ chosen.sort_by(|a, b| {
+ let oa = app.config_output_order_for(&a.3);
+ let ob = app.config_output_order_for(&b.3);
+ oa.cmp(&ob).then_with(|| a.3.cmp(&b.3))
+ });
+ // Primaria = la primera tras el sort.
+ let out_name = chosen[0].3.clone();
+
+ // Disponer los rects globales en la dirección que pide la config
+ // (horizontal o vertical). El cálculo lo hace `mirada-layout` —cero
+ // geometría a mano— y es la misma función que el día de mañana usará
+ // wawa cuando enumere scanouts.
+ let tamanos: Vec<(i32, i32)> = chosen
+ .iter()
+ .map(|(_, _, mode, _)| {
+ let (w, h) = mode.size();
+ (w as i32, h as i32)
+ })
+ .collect();
+ let disp = app.config_output_disposition();
+ let rects = mirada_brain::disponer(&tamanos, disp);
+
+ // Construir un OutputCtx por cada salida en el orden ya decidido.
+ let mut output_ctxs: Vec<OutputCtx> = Vec::with_capacity(chosen.len());
+ for (i, (conn_handle, crtc_h, mode, name)) in chosen.iter().cloned().enumerate() {
+ let (w, h) = mode.size();
+ let surface = drm
+ .create_surface(crtc_h, mode, &[conn_handle])
+ .map_err(|e| format!("create_surface[{name}] falló: {e}"))?;
+ // Scale/transform por salida — overrides de la config o defaults.
+ // Para `OutputModeSource::Static` (geometría DRM) hace falta el
+ // factor decimal; el announce al protocolo usa el enum de smithay
+ // (Integer/Fractional) para no romper clientes viejos.
+ let scale_120 = app.config_output_scale_120_for(&name);
+ let transform = app.config_output_transform_for(&name);
+ let scale_f64 = (if scale_120 > 0 { scale_120 } else { 120 }) as f64 / 120.0;
+ let mode_source = OutputModeSource::Static {
+ size: Size::from((w as i32, h as i32)),
+ scale: Scale::from(scale_f64),
+ transform,
+ };
+ let comp: Compositor = DrmCompositor::new(
+ mode_source,
+ surface,
+ None,
+ allocator.clone(),
+ exporter.clone(),
+ [Fourcc::Argb8888, Fourcc::Xrgb8888],
+ renderer_formats.clone(),
+ drm.cursor_size(),
+ Some(gbm.clone()),
+ )
+ .map_err(|e| format!("DrmCompositor::new[{name}] falló: {e}"))?;
+ let refresh_mhz = mode.vrefresh() as i32 * 1000;
+ let smithay_out = crate::announce_output(
+ &display.handle(),
+ &name,
+ w as i32,
+ h as i32,
+ refresh_mhz,
+ scale_120,
+ transform,
+ );
+ // Brain: cada salida es un id incremental con su tamaño local.
+ let ev = app.body.add_output(i as u32, w as i32, h as i32);
+ app.brain_feed(ev);
+ let rect = rects[i];
+ // Wallpaper resuelto por salida (override por nombre o global).
+ let wp_path = app.config_wallpaper_path_for(&name);
+ let wp_fit = app.config_wallpaper_fit_for(&name);
+ println!(" compositor de «{name}» listo · rect global {rect:?}");
+ output_ctxs.push(OutputCtx {
+ name,
+ output: smithay_out,
+ crtc: crtc_h,
+ compositor: comp,
+ rect,
+ refresh_mhz,
+ wallpaper: None,
+ wallpaper_path: wp_path,
+ wallpaper_fit: wp_fit,
+ pending_flip: false,
+ });
+ }
+
+ // Espacio global del escritorio: union de todos los rects ya dispuestos.
+ let env = mirada_brain::envolvente(&rects);
+ let total_w = env.w.max(1);
+ let total_h = env.h.max(1);
+ app.output_size = (total_w, total_h);
+ // El puntero arranca centrado en la salida primaria (no en el centro
+ // del escritorio global) — más predecible cuando hay varios monitores.
+ let primary_rect = output_ctxs[0].rect;
+ app.pointer_loc = (
+ (primary_rect.x + primary_rect.w / 2) as f64,
+ (primary_rect.y + primary_rect.h / 2) as f64,
+ );
+ // Primary `smithay::Output` para layer-shell, xdg-output handlers, etc.
+ app.output = Some(output_ctxs[0].output.clone());
+ app.outputs = output_ctxs.iter().map(|c| c.output.clone()).collect();
+
+ // El socket Wayland por el que se conectan los clientes.
+ let listener = ListeningSocket::bind_auto("wayland", 1..32)?;
+ let socket_name = listener
+ .socket_name()
+ .and_then(|s| s.to_str())
+ .unwrap_or("wayland-?")
+ .to_string();
+ std::env::set_var("WAYLAND_DISPLAY", &socket_name);
+ println!(" escuchando en WAYLAND_DISPLAY={socket_name}");
+
+ // Modo DM: lanza el greeter y recibe su tiquet por un canal de
+ // `calloop`. Modo normal: autoarranque + `MIRADA_STARTUP`.
+ let greeter_rx = if app.mode == BodyMode::Greeter {
+ let (tx, rx) = ticket_channel::<SessionTicket>();
+ crate::spawn_greeter(move |ticket| {
+ let _ = tx.send(ticket);
+ })?;
+ Some(rx)
+ } else {
+ // Autoarranque: los programas de `~/.config/mirada/autostart`.
+ // Modo no-greeter: ya corremos con la identidad del usuario, sin
+ // traspaso, así que no hay entorno de sesión que inyectar.
+ crate::spawn_autostart(None, &[]);
+ // App de arranque: si `MIRADA_STARTUP` trae un comando, se lanza
+ // como hijo (hereda `WAYLAND_DISPLAY`) — cómodo para probar sin
+ // saltar de VT.
+ if let Ok(cmd) = std::env::var("MIRADA_STARTUP") {
+ crate::spawn_command(&cmd, None, &[]);
+ }
+ None
+ };
+
+ // 8 · El bucle `calloop`: VBlank, teclado, clientes y un timer.
+ println!("[8/8] montando el bucle de eventos …");
+ let mut event_loop: EventLoop<DrmState> =
+ EventLoop::try_new().map_err(|e| format!("calloop falló: {e}"))?;
+ let handle = event_loop.handle();
+
+ // Sesión: pausa/activación al conmutar de VT.
+ handle
+ .insert_source(session_notifier, |event, _, state: &mut DrmState| match event {
+ SessionEvent::PauseSession => state.pause_session(),
+ SessionEvent::ActivateSession => state.resume_session(),
+ })
+ .map_err(|e| format!("insert session: {e}"))?;
+
+ // VBlank: el page-flip de un CRTC terminó. Rutearlo a la salida que lo
+ // posee. Si llega para un CRTC desconocido (no debería en estado estable)
+ // se ignora silenciosamente.
+ handle
+ .insert_source(drm_notifier, |event, _meta, state| match event {
+ DrmEvent::VBlank(crtc) => {
+ if let Some(idx) = state.output_index_by_crtc(crtc) {
+ let ctx = &mut state.outputs[idx];
+ if let Err(e) = ctx.compositor.frame_submitted() {
+ eprintln!(
+ "mirada-compositor · frame_submitted[{}]: {e}",
+ ctx.name
+ );
+ }
+ ctx.pending_flip = false;
+ }
+ }
+ DrmEvent::Error(e) => eprintln!("mirada-compositor · DRM: {e}"),
+ })
+ .map_err(|e| format!("insert drm: {e}"))?;
+
+ // Hotplug: udev notifica cuando un monitor se conecta/desconecta o
+ // cuando aparece/desaparece una GPU. En `Changed` reenumeramos los
+ // conectores y reconciliamos `outputs` (crear OutputCtx para los
+ // recién enchufados, dropear los desenchufados). Los eventos
+ // `Added`/`Removed` de GPU completa se logean: el compositor hoy
+ // sirve una sola GPU, cambiar la primaria pide reiniciar.
+ let udev = match smithay::backend::udev::UdevBackend::new(&seat_name) {
+ Ok(u) => u,
+ Err(e) => {
+ eprintln!("mirada-compositor · UdevBackend no arranca ({e}); sin hotplug");
+ return Err(format!("insert drm: udev: {e}").into());
+ }
+ };
+ handle
+ .insert_source(udev, |event, _meta, state| {
+ use smithay::backend::udev::UdevEvent;
+ match event {
+ UdevEvent::Added { device_id, path } => {
+ eprintln!(
+ "mirada-compositor · hotplug · GPU añadida: {} ({device_id:?}); ignorada (multi-GPU pendiente)",
+ path.display()
+ );
+ }
+ UdevEvent::Changed { device_id: _ } => state.detect_connector_changes(),
+ UdevEvent::Removed { device_id, .. } => {
+ eprintln!(
+ "mirada-compositor · hotplug · GPU retirada ({device_id:?}); ignorada (multi-GPU pendiente)"
+ );
+ }
+ }
+ })
+ .map_err(|e| format!("insert udev: {e}"))?;
+
+ // Teclado y ratón vía libinput. Guardamos un clon del contexto (es
+ // un manejador con contador de referencias) para suspenderlo y
+ // reanudarlo al conmutar de VT.
+ let mut libinput = Libinput::new_with_udev(LibinputSessionInterface::from(session.clone()));
+ libinput
+ .udev_assign_seat(&seat_name)
+ .map_err(|()| "libinput: no pude asignar el seat")?;
+ let libinput_handle = libinput.clone();
+ handle
+ .insert_source(LibinputInputBackend::new(libinput), |event, _meta, state| {
+ state.handle_input(event);
+ })
+ .map_err(|e| format!("insert libinput: {e}"))?;
+
+ // Clientes Wayland nuevos.
+ handle
+ .insert_source(
+ Generic::new(listener, Interest::READ, CalloopMode::Level),
+ |_readiness, listener, state| {
+ while let Some(stream) = listener.accept()? {
+ eprintln!("mirada-compositor · cliente Wayland conectado.");
+ let _ = state
+ .display
+ .handle()
+ .insert_client(stream, Arc::new(ClientState::default()));
+ }
+ Ok(PostAction::Continue)
+ },
+ )
+ .map_err(|e| format!("insert socket: {e}"))?;
+
+ // Peticiones de los clientes ya conectados.
+ let poll_fd = display.backend().poll_fd().try_clone_to_owned()?;
+ handle
+ .insert_source(
+ Generic::new(poll_fd, Interest::READ, CalloopMode::Level),
+ |_readiness, _fd, state| {
+ let DrmState { display, app, .. } = state;
+ if let Err(e) = display.dispatch_clients(app) {
+ eprintln!("mirada-compositor · dispatch: {e}");
+ }
+ let _ = display.flush_clients();
+ Ok(PostAction::Continue)
+ },
+ )
+ .map_err(|e| format!("insert display: {e}"))?;
+
+ // Timer de composición + tareas — ~60 Hz.
+ handle
+ .insert_source(Timer::immediate(), |_instant, _meta, state| {
+ state.tick();
+ TimeoutAction::ToDuration(Duration::from_millis(16))
+ })
+ .map_err(|e| format!("insert timer: {e}"))?;
+
+ // Tiquet del greeter (modo DM): al llegar, el traspaso a la sesión.
+ // El hilo lector del greeter despierta el bucle por este canal.
+ if let Some(rx) = greeter_rx {
+ handle
+ .insert_source(rx, |event, _, state: &mut DrmState| {
+ if let TicketEvent::Msg(ticket) = event {
+ state.app.complete_greeter_handoff(ticket);
+ }
+ })
+ .map_err(|e| format!("insert greeter: {e}"))?;
+ }
+
+ // Tope de tiempo opcional: `MIRADA_DRM_TIMEOUT=<segundos>` cierra el
+ // compositor solo (0 o sin definir = sin tope). El teclado ya
+ // funciona — `Super+Shift+e` o `Ctrl+C` son la salida normal.
+ let timeout_secs: u64 = std::env::var("MIRADA_DRM_TIMEOUT")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(0);
+
+ println!("──────────────────────────────────────────────────");
+ println!("mirada-compositor · escritorio en marcha sobre «{out_name}».");
+ println!(" Lanza un cliente: WAYLAND_DISPLAY={socket_name} foot");
+ println!(" Salir: Super+Shift+e · o Ctrl+C en esta TTY.");
+ if timeout_secs > 0 {
+ println!(" Se cerrará solo a los {timeout_secs}s (MIRADA_DRM_TIMEOUT=0 lo quita).");
+ }
+
+ let font_path = app.config_font_path();
+ let menu_entries = app.config_menu();
+ let zones = app.config_zones();
+ // Lista de presets: el 0 es `config.zones`, luego los de `zone_presets`.
+ let mut zone_presets = vec![zones.clone()];
+ zone_presets.extend(app.config_zone_presets());
+ let dh = display.handle();
+ let mut state = DrmState {
+ app,
+ session: session.clone(),
+ display,
+ drm,
+ gbm: gbm.clone(),
+ allocator: allocator.clone(),
+ exporter: exporter.clone(),
+ renderer_formats: renderer_formats.clone(),
+ dh,
+ outputs: output_ctxs,
+ renderer,
+ libinput: libinput_handle,
+ active: true,
+ watches,
+ ctl,
+ start: Instant::now(),
+ last_windows: 0,
+ cursor_id: Id::new(),
+ last_pointer_window: None,
+ output_size: (total_w as f64, total_h as f64),
+ text: {
+ let t = crate::text::TextRenderer::system(font_path.as_deref());
+ if t.is_some() {
+ println!("mirada-compositor · fuente de etiquetas cargada.");
+ } else {
+ eprintln!("mirada-compositor · sin fuente para etiquetas; no pinto títulos.");
+ }
+ t
+ },
+ text_cache: std::collections::HashMap::new(),
+ menu_entries,
+ root_menu: None,
+ menu_output_idx: None,
+ zones,
+ zone_presets,
+ active_preset: 0,
+ drag_zone: None,
+ preset_hud_until: None,
+ preset_hud_label: String::new(),
+ };
+
+ let signal = event_loop.get_signal();
+ event_loop
+ .run(None, &mut state, |state| {
+ let timed_out =
+ timeout_secs > 0 && state.start.elapsed() > Duration::from_secs(timeout_secs);
+ if !state.app.running || timed_out {
+ if timed_out {
+ println!("mirada-compositor · tope de tiempo — cerrando.");
+ }
+ signal.stop();
+ }
+ })
+ .map_err(|e| format!("el bucle de eventos falló: {e}"))?;
+
+ // Sesión ajena pendiente: soltamos TODO —`drop(state)` cierra el
+ // dispositivo DRM y `drop(event_loop)` libera el último clon de la
+ // sesión libseat (cede el seat)— y recién entonces ejecutamos el otro
+ // compositor, que ya puede tomar la GPU.
+ let pending = state.app.pending_session.take();
+ drop(state);
+ drop(event_loop);
+ if let Some((cmd, user)) = pending {
+ crate::exec_session(&cmd, user.as_ref());
+ }
+
+ println!("mirada-compositor · adiós.");
+ Ok(())
+}
diff --git a/02_ruway/mirada/mirada-compositor/src/main.rs b/02_ruway/mirada/mirada-compositor/src/main.rs
new file mode 100644
index 0000000..c759e59
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/src/main.rs
@@ -0,0 +1,2572 @@
+//! `mirada-compositor` — el Cuerpo del compositor carmen.
+//!
+//! Un compositor Wayland teselante real, sobre `smithay`, con backend
+//! `winit`: corre **anidado** como una ventana dentro de tu sesión
+//! gráfica actual (X11 o Wayland). Habla el protocolo Wayland con los
+//! clientes, compone sus superficies y aplica la geometría que decide el
+//! Cerebro.
+//!
+//! Dos modos:
+//!
+//! - **Autónomo** (por defecto): lleva un [`Desktop`] embebido — es un
+//! compositor teselante completo en un solo proceso. Lánzalo y abre
+//! clientes; el teclado (`Super+…`) maneja el escritorio.
+//! - **Enlazado** (`MIRADA_SOCKET=/ruta`): el Cuerpo escucha ahí y la
+//! app `mirada` (el Cerebro GPUI) se conecta; la geometría viaja por
+//! [`mirada_link`].
+//!
+//! Cómo probarlo en un Linux real: ver `crates/apps/mirada-compositor/README.md`.
+
+use std::sync::Arc;
+use std::time::Instant;
+
+use smithay::backend::allocator::dmabuf::Dmabuf;
+use smithay::backend::input::{InputEvent, KeyState, KeyboardKeyEvent};
+use smithay::backend::renderer::element::surface::{
+ render_elements_from_surface_tree, WaylandSurfaceRenderElement,
+};
+use smithay::backend::renderer::element::solid::SolidColorBuffer;
+use smithay::backend::renderer::element::Kind;
+use smithay::backend::renderer::gles::GlesRenderer;
+use smithay::backend::renderer::utils::{
+ draw_render_elements, on_commit_buffer_handler, with_renderer_surface_state,
+};
+use smithay::backend::renderer::{Color32F, Frame, ImportDma, Renderer};
+use smithay::backend::winit::{self, WinitEvent};
+use smithay::input::keyboard::{xkb, FilterResult, KeyboardHandle, Keysym, ModifiersState};
+use smithay::input::pointer::{CursorImageStatus, CursorImageSurfaceData, PointerHandle};
+use smithay::input::{Seat, SeatHandler, SeatState};
+use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode;
+use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
+use smithay::reexports::wayland_server::backend::{ClientData, ClientId, DisconnectReason};
+use smithay::reexports::wayland_server::protocol::wl_buffer;
+use smithay::reexports::wayland_server::protocol::wl_output;
+use smithay::reexports::wayland_server::protocol::wl_seat;
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::reexports::wayland_server::{Client, Display, DisplayHandle, ListeningSocket};
+use smithay::reexports::winit::platform::pump_events::PumpStatus;
+use smithay::utils::{Logical, Point, Rectangle, SERIAL_COUNTER};
+use smithay::utils::{Serial, Transform};
+use smithay::backend::egl::EGLDevice;
+use smithay::wayland::buffer::BufferHandler;
+use smithay::wayland::dmabuf::{
+ DmabufFeedbackBuilder, DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier,
+};
+use smithay::wayland::compositor::{
+ with_states, with_surface_tree_downward, CompositorClientState, CompositorHandler,
+ CompositorState, SurfaceAttributes, TraversalAction,
+};
+use smithay::wayland::selection::data_device::{
+ ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler,
+};
+use smithay::wayland::selection::wlr_data_control::{DataControlHandler, DataControlState};
+use smithay::wayland::selection::SelectionHandler;
+use smithay::wayland::shell::xdg::decoration::{XdgDecorationHandler, XdgDecorationState};
+use smithay::wayland::shell::xdg::{
+ PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState,
+ XdgToplevelSurfaceData,
+};
+use smithay::wayland::output::{OutputHandler, OutputManagerState};
+use smithay::wayland::shell::wlr_layer::{
+ KeyboardInteractivity, Layer, LayerSurface as WlrLayerSurface, LayerSurfaceData,
+ WlrLayerShellHandler, WlrLayerShellState,
+};
+use smithay::wayland::shm::{ShmHandler, ShmState};
+use smithay::desktop::{layer_map_for_output, LayerSurface as DesktopLayerSurface, WindowSurfaceType};
+use smithay::output::Output;
+use smithay::{
+ delegate_compositor, delegate_data_control, delegate_data_device, delegate_dmabuf,
+ delegate_layer_shell, delegate_output, delegate_seat, delegate_shm, delegate_xdg_decoration,
+ delegate_xdg_shell,
+};
+
+use auth_core::{SessionTicket, UserInfo};
+use mirada_body::{BodyOp, BodyState};
+use mirada_brain::{
+ BodyEvent, BrainCommand, CtlReply, CtlRequest, CtlServer, Desktop, Keymap, Rules,
+};
+use mirada_link::BodyLink;
+
+mod drm_backend;
+mod menu;
+mod text;
+
+// ---------------------------------------------------------------------
+// Estado
+// ---------------------------------------------------------------------
+
+/// De dónde salen las decisiones de geometría.
+enum Brain {
+ /// El compositor lleva su propio `Desktop` — proceso único.
+ Embedded(Desktop),
+ /// Un Cerebro externo (la app `mirada`) por socket.
+ Linked(BodyLink),
+}
+
+/// La fase del ciclo de vida del Cuerpo. Es un eje **ortogonal** a
+/// [`Brain`]: `Brain` dice de dónde sale la geometría; `BodyMode` dice
+/// si el compositor está pidiendo credenciales o sirviendo una sesión.
+/// Un arranque normal nace ya en [`BodyMode::Session`]; un arranque de
+/// DM (`--greeter`) nace en [`BodyMode::Greeter`] y muta una sola vez,
+/// al recibir el tiquet de un login válido — la «mutación atómica».
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum BodyMode {
+ /// Pantalla de login: el único cliente es el greeter, no se
+ /// registran atajos, se rechaza `Spawn` y no hay autoarranque.
+ Greeter,
+ /// Sesión de usuario: el compositor funciona con normalidad.
+ Session,
+}
+
+/// Grosor por defecto de la franja del shell (px), si el entorno no lo fija.
+const SHELL_DOCK_DEFAULT: i32 = 40;
+
+/// El borde de la salida al que se acopla la franja del shell (el marco `pata`).
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+enum ShellAnchor {
+ Top,
+ Bottom,
+ Left,
+ Right,
+}
+
+impl ShellAnchor {
+ /// Parsea el valor de `MIRADA_SHELL_ANCHOR`; cae a `Bottom` si no calza.
+ fn parse(s: &str) -> Self {
+ match s.trim().to_ascii_lowercase().as_str() {
+ "top" => Self::Top,
+ "left" => Self::Left,
+ "right" => Self::Right,
+ _ => Self::Bottom,
+ }
+ }
+
+ /// `true` para los bordes horizontales (top/bottom): su grosor es alto.
+ fn es_horizontal(&self) -> bool {
+ matches!(self, Self::Top | Self::Bottom)
+ }
+}
+
+/// Config del acople del shell, resuelta una vez desde el entorno:
+/// - `MIRADA_SHELL_APP_ID` — identidad de la ventana-marco (default `gioser.pata`).
+/// - `MIRADA_SHELL_ANCHOR` — borde (`top`/`bottom`/`left`/`right`, default `bottom`).
+/// - `MIRADA_SHELL_THICKNESS` — grosor en px (default `40`).
+/// - `MIRADA_SHELL_AUTOHIDE` — `1`/`true` para autoesconder el dock: nunca
+/// reserva su franja (las ventanas usan toda la pantalla) y sólo se muestra,
+/// superpuesto, al acercar el puntero al borde anclado.
+struct ShellDock {
+ app_id: String,
+ anchor: ShellAnchor,
+ thickness: i32,
+ autohide: bool,
+}
+
+/// Banda fina (px) del borde anclado que revela el dock autoescondido, y
+/// grosor de la sutil franja-pista que se pinta mientras está oculto.
+const SHELL_REVEAL_BAND: i32 = 3;
+
+/// La config del shell, leída del entorno la primera vez que se consulta.
+fn shell_dock() -> &'static ShellDock {
+ static DOCK: std::sync::OnceLock<ShellDock> = std::sync::OnceLock::new();
+ DOCK.get_or_init(|| {
+ let app_id =
+ std::env::var("MIRADA_SHELL_APP_ID").unwrap_or_else(|_| "gioser.pata".to_string());
+ let anchor = std::env::var("MIRADA_SHELL_ANCHOR")
+ .map(|s| ShellAnchor::parse(&s))
+ .unwrap_or(ShellAnchor::Bottom);
+ let thickness = std::env::var("MIRADA_SHELL_THICKNESS")
+ .ok()
+ .and_then(|s| s.trim().parse::<i32>().ok())
+ .filter(|t| *t > 0)
+ .unwrap_or(SHELL_DOCK_DEFAULT);
+ let autohide = std::env::var("MIRADA_SHELL_AUTOHIDE")
+ .map(|s| matches!(s.trim(), "1" | "true" | "yes" | "on"))
+ .unwrap_or(false);
+ ShellDock {
+ app_id,
+ anchor,
+ thickness,
+ autohide,
+ }
+ })
+}
+
+/// `true` si el `app_id` corresponde al shell: la identidad de `pata` (o el
+/// override de `MIRADA_SHELL_APP_ID`), o el alias legacy `carmen.shell`.
+fn is_shell_app_id(app_id: &str) -> bool {
+ app_id == shell_dock().app_id || app_id == "carmen.shell"
+}
+
+/// El rect `(x, y, w, h)` de la franja del shell sobre una salida `ow×oh` con
+/// grosor `t`, según el borde. Pura — fácil de testear.
+fn shell_strip(anchor: ShellAnchor, ow: i32, oh: i32, t: i32) -> (i32, i32, i32, i32) {
+ match anchor {
+ ShellAnchor::Top => (0, 0, ow, t),
+ ShellAnchor::Bottom => (0, oh - t, ow, t),
+ ShellAnchor::Left => (0, 0, t, oh),
+ ShellAnchor::Right => (ow - t, 0, t, oh),
+ }
+}
+
+/// Las zonas exclusivas `(top, bottom, left, right)` que reserva una franja de
+/// grosor `t` en el borde `anchor` — lo que el teselado debe esquivar. Pura.
+fn shell_insets(anchor: ShellAnchor, t: i32) -> (i32, i32, i32, i32) {
+ match anchor {
+ ShellAnchor::Top => (t, 0, 0, 0),
+ ShellAnchor::Bottom => (0, t, 0, 0),
+ ShellAnchor::Left => (0, 0, t, 0),
+ ShellAnchor::Right => (0, 0, 0, t),
+ }
+}
+
+/// La franja-pista `(x, y, w, h)` que se pinta en el borde mientras el dock
+/// autoescondido está oculto: una banda fina de grosor `band` pegada al borde
+/// anclado, a lo ancho/alto de la franja del dock. Pura.
+fn shell_reveal_band(anchor: ShellAnchor, ow: i32, oh: i32, t: i32, band: i32) -> (i32, i32, i32, i32) {
+ let (sx, sy, sw, sh) = shell_strip(anchor, ow, oh, t);
+ match anchor {
+ ShellAnchor::Top => (sx, 0, sw, band),
+ ShellAnchor::Bottom => (sx, oh - band, sw, band),
+ ShellAnchor::Left => (0, sy, band, sh),
+ ShellAnchor::Right => (ow - band, sy, band, sh),
+ }
+}
+
+/// Decide el próximo estado oculto/visible del dock autoescondido según el
+/// puntero. Asimétrico (con histéresis): si está oculto, sólo se revela al
+/// tocar la banda fina del borde (`edge_band`); si está visible, sólo se
+/// oculta cuando el puntero sale de la franja completa del dock. Pura.
+fn autohide_next_hidden(
+ anchor: ShellAnchor,
+ ow: i32,
+ oh: i32,
+ t: i32,
+ px: i32,
+ py: i32,
+ hidden: bool,
+ edge_band: i32,
+) -> bool {
+ let (sx, sy, sw, sh) = shell_strip(anchor, ow, oh, t);
+ let over_strip = px >= sx && px < sx + sw && py >= sy && py < sy + sh;
+ let at_edge = match anchor {
+ ShellAnchor::Top => py <= edge_band,
+ ShellAnchor::Bottom => py >= oh - edge_band,
+ ShellAnchor::Left => px <= edge_band,
+ ShellAnchor::Right => px >= ow - edge_band,
+ };
+ if hidden {
+ !at_edge
+ } else {
+ !over_strip
+ }
+}
+
+/// Una ventana de cliente que el compositor gestiona.
+struct ManagedWindow {
+ id: u64,
+ toplevel: ToplevelSurface,
+ surface: WlSurface,
+ /// Esquina superior-izquierda de la celda asignada, según el Cerebro.
+ loc: (i32, i32),
+ /// Tamaño de la celda asignada — para centrar la ventana si el
+ /// cliente presenta una superficie más pequeña.
+ size: (i32, i32),
+ visible: bool,
+ /// `true` si flota: se compone por encima de las teseladas.
+ floating: bool,
+ /// `true` si tiene el foco del teclado — pinta el marco resaltado.
+ focused: bool,
+ /// `true` si es la ventana del shell — acoplada al pie, sin teselar.
+ is_shell: bool,
+ /// `true` si está a pantalla completa — no lleva barra de título ni marco.
+ fullscreen: bool,
+ /// Título del cliente — para pintar la etiqueta (barra de título).
+ /// Se actualiza en `title_changed`.
+ title: String,
+ /// Búferes de los 4 lados del marco (arriba, abajo, izq., der.) —
+ /// cada uno con su `Id` estable para el seguimiento de daño.
+ borders: [SolidColorBuffer; 4],
+}
+
+/// Un arrastre de ratón en curso: mueve o redimensiona una ventana.
+struct DragGrab {
+ /// La ventana que se arrastra.
+ id: u64,
+ /// Mover (`Super`+botón izquierdo) o redimensionar (`Super`+derecho).
+ mode: DragMode,
+ /// Posición del puntero al empezar el arrastre.
+ start_pointer: (f64, f64),
+ /// Rectángulo `(x, y, w, h)` de la ventana al empezar.
+ start_rect: (i32, i32, i32, i32),
+}
+
+/// Qué le hace un arrastre a la ventana.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum DragMode {
+ /// Reubicar una ventana **flotante** — la esquina la sigue al puntero.
+ Move,
+ /// Redimensionarla — la esquina inferior-derecha sigue al puntero.
+ Resize,
+ /// Reordenar una ventana **teselada**: la intercambia con la tesela
+ /// bajo el puntero (el Cerebro decide el swap), sin sacarla del teselado.
+ Tile,
+}
+
+/// El estado global del compositor.
+struct App {
+ compositor_state: CompositorState,
+ xdg_shell_state: XdgShellState,
+ shm_state: ShmState,
+ /// Estado de `zwp_linux_dmabuf` — deja que los clientes con GPU
+ /// (apps GPUI, navegadores acelerados) compartan búferes de vídeo.
+ dmabuf_state: DmabufState,
+ seat_state: SeatState<Self>,
+ data_device_state: DataDeviceState,
+ /// Estado de `zwlr_data_control_manager_v1` — lectura/escritura del
+ /// portapapeles SIN robar foco. Sin esto, `wl-paste` (el widget `clipboard`
+ /// de pata lo corre ~1Hz) caía a su fallback: crear una surface de tamaño 0,
+ /// robar el foco de teclado para leer la selección, y destruirla — titilando
+ /// el foco cada segundo. También lo usan cliphist y los gestores de
+ /// portapapeles.
+ data_control_state: DataControlState,
+ seat: Seat<Self>,
+ /// Estado del protocolo `wlr-layer-shell` (barras/fondos/overlays como
+ /// waybar, swaybg, wofi, mako).
+ layer_shell_state: WlrLayerShellState,
+ /// La salida **primaria** — la necesita `layer_map_for_output` para
+ /// arreglar anclajes y zonas exclusivas de los layer surfaces que el
+ /// cliente no ate a un output específico (cae al primario).
+ output: Option<Output>,
+ /// Todas las salidas activas (la primaria es `outputs[0]`). El compositor
+ /// las publica acá tras armarlas, así un layer surface con `output_hint`
+ /// puede mapearse al monitor que el cliente pidió, no siempre al primario.
+ outputs: Vec<Output>,
+ /// Gestor de salidas con `xdg-output` (`zxdg_output_manager_v1`): waybar
+ /// y otras barras lo exigen para conocer nombre/geometría de las salidas.
+ /// Se conserva sólo para mantener vivo el global (de ahí el `allow`).
+ #[allow(dead_code)]
+ output_manager_state: OutputManagerState,
+ keyboard: Option<KeyboardHandle<Self>>,
+ pointer: Option<PointerHandle<Self>>,
+ /// Posición del puntero en coordenadas globales.
+ pointer_loc: (f64, f64),
+ /// Qué cursor pide el cliente enfocado — una superficie suya, un
+ /// cursor con nombre, u oculto. El backend lo pinta en consecuencia.
+ cursor_status: CursorImageStatus,
+ /// Arrastre de ventana en curso (mover o redimensionar con el ratón).
+ drag: Option<DragGrab>,
+ /// Tamaño real de la salida (con la franja del shell incluida) — lo
+ /// fija el backend; sirve para acoplar la ventana del shell.
+ output_size: (i32, i32),
+ /// Con el dock autoescondido (`MIRADA_SHELL_AUTOHIDE`), si está oculto
+ /// ahora. Sin autohide se ignora. El puntero cerca del borde lo alterna.
+ shell_hidden: bool,
+ /// Última reserva publicada `(top, bottom, left, right)` en px — define el
+ /// área de trabajo (salida menos dock/layers). Las zonas se escalan a ella.
+ reserved: (i32, i32, i32, i32),
+
+ /// Ventanas gestionadas, en orden de aparición.
+ windows: Vec<ManagedWindow>,
+ /// La contabilidad del
El parche sigue, pero se cortó acá: pedí el commit por git para verlo entero.