From 7fabf3ffcf5b62be83dbc8b38bcdeaf9033b1163 Mon Sep 17 00:00:00 2001 From: GGRei Date: Thu, 18 Jun 2026 13:55:52 +0200 Subject: [PATCH] gui: reclaim layout callback lifetimes --- _drag_reorder_test.v | 144 +++- _window_lifetime_test.v | 1420 +++++++++++++++++++++++++++++++++++++++ a11y_tree.v | 3 + animation.v | 29 +- closure_lifetime.v | 97 +++ data_source_grid.v | 55 +- docs/ANIMATIONS.md | 5 + drag_reorder.v | 87 ++- event_handlers.v | 33 +- layout.v | 6 +- markdown_math.v | 217 +++--- markdown_mermaid.v | 229 +++---- view_data_grid_events.v | 119 +++- view_form.v | 61 +- view_image.v | 5 +- view_listbox.v | 55 +- view_progress_bar.v | 55 +- view_sidebar.v | 97 ++- view_state.v | 31 +- view_svg.v | 41 +- view_tab_control.v | 12 +- view_tree.v | 16 +- window.v | 80 +-- window_api.v | 39 ++ window_update.v | 11 +- 25 files changed, 2471 insertions(+), 476 deletions(-) create mode 100644 _window_lifetime_test.v create mode 100644 closure_lifetime.v diff --git a/_drag_reorder_test.v b/_drag_reorder_test.v index 538a610..6959144 100644 --- a/_drag_reorder_test.v +++ b/_drag_reorder_test.v @@ -1,10 +1,73 @@ module gui +const drag_reorder_lifetime_payload_len = 64 + struct DragKeyboardCapture { mut: called bool moved string before string + value int +} + +fn drag_reorder_lifetime_item_layout(id string, y f32) Layout { + return Layout{ + shape: &Shape{ + id: id + x: 0 + y: y + width: 100 + height: 10 + } + } +} + +fn drag_reorder_lifetime_layout(drag_key string, seed int) Layout { + payload := []int{len: drag_reorder_lifetime_payload_len, init: seed + index} + return Layout{ + shape: &Shape{ + id: 'root' + } + children: [ + Layout{ + shape: &Shape{ + id: drag_reorder_drop_handler_id(drag_key) + events: &EventHandlers{ + on_scroll: fn [drag_key, payload] (_ &Layout, mut w Window) { + drop := drag_reorder_drop_take(mut w, drag_key) or { return } + mut cap := unsafe { &DragKeyboardCapture(w.state) } + cap.called = true + cap.moved = drop.moved_id + cap.before = drop.before_id + cap.value = payload[0] + payload[payload.len - 1] + } + } + } + }, + drag_reorder_lifetime_item_layout('a', 0), + drag_reorder_lifetime_item_layout('b', 10), + drag_reorder_lifetime_item_layout('c', 20), + ] + } +} + +fn drag_reorder_rebuild_lifetime_layout(mut w Window, drag_key string, seed int) { + w.layout_callback_lifetime.lifetime.frame(fn [mut w, drag_key, seed] () { + layout_clear(mut w.layout) + w.layout = drag_reorder_lifetime_layout(drag_key, seed) + }) or { panic(err) } + w.reclaim_old_layout_callbacks() +} + +fn drag_reorder_collect_and_churn_test() { + gc_collect() + for _ in 0 .. 1024 { + unsafe { + p := malloc(32) + vmemset(p, 0x55, 32) + } + } + gc_collect() } fn test_reorder_indices_cases() { @@ -229,15 +292,15 @@ fn test_drag_reorder_start_sets_layout_validity() { } drag_key_ok := 'drag_layout_ok' - drag_reorder_start(drag_key_ok, 0, 'a', .vertical, ['a', 'b'], fn (_ string, _ string, mut _ Window) {}, - ['a', 'b'], 0, 0, &item, &e, mut w) + drag_reorder_start(drag_key_ok, 0, 'a', .vertical, ['a', 'b'], ['a', 'b'], 0, 0, + '', &item, &e, mut w) state_ok := drag_reorder_get(mut w, drag_key_ok) assert state_ok.started assert state_ok.layouts_valid drag_key_missing := 'drag_layout_missing' - drag_reorder_start(drag_key_missing, 0, 'a', .vertical, ['a', 'b'], fn (_ string, _ string, mut _ Window) {}, - ['a', 'missing'], 0, 0, &item, &e, mut w) + drag_reorder_start(drag_key_missing, 0, 'a', .vertical, ['a', 'b'], ['a', 'missing'], + 0, 0, '', &item, &e, mut w) state_missing := drag_reorder_get(mut w, drag_key_missing) assert state_missing.started assert !state_missing.layouts_valid @@ -351,25 +414,78 @@ fn test_drag_reorder_cancels_on_mid_drag_mutation() { } drag_key := 'drag_mutation' - mut cap := &DragKeyboardCapture{} - drag_reorder_start(drag_key, 0, 'a', .vertical, ['a', 'b', 'c'], fn [mut cap] (m string, b string, mut _ Window) { - cap.called = true - cap.moved = m - cap.before = b - }, ['a', 'b', 'c'], 0, 0, &item, &e, mut w) + drag_reorder_start(drag_key, 0, 'a', .vertical, ['a', 'b', 'c'], ['a', 'b', 'c'], + 0, 0, '', &item, &e, mut w) drag_reorder_ids_meta_set(mut w, drag_key, ['a', 'b', 'c']) // Simulate list mutation before mouse-up. drag_reorder_ids_meta_set(mut w, drag_key, ['a', 'c']) - drag_reorder_on_mouse_up(drag_key, ['a', 'c'], fn [mut cap] (_ string, _ string, mut _ Window) { - cap.called = true - }, mut w) - assert !cap.called + drag_reorder_on_mouse_up(drag_key, ['a', 'c'], mut w) state := drag_reorder_get(mut w, drag_key) assert !state.started assert !state.active } +fn test_drag_reorder_mouse_up_uses_current_callback_after_reclaim() { + mut cap := &DragKeyboardCapture{} + mut w := Window{ + state: cap + layout_callback_lifetime: new_layout_callback_lifetime() + } + drag_key := 'drag_lifetime_drop' + drag_reorder_rebuild_lifetime_layout(mut w, drag_key, 11) + + mut parent := Layout{ + shape: &Shape{ + id: 'parent' + x: 0 + y: 0 + width: 100 + height: 100 + } + } + mut item := Layout{ + shape: &Shape{ + id: 'a' + x: 0 + y: 0 + width: 10 + height: 10 + } + parent: &parent + } + mut e := Event{ + mouse_x: 1 + mouse_y: 1 + } + + drag_reorder_start(drag_key, 0, 'a', .vertical, ['a', 'b', 'c'], ['a', 'b', 'c'], + 0, 0, '', &item, &e, mut w) + mut state := drag_reorder_get(mut w, drag_key) + state.active = true + state.current_index = 2 + drag_reorder_set(mut w, drag_key, state) + + drag_reorder_rebuild_lifetime_layout(mut w, drag_key, 29) + drag_reorder_rebuild_lifetime_layout(mut w, drag_key, 29) + drag_reorder_collect_and_churn_test() + + mouse_up := w.view_state.mouse_lock.mouse_up or { + assert false, 'expected mouse lock up callback' + return + } + mut up := Event{} + mouse_up(&w.layout, mut up, mut w) + + assert cap.called + assert cap.moved == 'a' + assert cap.before == 'c' + assert cap.value == 29 + 29 + drag_reorder_lifetime_payload_len - 1 + + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + fn test_drag_reorder_cancels_on_mid_drag_move_mutation() { mut w := Window{} w.layout = Layout{ diff --git a/_window_lifetime_test.v b/_window_lifetime_test.v new file mode 100644 index 0000000..4a4d5a4 --- /dev/null +++ b/_window_lifetime_test.v @@ -0,0 +1,1420 @@ +module gui + +import gg +import os +import time + +const lifetime_payload_len = 2048 +const lifetime_rebuild_iterations = 700 +const lifetime_memory_max_growth = usize(3 * 1024 * 1024) +const lifetime_persistent_animation_id = 'lifetime_persistent_animation' +const lifetime_hover_animation_id = 'lifetime_hover_animation' +const lifetime_progress_id = 'lifetime_progress' +const lifetime_progress_animation_id = '${lifetime_progress_id}_indefinite' +const lifetime_async_grid_id = 'lifetime_async_grid' +const lifetime_async_list_id = 'lifetime_async_list' +const lifetime_async_form_id = 'lifetime_async_form' +const lifetime_async_form_field_id = 'email' +const lifetime_math_hash = i64(314159) +const lifetime_mermaid_hash = i64(58058) +const lifetime_image_invalid_url = 'http://' +const lifetime_sidebar_id = 'lifetime_sidebar' +const lifetime_debounce_grid_id = 'lifetime_debounce_grid' + +struct LifetimeUpdateView implements View { + seed int + add_animation bool +mut: + content []View +} + +struct LifetimeHoverView implements View { + seed int +mut: + content []View +} + +struct LifetimeSidebarView implements View { + open bool + easing_mix f32 +mut: + content []View +} + +@[heap] +struct LifetimeAsyncSourceState { +mut: + grid_source ?DataGridDataSource + list_source ?ListBoxDataSource +} + +@[heap] +struct LifetimeUpdateState { +mut: + seed int + add_animation bool + animation_value int +} + +@[heap] +struct LifetimeSidebarState { +mut: + open bool + easing_mix f32 + width f32 +} + +@[heap] +struct LifetimeDataGridDebounceState { +mut: + seed int + show bool + call_count int + callback_value int + callback_text string +} + +@[heap] +struct LifetimeMouseLockState { +mut: + cfg MouseLockCfg +} + +@[heap] +struct LifetimeAnimationLayoutState { +mut: + called bool +} + +fn make_lifetime_window() Window { + return Window{ + layout_callback_lifetime: new_layout_callback_lifetime() + window_size: gg.Size{ + width: 100 + height: 100 + } + } +} + +fn lifetime_expected(seed int) int { + return seed + seed + lifetime_payload_len - 1 +} + +fn make_lifetime_callback_layout(seed int) Layout { + payload := []int{len: lifetime_payload_len, init: seed + index} + return Layout{ + shape: &Shape{ + width: 100 + height: 100 + events: &EventHandlers{ + on_click: fn [payload] (_ &Layout, mut e Event, mut _ Window) { + e.frame_count = u64(payload[0] + payload[payload.len - 1]) + e.is_handled = true + } + } + } + } +} + +fn make_lifetime_hover_animation_layout(seed int) Layout { + return Layout{ + shape: &Shape{ + width: 100 + height: 100 + shape_clip: gg.Rect{ + x: 0 + y: 0 + width: 100 + height: 100 + } + events: &EventHandlers{ + on_hover: fn [seed] (mut _ Layout, mut e Event, mut w Window) { + if lifetime_hover_animation_id !in w.animations { + payload := []int{len: lifetime_payload_len, init: seed + index} + mut state := unsafe { &LifetimeUpdateState(w.state) } + w.animation_add(mut Animate{ + id: lifetime_hover_animation_id + repeat: true + callback: fn [payload, mut state] (mut an Animate, mut _ Window) { + state.animation_value = payload[0] + payload[payload.len - 1] + an.stopped = true + } + }) + } + e.is_handled = true + } + } + } + } +} + +fn lifetime_update_view_generator(window &Window) View { + state := unsafe { &LifetimeUpdateState(window.state) } + return LifetimeUpdateView{ + seed: state.seed + add_animation: state.add_animation + } +} + +fn (mut view LifetimeUpdateView) generate_layout(mut w Window) Layout { + if view.add_animation && lifetime_persistent_animation_id !in w.animations { + seed := view.seed + w.animation_add_from_layout(fn [mut w, seed] () { + mut state := unsafe { &LifetimeUpdateState(w.state) } + w.animation_add(mut Animate{ + id: lifetime_persistent_animation_id + repeat: true + callback: fn [mut state, seed] (mut an Animate, mut _ Window) { + state.animation_value = lifetime_expected(seed) + an.stopped = true + } + }) + }) or { panic(err) } + } + return make_lifetime_callback_layout(view.seed) +} + +fn lifetime_progress_view_generator(_ &Window) View { + return progress_bar( + id: lifetime_progress_id + indefinite: true + width: 100 + height: 16 + ) +} + +fn lifetime_hover_view_generator(window &Window) View { + state := unsafe { &LifetimeUpdateState(window.state) } + return LifetimeHoverView{ + seed: state.seed + } +} + +fn lifetime_zero_window_view_generator(_ &Window) View { + return LifetimeUpdateView{ + seed: 43 + } +} + +fn lifetime_sidebar_view_generator(window &Window) View { + state := unsafe { &LifetimeSidebarState(window.state) } + return LifetimeSidebarView{ + open: state.open + easing_mix: state.easing_mix + } +} + +fn (mut view LifetimeSidebarView) generate_layout(mut w Window) Layout { + payload := [view.easing_mix] + easing := fn [payload] (t f32) f32 { + return payload[0] * t * t + (1 - payload[0]) * t + } + width := sidebar_animated_width(mut w, SidebarCfg{ + id: lifetime_sidebar_id + open: view.open + width: 100 + tween_duration: 300 * time.millisecond + tween_easing: easing + content: []View{} + }) + mut state := unsafe { &LifetimeSidebarState(w.state) } + state.width = width + return make_lifetime_callback_layout(44) +} + +fn lifetime_grid_columns() []GridColumnCfg { + return [ + GridColumnCfg{ + id: 'name' + title: 'Name' + }, + ] +} + +fn lifetime_grid_rows() []GridRow { + return [ + GridRow{ + id: 'row-1' + cells: { + 'name': 'Alpha' + } + }, + GridRow{ + id: 'row-2' + cells: { + 'name': 'Beta' + } + }, + ] +} + +fn lifetime_list_options() []ListBoxOption { + return [ + list_box_option('one', 'One', '1'), + list_box_option('two', 'Two', '2'), + ] +} + +type LifetimeFormIssueList = []FormIssue + +fn lifetime_form_async_validator(_ FormFieldSnapshot, _ FormSnapshot, signal &GridAbortSignal) !LifetimeFormIssueList { + time.sleep(40 * time.millisecond) + if signal.is_aborted() { + return LifetimeFormIssueList([]FormIssue{}) + } + return LifetimeFormIssueList([ + FormIssue{ + code: 'async' + msg: 'async complete' + }, + ]) +} + +fn lifetime_capturing_form_async_validator(payload []int) FormAsyncValidator { + return fn [payload] (_ FormFieldSnapshot, _ FormSnapshot, signal &GridAbortSignal) !LifetimeFormIssueList { + time.sleep(40 * time.millisecond) + if signal.is_aborted() { + return LifetimeFormIssueList([]FormIssue{}) + } + return LifetimeFormIssueList([ + FormIssue{ + code: 'captured_async' + msg: '${payload[0] + payload[payload.len - 1]}' + }, + ]) + } +} + +fn make_lifetime_form_field_layout(form_id string, field_id string) &Layout { + parent := &Layout{ + shape: &Shape{ + id: form_layout_id(form_id) + } + } + return &Layout{ + shape: &Shape{ + id: field_id + } + parent: parent + } +} + +fn (mut view LifetimeHoverView) generate_layout(mut _ Window) Layout { + return make_lifetime_hover_animation_layout(view.seed) +} + +fn (mut w Window) rebuild_lifetime_test_layout(seed int) { + w.layout_callback_lifetime.lifetime.frame(fn [mut w, seed] () { + layout_clear(mut w.layout) + w.layout = make_lifetime_callback_layout(seed) + }) or { panic(err) } + w.reclaim_old_layout_callbacks() +} + +fn (mut w Window) update_lifetime_test_layout(seed int) { + mut state := unsafe { &LifetimeUpdateState(w.state) } + state.seed = seed + w.update() +} + +fn collect_and_churn_lifetime_test() { + gc_collect() + for _ in 0 .. 1024 { + unsafe { + p := malloc(32) + vmemset(p, 0x55, 32) + } + } + gc_collect() +} + +fn lifetime_quick_filter_input_id() string { + return '${lifetime_debounce_grid_id}:quick_filter' +} + +fn lifetime_quick_filter_animation_id() string { + return '${lifetime_quick_filter_input_id()}:debounce' +} + +fn make_lifetime_quick_filter_debounce_layout(seed int) Layout { + input_id := lifetime_quick_filter_input_id() + payload := []int{len: lifetime_payload_len, init: seed + index} + query_callback := fn [payload] (query GridQueryState, mut _ Event, mut w Window) { + mut state := unsafe { &LifetimeDataGridDebounceState(w.state) } + state.call_count++ + state.callback_value = payload[0] + payload[payload.len - 1] + state.callback_text = query.quick_filter + } + return Layout{ + shape: &Shape{ + id: data_grid_quick_filter_debounce_handler_id(input_id) + events: &EventHandlers{ + on_scroll: fn [input_id, query_callback] (_ &Layout, mut w Window) { + data_grid_quick_filter_apply_debounce(input_id, query_callback, mut w) + } + } + } + } +} + +fn rebuild_lifetime_quick_filter_debounce_layout(mut w Window, seed int, show bool) { + w.layout_callback_lifetime.lifetime.frame(fn [mut w, seed, show] () { + layout_clear(mut w.layout) + if show { + w.layout = make_lifetime_quick_filter_debounce_layout(seed) + } else { + w.layout = Layout{ + shape: &Shape{ + id: 'lifetime_debounce_grid_gone' + } + } + } + }) or { panic(err) } + w.reclaim_old_layout_callbacks() +} + +fn start_lifetime_quick_filter_debounce(mut w Window, text string) u64 { + input_id := lifetime_quick_filter_input_id() + token := data_grid_quick_filter_set_debounce(input_id, DataGridQuickFilterDebounce{ + sorts: []GridSort{} + filters: []GridFilter{} + text: text + }, mut w) + w.animation_add(mut &Animate{ + id: lifetime_quick_filter_animation_id() + delay: 100 * time.millisecond + callback: fn [input_id, token] (mut _ Animate, mut w Window) { + data_grid_quick_filter_dispatch_debounce(input_id, token, mut w) + } + }) + return token +} + +fn lifetime_quick_filter_fallback_animate() Animate { + return Animate{ + id: 'missing' + callback: fn (mut _ Animate, mut _ Window) {} + } +} + +fn lifetime_quick_filter_animate(w &Window) Animate { + animation := w.animations[lifetime_quick_filter_animation_id()] or { + assert false, 'expected quick filter debounce animation' + return lifetime_quick_filter_fallback_animate() + } + match animation { + Animate { + return animation + } + else { + assert false, 'expected Animate debounce' + return lifetime_quick_filter_fallback_animate() + } + } +} + +fn lifetime_quick_filter_pending_token(w &Window) u64 { + pending := state_map_read[string, DataGridQuickFilterDebounce](w, ns_dg_quick_filter_debounce) or { + assert false, 'expected pending quick filter debounce' + return 0 + } + payload := pending.get(lifetime_quick_filter_input_id()) or { + assert false, 'expected pending quick filter payload' + return 0 + } + return payload.token +} + +fn lifetime_quick_filter_has_pending(w &Window) bool { + pending := state_map_read[string, DataGridQuickFilterDebounce](w, ns_dg_quick_filter_debounce) or { + return false + } + return pending.contains(lifetime_quick_filter_input_id()) +} + +fn run_lifetime_quick_filter_debounce(mut w Window) { + mut animation := lifetime_quick_filter_animate(&w) + animation.callback(mut animation, mut w) +} + +fn invoke_current_layout_click(mut w Window) int { + layout := w.layout.find_layout(fn (layout Layout) bool { + return layout.shape != unsafe { nil } && layout.shape.has_events() + && layout.shape.events.on_click != unsafe { nil } + }) or { + assert false, 'expected click layout' + return -1 + } + mut clickable := layout + assert !isnil(w.layout.shape) + mut e := Event{} + clickable.shape.events.on_click(&clickable, mut e, mut w) + assert e.is_handled + return int(e.frame_count) +} + +fn wait_for_lifetime_data_grid_source(mut w Window) { + for _ in 0 .. 40 { + time.sleep(10 * time.millisecond) + w.flush_commands() + stats := w.data_grid_source_stats(lifetime_async_grid_id) + if !stats.loading && stats.received_count == 2 { + return + } + } + stats := w.data_grid_source_stats(lifetime_async_grid_id) + assert false, 'expected async data grid source result, got loading=${stats.loading} received=${stats.received_count} error="${stats.load_error}"' +} + +fn wait_for_lifetime_list_box_source(mut w Window) { + for _ in 0 .. 40 { + time.sleep(10 * time.millisecond) + w.flush_commands() + stats := w.list_box_source_stats(lifetime_async_list_id) + if !stats.loading && stats.received_count == 2 { + return + } + } + stats := w.list_box_source_stats(lifetime_async_list_id) + assert false, 'expected async list box source result, got loading=${stats.loading} received=${stats.received_count} error="${stats.load_error}"' +} + +fn wait_for_lifetime_form_async_validation(mut w Window) { + for _ in 0 .. 40 { + time.sleep(10 * time.millisecond) + w.flush_commands() + state := w.form_field_state(lifetime_async_form_id, lifetime_async_form_field_id) or { + continue + } + if !state.pending && state.errors.len == 1 && state.errors[0].code == 'async' { + return + } + } + state := w.form_field_state(lifetime_async_form_id, lifetime_async_form_field_id) or { + FormFieldState{} + } + assert false, 'expected async form validation result, got pending=${state.pending} errors=${state.errors.len}' +} + +fn wait_for_lifetime_capturing_form_async_validation(mut w Window, expected_msg string) { + for _ in 0 .. 40 { + time.sleep(10 * time.millisecond) + w.flush_commands() + state := w.form_field_state(lifetime_async_form_id, lifetime_async_form_field_id) or { + continue + } + if !state.pending && state.errors.len == 1 && state.errors[0].code == 'captured_async' + && state.errors[0].msg == expected_msg { + return + } + } + state := w.form_field_state(lifetime_async_form_id, lifetime_async_form_field_id) or { + FormFieldState{} + } + msg := if state.errors.len > 0 { state.errors[0].msg } else { '' } + assert false, 'expected capturing async form validation result, got pending=${state.pending} errors=${state.errors.len} msg="${msg}"' +} + +fn wait_for_lifetime_math_oversized_error(mut w Window) { + for _ in 0 .. 40 { + time.sleep(10 * time.millisecond) + w.flush_commands() + entry := w.view_state.diagram_cache.get(lifetime_math_hash) or { continue } + if entry.state == .error && entry.error == 'LaTeX source too large' { + return + } + } + entry := w.view_state.diagram_cache.get(lifetime_math_hash) or { DiagramCacheEntry{} } + assert false, 'expected oversized math error, got state=${entry.state} error="${entry.error}"' +} + +fn wait_for_lifetime_mermaid_oversized_error(mut w Window) { + for _ in 0 .. 40 { + time.sleep(10 * time.millisecond) + w.flush_commands() + entry := w.view_state.diagram_cache.get(lifetime_mermaid_hash) or { continue } + if entry.state == .error && entry.error == 'Mermaid source too large' { + return + } + } + entry := w.view_state.diagram_cache.get(lifetime_mermaid_hash) or { DiagramCacheEntry{} } + assert false, 'expected oversized mermaid error, got state=${entry.state} error="${entry.error}"' +} + +fn wait_for_lifetime_image_download_cleanup(mut w Window) { + for _ in 0 .. 40 { + time.sleep(10 * time.millisecond) + w.flush_commands() + mut downloads := state_map[string, i64](mut w, ns_active_downloads, cap_moderate) + if !downloads.contains(lifetime_image_invalid_url) { + return + } + } + mut downloads := state_map[string, i64](mut w, ns_active_downloads, cap_moderate) + assert false, 'expected image download cleanup, got active=${downloads.len()}' +} + +fn start_lifetime_data_grid_source_request(mut w Window, source ?DataGridDataSource) { + cfg := DataGridCfg{ + id: lifetime_async_grid_id + columns: lifetime_grid_columns() + data_source: source + page_limit: 2 + } + caps := GridDataCapabilities{ + supports_cursor_pagination: true + row_count_known: true + } + w.layout_callback_lifetime.lifetime.frame(fn [mut w, cfg, caps] () { + mut state := DataGridSourceState{} + data_grid_source_start_request(cfg, caps, .cursor, 'lifetime-grid-key', mut state, mut w) + mut sm := state_map[string, DataGridSourceState](mut w, ns_dg_source, cap_moderate) + sm.set(lifetime_async_grid_id, state) + }) or { panic(err) } +} + +fn start_lifetime_list_box_source_request(mut w Window, source ?ListBoxDataSource) { + cfg := ListBoxCfg{ + id: lifetime_async_list_id + data_source: source + } + w.layout_callback_lifetime.lifetime.frame(fn [mut w, cfg] () { + mut state := ListBoxSourceState{} + list_box_source_start_request(cfg, 'lifetime-list-key', mut state, mut w) + mut sm := state_map[string, ListBoxSourceState](mut w, ns_list_box_source, cap_moderate) + sm.set(lifetime_async_list_id, state) + }) or { panic(err) } +} + +fn start_lifetime_form_async_validation(mut w Window) { + layout := make_lifetime_form_field_layout(lifetime_async_form_id, lifetime_async_form_field_id) + form_cfg := FormCfg{ + id: lifetime_async_form_id + validate_on: .change + } + field_cfg := FormFieldAdapterCfg{ + field_id: lifetime_async_form_field_id + value: 'user@example.com' + async_validators: [FormAsyncValidator(lifetime_form_async_validator)] + validate_on_override: .change + } + w.layout_callback_lifetime.lifetime.frame(fn [mut w, layout, form_cfg, field_cfg] () { + w.form_apply_cfg(lifetime_async_form_id, form_cfg) + w.form_register_field(layout, field_cfg) + w.form_on_field_event(layout, field_cfg, .change) + }) or { panic(err) } +} + +fn start_lifetime_capturing_form_async_validation(mut w Window, seed int) string { + layout := make_lifetime_form_field_layout(lifetime_async_form_id, lifetime_async_form_field_id) + form_cfg := FormCfg{ + id: lifetime_async_form_id + validate_on: .change + } + expected_msg := '${lifetime_expected(seed)}' + w.layout_callback_lifetime.lifetime.frame(fn [mut w, layout, form_cfg, seed] () { + payload := []int{len: lifetime_payload_len, init: seed + index} + validator := lifetime_capturing_form_async_validator(payload) + field_cfg := FormFieldAdapterCfg{ + field_id: lifetime_async_form_field_id + value: 'user@example.com' + async_validators: [validator] + validate_on_override: .change + } + w.form_apply_cfg(lifetime_async_form_id, form_cfg) + w.form_register_field(layout, field_cfg) + w.form_on_field_event(layout, field_cfg, .change) + }) or { panic(err) } + return expected_msg +} + +fn start_lifetime_math_oversized_fetch(mut w Window) { + latex := 'x'.repeat(max_latex_source_len + 1) + request_id := u64(1) + w.view_state.diagram_cache.set(lifetime_math_hash, DiagramCacheEntry{ + state: .loading + request_id: request_id + }) + w.layout_callback_lifetime.lifetime.frame(fn [mut w, latex, request_id] () { + fetch_math_async(mut w, latex, lifetime_math_hash, request_id, 120, rgba(0, 0, 0, 255)) + }) or { panic(err) } +} + +fn start_lifetime_mermaid_oversized_fetch(mut w Window) { + source := 'a'.repeat(max_mermaid_source_len + 1) + request_id := u64(1) + w.view_state.diagram_cache.set(lifetime_mermaid_hash, DiagramCacheEntry{ + state: .loading + request_id: request_id + }) + w.layout_callback_lifetime.lifetime.frame(fn [mut w, source, request_id] () { + fetch_mermaid_async(mut w, source, lifetime_mermaid_hash, request_id, 100, 255, 255, 255) + }) or { panic(err) } +} + +fn start_lifetime_image_invalid_download(mut w Window) { + w.layout_callback_lifetime.lifetime.frame(fn [mut w] () { + mut iv := ImageView{ + id: 'lifetime_image' + src: lifetime_image_invalid_url + width: 16 + height: 16 + } + _ = iv.generate_layout(mut w) + }) or { panic(err) } +} + +fn lifetime_sidebar_anim_id() string { + return 'sidebar:${lifetime_sidebar_id}' +} + +fn lifetime_sidebar_eased(progress f32, mix f32) f32 { + return mix * progress * progress + (1 - mix) * progress +} + +fn advance_lifetime_sidebar_tween(mut w Window, progress f32) { + mut animation := w.animations[lifetime_sidebar_anim_id()] or { + assert false, 'expected sidebar tween animation' + return + } + match mut animation { + TweenAnimation { + assert animation.from == 0 + assert animation.to == 1 + assert f32_abs(animation.easing(0.5) - f32(0.5)) < f32(0.0001) + animation.on_value(progress, mut w) + } + else { + assert false, 'expected TweenAnimation' + } + } +} + +fn lifetime_sidebar_runtime(mut w Window) SidebarRuntimeState { + mut sm := state_map[string, SidebarRuntimeState](mut w, ns_sidebar, cap_few) + return sm.get(lifetime_sidebar_id) or { + assert false, 'expected sidebar runtime' + return SidebarRuntimeState{} + } +} + +fn test_layout_lifetime_reclaims_old_capturing_callbacks_bounded() { + mut w := make_lifetime_window() + baseline := gc_memory_use() + + for i in 0 .. lifetime_rebuild_iterations { + w.rebuild_lifetime_test_layout(i) + if i % 50 == 0 { + collect_and_churn_lifetime_test() + } + } + collect_and_churn_lifetime_test() + + after := gc_memory_use() + growth := if after > baseline { after - baseline } else { usize(0) } + assert growth < lifetime_memory_max_growth + + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_zero_value_window_update_lazy_initializes_layout_lifetime() { + mut w := Window{} + w.window_size = gg.Size{ + width: 100 + height: 100 + } + w.update_view(lifetime_zero_window_view_generator) + + w.update() + w.update() + collect_and_churn_lifetime_test() + + assert w.layout_callback_lifetime.initialized + assert invoke_current_layout_click(mut w) == lifetime_expected(43) + + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_current_layout_survives_reclaim_one_frame() { + mut w := make_lifetime_window() + + w.rebuild_lifetime_test_layout(7) + collect_and_churn_lifetime_test() + + assert invoke_current_layout_click(mut w) == lifetime_expected(7) + + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_window_update_path_current_layout_survives_reclaim_one_frame() { + mut state := &LifetimeUpdateState{ + seed: 11 + } + mut w := make_lifetime_window() + w.state = state + w.view_generator = lifetime_update_view_generator + + w.update_lifetime_test_layout(11) + w.update_lifetime_test_layout(12) + collect_and_churn_lifetime_test() + + assert invoke_current_layout_click(mut w) == lifetime_expected(12) + + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_data_grid_quick_filter_debounce_uses_current_callback_after_reclaim() { + mut state := &LifetimeDataGridDebounceState{ + seed: 51 + show: true + } + mut w := make_lifetime_window() + w.state = state + + rebuild_lifetime_quick_filter_debounce_layout(mut w, state.seed, state.show) + start_lifetime_quick_filter_debounce(mut w, 'needle') + + state.seed = 73 + rebuild_lifetime_quick_filter_debounce_layout(mut w, state.seed, state.show) + collect_and_churn_lifetime_test() + run_lifetime_quick_filter_debounce(mut w) + + assert state.call_count == 1 + assert state.callback_value == lifetime_expected(73) + assert state.callback_text == 'needle' + + layout_clear(mut w.layout) + w.animations.delete(lifetime_quick_filter_animation_id()) + w.dispose_layout_callbacks() +} + +fn test_data_grid_quick_filter_debounce_stale_callback_keeps_current_payload() { + mut state := &LifetimeDataGridDebounceState{ + seed: 61 + show: true + } + mut w := make_lifetime_window() + w.state = state + + rebuild_lifetime_quick_filter_debounce_layout(mut w, state.seed, state.show) + old_token := start_lifetime_quick_filter_debounce(mut w, 'old') + mut stale_animation := lifetime_quick_filter_animate(&w) + + new_token := start_lifetime_quick_filter_debounce(mut w, 'new') + assert old_token != new_token + + stale_animation.callback(mut stale_animation, mut w) + assert state.call_count == 0 + assert lifetime_quick_filter_pending_token(&w) == new_token + + run_lifetime_quick_filter_debounce(mut w) + assert state.call_count == 1 + assert state.callback_text == 'new' + assert !lifetime_quick_filter_has_pending(&w) + + layout_clear(mut w.layout) + w.animations.delete(lifetime_quick_filter_animation_id()) + w.dispose_layout_callbacks() +} + +fn test_data_grid_quick_filter_debounce_clears_when_widget_disappears() { + mut state := &LifetimeDataGridDebounceState{ + seed: 81 + show: true + } + mut w := make_lifetime_window() + w.state = state + + rebuild_lifetime_quick_filter_debounce_layout(mut w, state.seed, state.show) + start_lifetime_quick_filter_debounce(mut w, 'gone') + + state.show = false + rebuild_lifetime_quick_filter_debounce_layout(mut w, state.seed, state.show) + collect_and_churn_lifetime_test() + run_lifetime_quick_filter_debounce(mut w) + + assert state.call_count == 0 + assert !lifetime_quick_filter_has_pending(&w) + + layout_clear(mut w.layout) + w.animations.delete(lifetime_quick_filter_animation_id()) + w.dispose_layout_callbacks() +} + +fn test_data_grid_source_spawn_created_during_update_survives_layout_reclaim() { + mut state := &LifetimeAsyncSourceState{} + state.grid_source = &InMemoryDataSource{ + rows: lifetime_grid_rows() + default_limit: 2 + latency_ms: 40 + } + mut w := make_lifetime_window() + + start_lifetime_data_grid_source_request(mut w, state.grid_source) + w.reclaim_old_layout_callbacks() + w.layout_callback_lifetime.lifetime.frame(fn () {}) or { panic(err) } + w.reclaim_old_layout_callbacks() + collect_and_churn_lifetime_test() + wait_for_lifetime_data_grid_source(mut w) + + stats := w.data_grid_source_stats(lifetime_async_grid_id) + assert stats.request_count == 1 + assert stats.received_count == 2 + + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_list_box_source_spawn_created_during_update_survives_layout_reclaim() { + mut state := &LifetimeAsyncSourceState{} + state.list_source = &InMemoryListBoxDataSource{ + data: lifetime_list_options() + latency_ms: 40 + } + mut w := make_lifetime_window() + + start_lifetime_list_box_source_request(mut w, state.list_source) + w.reclaim_old_layout_callbacks() + w.layout_callback_lifetime.lifetime.frame(fn () {}) or { panic(err) } + w.reclaim_old_layout_callbacks() + collect_and_churn_lifetime_test() + wait_for_lifetime_list_box_source(mut w) + + stats := w.list_box_source_stats(lifetime_async_list_id) + assert stats.request_count == 1 + assert stats.received_count == 2 + + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_form_async_validator_spawn_created_during_update_survives_layout_reclaim() { + mut w := make_lifetime_window() + + start_lifetime_form_async_validation(mut w) + w.reclaim_old_layout_callbacks() + w.layout_callback_lifetime.lifetime.frame(fn () {}) or { panic(err) } + w.reclaim_old_layout_callbacks() + collect_and_churn_lifetime_test() + wait_for_lifetime_form_async_validation(mut w) + + state := w.form_field_state(lifetime_async_form_id, lifetime_async_form_field_id) or { + assert false, 'expected form field state' + return + } + assert state.errors.len == 1 + assert state.errors[0].code == 'async' + + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_capturing_form_async_validator_created_during_update_survives_layout_reclaim() { + mut w := make_lifetime_window() + + expected_msg := start_lifetime_capturing_form_async_validation(mut w, 89) + assert w.layout_callback_lifetime.reclaim_pins == 1 + w.reclaim_old_layout_callbacks() + assert w.layout_callback_lifetime.reclaim_pins == 1 + w.layout_callback_lifetime.lifetime.frame(fn () {}) or { panic(err) } + w.reclaim_old_layout_callbacks() + assert w.layout_callback_lifetime.reclaim_pins == 1 + collect_and_churn_lifetime_test() + wait_for_lifetime_capturing_form_async_validation(mut w, expected_msg) + assert w.layout_callback_lifetime.reclaim_pins == 0 + + state := w.form_field_state(lifetime_async_form_id, lifetime_async_form_field_id) or { + assert false, 'expected form field state' + return + } + assert state.errors.len == 1 + assert state.errors[0].code == 'captured_async' + assert state.errors[0].msg == expected_msg + + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_math_spawn_created_during_update_survives_layout_reclaim_without_network() { + mut w := make_lifetime_window() + + start_lifetime_math_oversized_fetch(mut w) + w.reclaim_old_layout_callbacks() + w.layout_callback_lifetime.lifetime.frame(fn () {}) or { panic(err) } + w.reclaim_old_layout_callbacks() + collect_and_churn_lifetime_test() + wait_for_lifetime_math_oversized_error(mut w) + + entry := w.view_state.diagram_cache.get(lifetime_math_hash) or { + assert false, 'expected math cache entry' + return + } + assert entry.state == .error + assert entry.error == 'LaTeX source too large' + + w.view_state.diagram_cache.clear() + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_mermaid_spawn_created_during_update_survives_layout_reclaim() { + mut w := make_lifetime_window() + + start_lifetime_mermaid_oversized_fetch(mut w) + w.reclaim_old_layout_callbacks() + w.layout_callback_lifetime.lifetime.frame(fn () {}) or { panic(err) } + w.reclaim_old_layout_callbacks() + collect_and_churn_lifetime_test() + wait_for_lifetime_mermaid_oversized_error(mut w) + + entry := w.view_state.diagram_cache.get(lifetime_mermaid_hash) or { + assert false, 'expected mermaid cache entry' + return + } + assert entry.state == .error + assert entry.error == 'Mermaid source too large' + + w.view_state.diagram_cache.clear() + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_image_download_spawn_created_during_layout_survives_reclaim_without_network() { + mut w := make_lifetime_window() + + start_lifetime_image_invalid_download(mut w) + mut downloads := state_map[string, i64](mut w, ns_active_downloads, cap_moderate) + assert downloads.contains(lifetime_image_invalid_url) + w.reclaim_old_layout_callbacks() + w.layout_callback_lifetime.lifetime.frame(fn () {}) or { panic(err) } + w.reclaim_old_layout_callbacks() + collect_and_churn_lifetime_test() + wait_for_lifetime_image_download_cleanup(mut w) + + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_persistent_animation_created_during_update_survives_reclaim() { + mut state := &LifetimeUpdateState{ + seed: 21 + add_animation: true + } + mut w := make_lifetime_window() + w.state = state + w.view_generator = lifetime_update_view_generator + + w.update_lifetime_test_layout(21) + w.update_lifetime_test_layout(22) + collect_and_churn_lifetime_test() + + mut animation := w.animations[lifetime_persistent_animation_id] or { + assert false, 'expected persistent animation' + return + } + match mut animation { + Animate { + animation.callback(mut animation, mut w) + } + else { + assert false, 'expected Animate' + } + } + + assert state.animation_value == lifetime_expected(21) + + layout_clear(mut w.layout) + array_clear(mut w.renderers) + w.animations.delete(lifetime_persistent_animation_id) + w.dispose_layout_callbacks() +} + +fn test_sidebar_custom_tween_easing_created_during_layout_survives_reclaim() { + mut state := &LifetimeSidebarState{ + easing_mix: 0.5 + } + mut w := make_lifetime_window() + w.state = state + w.view_generator = lifetime_sidebar_view_generator + + w.update() + state.open = true + w.update() + advance_lifetime_sidebar_tween(mut w, 0.5) + w.reclaim_old_layout_callbacks() + w.layout_callback_lifetime.lifetime.frame(fn () {}) or { panic(err) } + w.reclaim_old_layout_callbacks() + collect_and_churn_lifetime_test() + w.update() + + want := f32(100) * lifetime_sidebar_eased(0.5, state.easing_mix) + assert f32_abs(state.width - want) < f32(0.0001) + + w.animations.delete(lifetime_sidebar_anim_id()) + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_sidebar_tween_interrupt_close_starts_from_resolved_visual_fraction() { + mut state := &LifetimeSidebarState{ + easing_mix: 0.5 + } + mut w := make_lifetime_window() + w.state = state + w.view_generator = lifetime_sidebar_view_generator + + w.update() + state.open = true + w.update() + advance_lifetime_sidebar_tween(mut w, 0.5) + w.update() + + mid_frac := lifetime_sidebar_eased(0.5, state.easing_mix) + assert f32_abs(state.width - f32(100) * mid_frac) < f32(0.0001) + + state.open = false + w.update() + + rt := lifetime_sidebar_runtime(mut w) + assert rt.tween_active + assert f32_abs(rt.tween_progress) < f32(0.0001) + assert f32_abs(rt.tween_from - mid_frac) < f32(0.0001) + assert f32_abs(rt.tween_to) < f32(0.0001) + assert f32_abs(state.width - f32(100) * mid_frac) < f32(0.0001) + + advance_lifetime_sidebar_tween(mut w, 0.5) + w.update() + want := f32(100) * lerp(mid_frac, 0, lifetime_sidebar_eased(0.5, state.easing_mix)) + assert f32_abs(state.width - want) < f32(0.0001) + + w.animations.delete(lifetime_sidebar_anim_id()) + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_indefinite_progress_animation_created_during_layout_survives_reclaim() { + mut w := make_lifetime_window() + w.view_generator = lifetime_progress_view_generator + + w.update() + w.update() + collect_and_churn_lifetime_test() + + mut animation := w.animations[lifetime_progress_animation_id] or { + assert false, 'expected progress animation' + return + } + match mut animation { + KeyframeAnimation { + animation.on_value(0.625, mut w) + } + else { + assert false, 'expected KeyframeAnimation' + } + } + + progress := state_map[string, f32](mut w, ns_progress, cap_moderate).get(lifetime_progress_id) or { + f32(-1) + } + assert progress == f32(0.625) + + layout_clear(mut w.layout) + array_clear(mut w.renderers) + w.animations.delete(lifetime_progress_animation_id) + w.dispose_layout_callbacks() +} + +fn test_persistent_animation_created_during_hover_survives_layout_reclaim() { + mut state := &LifetimeUpdateState{ + seed: 37 + } + mut w := make_lifetime_window() + w.state = state + w.view_generator = lifetime_hover_view_generator + w.ui = &gg.Context{ + mouse_pos_x: 10 + mouse_pos_y: 10 + width: 100 + height: 100 + } + + w.update_lifetime_test_layout(37) + w.update_lifetime_test_layout(38) + w.update_lifetime_test_layout(39) + collect_and_churn_lifetime_test() + + mut animation := w.animations[lifetime_hover_animation_id] or { + assert false, 'expected hover animation' + return + } + match mut animation { + Animate { + animation.callback(mut animation, mut w) + } + else { + assert false, 'expected Animate' + } + } + + assert state.animation_value == lifetime_expected(37) + + layout_clear(mut w.layout) + array_clear(mut w.renderers) + w.animations.delete(lifetime_hover_animation_id) + w.dispose_layout_callbacks() +} + +fn test_layout_lifetime_cleanup_disposes_after_frames() { + mut w := make_lifetime_window() + for i in 0 .. 8 { + w.rebuild_lifetime_test_layout(i) + } + layout_clear(mut w.layout) + array_clear(mut w.renderers) + w.dispose_layout_callbacks() + w.dispose_layout_callbacks() + w.reclaim_old_layout_callbacks() + collect_and_churn_lifetime_test() + assert true +} + +fn test_mouse_lock_callback_created_during_frame_survives_layout_reclaim() { + mut w := make_lifetime_window() + mut state := &LifetimeMouseLockState{} + w.layout_callback_lifetime.lifetime.frame(fn [mut state] () { + payload := []int{len: lifetime_payload_len, init: 31 + index} + state.cfg = MouseLockCfg{ + mouse_move: fn [payload] (_ &Layout, mut e Event, mut _ Window) { + e.frame_count = u64(payload[0] + payload[payload.len - 1]) + e.is_handled = true + } + } + }) or { panic(err) } + w.mouse_lock(state.cfg) + assert w.layout_callback_lifetime.reclaim_pins == 1 + + for i in 0 .. 32 { + w.rebuild_lifetime_test_layout(i) + assert w.layout_callback_lifetime.reclaim_pins == 1 + } + collect_and_churn_lifetime_test() + + mut e := Event{} + mouse_move_handler(w.layout, mut e, mut w) + assert e.is_handled + assert int(e.frame_count) == lifetime_expected(31) + + w.mouse_unlock() + assert w.layout_callback_lifetime.reclaim_pins == 0 + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_mouse_lock_unlock_during_handler_defers_reclaim_pin_release() { + mut w := make_lifetime_window() + mut state := &LifetimeMouseLockState{} + w.layout_callback_lifetime.lifetime.frame(fn [mut state] () { + payload := []int{len: lifetime_payload_len, init: 43 + index} + state.cfg = MouseLockCfg{ + mouse_up: fn [payload] (_ &Layout, mut e Event, mut w Window) { + w.mouse_unlock() + assert w.layout_callback_lifetime.reclaim_pins == 1 + assert w.view_state.mouse_lock_release_pending == 1 + w.reclaim_old_layout_callbacks() + w.layout_callback_lifetime.lifetime.frame(fn () {}) or { panic(err) } + w.reclaim_old_layout_callbacks() + collect_and_churn_lifetime_test() + e.frame_count = u64(payload[0] + payload[payload.len - 1]) + e.is_handled = true + } + } + }) or { panic(err) } + w.mouse_lock(state.cfg) + assert w.layout_callback_lifetime.reclaim_pins == 1 + + mut e := Event{} + mouse_up_handler(w.layout, mut e, mut w) + assert e.is_handled + assert int(e.frame_count) == lifetime_expected(43) + assert w.layout_callback_lifetime.reclaim_pins == 0 + assert w.view_state.mouse_lock_release_pending == 0 + + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_mouse_lock_replacement_and_double_unlock_balance_reclaim_pins() { + mut w := make_lifetime_window() + w.mouse_lock(MouseLockCfg{}) + assert w.layout_callback_lifetime.reclaim_pins == 0 + + w.mouse_lock(MouseLockCfg{ + mouse_move: fn (_ &Layout, mut e Event, mut _ Window) { + e.frame_count = 1 + e.is_handled = true + } + }) + assert w.layout_callback_lifetime.reclaim_pins == 1 + + w.mouse_lock(MouseLockCfg{ + mouse_move: fn (_ &Layout, mut e Event, mut _ Window) { + e.frame_count = 2 + e.is_handled = true + } + }) + assert w.layout_callback_lifetime.reclaim_pins == 1 + + w.mouse_lock(MouseLockCfg{}) + assert w.layout_callback_lifetime.reclaim_pins == 0 + w.mouse_unlock() + assert w.layout_callback_lifetime.reclaim_pins == 0 + w.dispose_layout_callbacks() +} + +fn test_mouse_lock_zero_value_window_balances_reclaim_pins() { + mut w := Window{} + w.mouse_lock(MouseLockCfg{ + mouse_move: fn (_ &Layout, mut e Event, mut _ Window) { + e.is_handled = true + } + }) + assert w.layout_callback_lifetime.reclaim_pins == 1 + assert w.layout_callback_lifetime.initialized + assert w.mouse_is_locked() + + w.mouse_unlock() + assert w.layout_callback_lifetime.reclaim_pins == 0 + assert !w.mouse_is_locked() + + w.mouse_unlock() + assert w.layout_callback_lifetime.reclaim_pins == 0 + assert !w.mouse_is_locked() +} + +fn test_animation_add_from_layout_zero_value_window_initializes_lifetime() { + mut w := Window{} + mut state := &LifetimeAnimationLayoutState{} + w.animation_add_from_layout(fn [mut state] () { + state.called = true + }) or { panic(err) } + + assert w.layout_callback_lifetime.initialized + assert state.called + w.dispose_layout_callbacks() +} + +fn test_mouse_lock_unlock_after_dispose_pending_releases_lifetime() { + mut w := make_lifetime_window() + w.mouse_lock(MouseLockCfg{ + mouse_move: fn (_ &Layout, mut e Event, mut _ Window) { + e.is_handled = true + } + }) + assert w.layout_callback_lifetime.reclaim_pins == 1 + + w.dispose_layout_callbacks() + assert w.layout_callback_lifetime.reclaim_pins == 1 + assert w.layout_callback_lifetime.dispose_pending + assert !w.layout_callback_lifetime.disposed + + w.mouse_unlock() + assert w.layout_callback_lifetime.reclaim_pins == 0 + assert !w.layout_callback_lifetime.dispose_pending + assert w.layout_callback_lifetime.disposed + + w.mouse_lock(MouseLockCfg{ + mouse_move: fn (_ &Layout, mut e Event, mut _ Window) { + e.is_handled = true + } + }) + assert !w.mouse_is_locked() + assert w.layout_callback_lifetime.reclaim_pins == 0 + + w.mouse_unlock() + assert w.layout_callback_lifetime.reclaim_pins == 0 +} + +fn test_mouse_lock_replacement_during_dispatch_keeps_new_lock_pinned() { + mut w := make_lifetime_window() + w.mouse_lock(MouseLockCfg{ + mouse_move: fn (_ &Layout, mut e Event, mut w Window) { + w.mouse_lock(MouseLockCfg{ + mouse_up: fn (_ &Layout, mut e Event, mut _ Window) { + e.frame_count = 2 + e.is_handled = true + } + }) + e.frame_count = 1 + e.is_handled = true + } + }) + assert w.layout_callback_lifetime.reclaim_pins == 1 + + mut move_event := Event{} + mouse_move_handler(w.layout, mut move_event, mut w) + assert move_event.is_handled + assert move_event.frame_count == 1 + assert w.layout_callback_lifetime.reclaim_pins == 1 + assert w.view_state.mouse_lock_release_pending == 0 + + mut up_event := Event{} + mouse_up_handler(w.layout, mut up_event, mut w) + assert up_event.is_handled + assert up_event.frame_count == 2 + w.mouse_unlock() + assert w.layout_callback_lifetime.reclaim_pins == 0 + w.mouse_unlock() + assert w.layout_callback_lifetime.reclaim_pins == 0 + + layout_clear(mut w.layout) + w.dispose_layout_callbacks() +} + +fn test_mouse_lock_clear_view_state_during_dispatch_defers_pin_release() { + mut w := make_lifetime_window() + w.mouse_lock(MouseLockCfg{ + mouse_up: fn (_ &Layout, mut e Event, mut w Window) { + w.clear_view_state() + assert w.layout_callback_lifetime.reclaim_pins == 1 + assert w.view_state.mouse_lock_release_pending == 1 + e.is_handled = true + } + }) + assert w.layout_callback_lifetime.reclaim_pins == 1 + + mut e := Event{} + mouse_up_handler(w.layout, mut e, mut w) + assert e.is_handled + assert w.layout_callback_lifetime.reclaim_pins == 0 + assert w.view_state.mouse_lock_release_pending == 0 + + w.dispose_layout_callbacks() +} + +fn test_animation_add_from_layout_is_public_fallible_api() { + $if windows { + return + } + tmp_dir := os.join_path(os.temp_dir(), 'gui_animation_layout_api_${os.getpid()}') + os.rmdir_all(tmp_dir) or {} + os.mkdir_all(tmp_dir) or { panic(err) } + defer { + os.rmdir_all(tmp_dir) or {} + } + + repo_dir := os.dir(@FILE) + os.symlink(repo_dir, os.join_path(tmp_dir, 'gui')) or { panic(err) } + source_path := os.join_path(tmp_dir, 'main.v') + source := 'import gui\n\n' + 'fn main() {\n' + '\tmut w := gui.Window{}\n' + + '\tw.animation_add_from_layout(fn [mut w] () {\n' + '\t\tw.animation_add(mut gui.Animate{\n' + "\t\t\tid: 'external_layout_animation'\n" + '\t\t\tcallback: fn (mut _ gui.Animate, mut _ gui.Window) {}\n' + + '\t\t})\n' + '\t}) or { panic(err) }\n' + '}\n' + os.write_file(source_path, source) or { panic(err) } + + cmd := '${os.quoted_path(@VEXE)} -path "${tmp_dir}|@vlib|@vmodules" -check ${os.quoted_path(source_path)}' + res := os.execute(cmd) + assert res.exit_code == 0, res.output +} diff --git a/a11y_tree.v b/a11y_tree.v index 4470818..bae3b8f 100644 --- a/a11y_tree.v +++ b/a11y_tree.v @@ -249,7 +249,10 @@ fn a11y_action_callback(action int, focus_id int, user_data voidptr) { fn window_cleanup(user_data voidptr) { if user_data != unsafe { nil } { mut w := unsafe { &Window(user_data) } + layout_clear(mut w.layout) + array_clear(mut w.renderers) w.release_all_file_access() + w.dispose_layout_callbacks() } nativebridge.a11y_destroy() } diff --git a/animation.v b/animation.v index 09f18a8..4a1e409 100644 --- a/animation.v +++ b/animation.v @@ -71,6 +71,12 @@ pub fn (mut window Window) animation_add(mut animation Animation) { window.animations[animation.id] = animation } +// animation_add_from_layout runs animation construction outside transient layout +// callback tracking before the animation is stored for later frames. +pub fn (mut window Window) animation_add_from_layout(work fn ()) ! { + window.suspend_layout_callback_tracking(work)! +} + // has_animation returns true if an animation with the given id is // currently active. Safe to call during view generation (no lock). pub fn (window &Window) has_animation(id string) bool { @@ -102,41 +108,49 @@ fn (mut window Window) animation_loop() { match mut animation { Animate { if update_animate(mut animation, mut window, mut deferred) { - refresh_kind = max_animation_refresh_kind(refresh_kind, animation.refresh_kind()) + refresh_kind = max_animation_refresh_kind(refresh_kind, + animation.refresh_kind()) } } BlinkCursorAnimation { if update_blink_cursor(mut animation, mut window) { - refresh_kind = max_animation_refresh_kind(refresh_kind, animation.refresh_kind()) + refresh_kind = max_animation_refresh_kind(refresh_kind, + animation.refresh_kind()) } } TweenAnimation { if update_tween(mut animation, mut window, mut deferred) { - refresh_kind = max_animation_refresh_kind(refresh_kind, animation.refresh_kind()) + refresh_kind = max_animation_refresh_kind(refresh_kind, + animation.refresh_kind()) } } SpringAnimation { if update_spring(mut animation, mut window, dt, mut deferred) { - refresh_kind = max_animation_refresh_kind(refresh_kind, animation.refresh_kind()) + refresh_kind = max_animation_refresh_kind(refresh_kind, + animation.refresh_kind()) } } LayoutTransition { if update_layout_transition(mut animation, mut window, mut deferred) { - refresh_kind = max_animation_refresh_kind(refresh_kind, animation.refresh_kind()) + refresh_kind = max_animation_refresh_kind(refresh_kind, + animation.refresh_kind()) } } HeroTransition { if update_hero_transition(mut animation, mut window, mut deferred) { - refresh_kind = max_animation_refresh_kind(refresh_kind, animation.refresh_kind()) + refresh_kind = max_animation_refresh_kind(refresh_kind, + animation.refresh_kind()) } } KeyframeAnimation { if update_keyframe(mut animation, mut window, mut deferred) { - refresh_kind = max_animation_refresh_kind(refresh_kind, animation.refresh_kind()) + refresh_kind = max_animation_refresh_kind(refresh_kind, + animation.refresh_kind()) } } else {} } + if animation.stopped { stopped_ids << animation.id } @@ -184,6 +198,7 @@ fn update_animate(mut an Animate, mut w Window, mut deferred []AnimationCallback true { an.start = time.now() } else { an.stopped = true } } + return true } } diff --git a/closure_lifetime.v b/closure_lifetime.v new file mode 100644 index 0000000..38d3ab4 --- /dev/null +++ b/closure_lifetime.v @@ -0,0 +1,97 @@ +module gui + +import builtin.closure as closure_api + +const layout_callback_retain_frames = 1 + +struct LayoutCallbackLifetime { +mut: + lifetime closure_api.Lifetime + initialized bool + disposed bool + dispose_pending bool + reclaim_pins int +} + +fn new_layout_callback_lifetime() LayoutCallbackLifetime { + return LayoutCallbackLifetime{ + lifetime: closure_api.new_lifetime() + initialized: true + } +} + +fn (mut window Window) ensure_layout_callback_lifetime() { + if window.layout_callback_lifetime.initialized || window.layout_callback_lifetime.disposed { + return + } + window.layout_callback_lifetime.lifetime = closure_api.new_lifetime() + window.layout_callback_lifetime.initialized = true +} + +fn (mut window Window) layout_callback_frame(work fn ()) ! { + if window.layout_callback_lifetime.disposed { + return error('layout callback lifetime has been disposed') + } + window.ensure_layout_callback_lifetime() + window.layout_callback_lifetime.lifetime.frame(work)! +} + +fn (mut window Window) reclaim_old_layout_callbacks() { + if window.layout_callback_lifetime.disposed { + return + } + if window.layout_callback_lifetime.reclaim_pins > 0 { + return + } + window.ensure_layout_callback_lifetime() + window.layout_callback_lifetime.lifetime.reclaim(layout_callback_retain_frames) or { + panic(err) + } +} + +fn (mut window Window) pin_layout_callback_reclaim() ! { + if window.layout_callback_lifetime.disposed || window.layout_callback_lifetime.dispose_pending { + return error('layout callback lifetime has been disposed') + } + window.ensure_layout_callback_lifetime() + window.layout_callback_lifetime.reclaim_pins++ +} + +fn (mut window Window) release_layout_callback_reclaim_pin() { + if window.layout_callback_lifetime.disposed { + return + } + if window.layout_callback_lifetime.reclaim_pins > 0 { + window.layout_callback_lifetime.reclaim_pins-- + } + if window.layout_callback_lifetime.reclaim_pins == 0 + && window.layout_callback_lifetime.dispose_pending { + window.dispose_layout_callbacks() + } +} + +fn (mut window Window) suspend_layout_callback_tracking(work fn ()) ! { + if window.layout_callback_lifetime.disposed { + return error('layout callback lifetime has been disposed') + } + window.ensure_layout_callback_lifetime() + window.layout_callback_lifetime.lifetime.suspend(work)! +} + +fn (mut window Window) dispose_layout_callbacks() { + if window.layout_callback_lifetime.disposed { + return + } + if window.layout_callback_lifetime.reclaim_pins > 0 { + window.layout_callback_lifetime.dispose_pending = true + return + } + if !window.layout_callback_lifetime.initialized { + window.layout_callback_lifetime.disposed = true + window.layout_callback_lifetime.dispose_pending = false + return + } + window.layout_callback_lifetime.lifetime.dispose() or { panic(err) } + window.layout_callback_lifetime.disposed = true + window.layout_callback_lifetime.dispose_pending = false +} diff --git a/data_source_grid.v b/data_source_grid.v index f4f3823..c7f8ebd 100644 --- a/data_source_grid.v +++ b/data_source_grid.v @@ -274,6 +274,7 @@ fn data_grid_source_start_request(cfg DataGridCfg, caps GridDataCapabilities, ki }) } } + req := GridDataRequest{ grid_id: cfg.id query: cfg.query @@ -289,35 +290,35 @@ fn data_grid_source_start_request(cfg DataGridCfg, caps GridDataCapabilities, ki state.request_count++ state.pagination_kind = kind grid_id := cfg.id - spawn fn [source, req, grid_id, next_request_id, caps] (mut w Window) { - if req.signal.is_aborted() { - return - } - result := source.fetch_data(req) or { + window.suspend_layout_callback_tracking(fn [source, req, grid_id, next_request_id, caps, mut window] () { + spawn fn [source, req, grid_id, next_request_id, caps] (mut w Window) { if req.signal.is_aborted() { return } - err_msg := err.msg() - w.queue_command(fn [grid_id, next_request_id, err_msg] (mut w Window) { - data_grid_source_apply_error(grid_id, next_request_id, err_msg, mut w) + result := source.fetch_data(req) or { + if req.signal.is_aborted() { + return + } + err_msg := err.msg() + w.queue_command(fn [grid_id, next_request_id, err_msg] (mut w Window) { + data_grid_source_apply_error(grid_id, next_request_id, err_msg, mut w) + }) + return + } + if req.signal.is_aborted() { + return + } + w.queue_command(fn [grid_id, next_request_id, result, caps] (mut w Window) { + data_grid_source_apply_success(grid_id, next_request_id, result, caps, mut w) }) - return - } - if req.signal.is_aborted() { - return - } - w.queue_command(fn [grid_id, next_request_id, result, caps] (mut w Window) { - data_grid_source_apply_success(grid_id, next_request_id, result, caps, mut - w) - }) - }(mut window) + }(mut window) + }) or { panic(err) } } fn data_grid_source_drop_if_stale(request_id u64, mut state DataGridSourceState, mut window Window, grid_id string) bool { if request_id != state.request_id { state.stale_drop_count++ - mut dg_src := state_map[string, DataGridSourceState](mut window, ns_dg_source, - cap_moderate) + mut dg_src := state_map[string, DataGridSourceState](mut window, ns_dg_source, cap_moderate) dg_src.set(grid_id, state) return true } @@ -516,8 +517,8 @@ fn data_grid_source_jump_enabled(on_selection_change fn (GridSelection, mut Even } fn data_grid_source_submit_jump(on_selection_change fn (GridSelection, mut Event, mut Window), row_count ?int, loading bool, load_error string, kind GridPaginationKind, page_limit int, grid_id string, focus_id u32, mut e Event, mut window Window) { - if !data_grid_source_jump_enabled(on_selection_change, row_count, loading, load_error, - kind, page_limit) { + if !data_grid_source_jump_enabled(on_selection_change, row_count, loading, load_error, kind, + page_limit) { return } total := row_count or { return } @@ -565,6 +566,7 @@ fn data_grid_source_pager_row(cfg DataGridCfg, focus_id u32, state DataGridSourc jump_input_id := '${grid_id}:jump' mut content := []View{cap: 10} content << data_grid_indicator_button('◀', cfg.text_style_header, cfg.color_header_hover, + state.loading || !has_prev, data_grid_header_control_width + 10, fn [grid_id, kind, page_limit, focus_id] (_ &Layout, mut e Event, mut w Window) { data_grid_source_prev_page(grid_id, kind, page_limit, mut w) if focus_id > 0 { @@ -578,6 +580,7 @@ fn data_grid_source_pager_row(cfg DataGridCfg, focus_id u32, state DataGridSourc text_style: cfg.text_style_filter ) content << data_grid_indicator_button('▶', cfg.text_style_header, cfg.color_header_hover, + state.loading || !has_next, data_grid_header_control_width + 10, fn [grid_id, kind, page_limit, focus_id] (_ &Layout, mut e Event, mut w Window) { data_grid_source_next_page(grid_id, kind, page_limit, mut w) if focus_id > 0 { @@ -657,12 +660,12 @@ fn data_grid_source_pager_row(cfg DataGridCfg, focus_id u32, state DataGridSourc mut dg_ji := state_map[string, string](mut w, ns_dg_jump, cap_moderate) dg_ji.set(grid_id, digits) mut e := Event{} - data_grid_source_submit_jump(on_selection_change, row_count, loading, - load_error, kind, page_limit, grid_id, 0, mut e, mut w) + data_grid_source_submit_jump(on_selection_change, row_count, loading, load_error, + kind, page_limit, grid_id, 0, mut e, mut w) } on_enter: fn [on_selection_change, row_count, loading, load_error, kind, page_limit, grid_id, focus_id] (_ &Layout, mut e Event, mut w Window) { - data_grid_source_submit_jump(on_selection_change, row_count, loading, - load_error, kind, page_limit, grid_id, focus_id, mut e, mut w) + data_grid_source_submit_jump(on_selection_change, row_count, loading, load_error, + kind, page_limit, grid_id, focus_id, mut e, mut w) } ) } diff --git a/docs/ANIMATIONS.md b/docs/ANIMATIONS.md index edf80c3..58331a0 100644 --- a/docs/ANIMATIONS.md +++ b/docs/ANIMATIONS.md @@ -40,6 +40,11 @@ w.animation_add(mut gui.TweenAnimation{ }) ``` +If a persistent animation is constructed or registered from `generate_layout` or +`amend_layout`, wrap construction and registration in +`window.animation_add_from_layout(...)`. `animation_add(...)` only registers an +already-created animation. + ### State Updates Animations modify your application state, which triggers view regeneration: diff --git a/drag_reorder.v b/drag_reorder.v index 9d39938..997fed6 100644 --- a/drag_reorder.v +++ b/drag_reorder.v @@ -94,6 +94,7 @@ import time const ns_drag_reorder = 'gui.drag_reorder' const ns_drag_reorder_ids_meta = 'gui.drag_reorder.ids_meta' +const ns_drag_reorder_drop = 'gui.drag_reorder.drop' const drag_reorder_threshold = f32(5.0) const drag_reorder_scroll_zone = f32(40.0) const drag_reorder_scroll_speed = f32(4.0) @@ -133,6 +134,7 @@ mut: parent_x f32 parent_y f32 item_id string + parent_id string id_scroll u32 container_start f32 container_end f32 @@ -148,6 +150,16 @@ struct DragReorderIdsMeta { ids_hash u64 } +struct DragReorderDrop { + moved_id string + before_id string + parent_id string +} + +fn drag_reorder_drop_handler_id(drag_key string) string { + return '${drag_key}:drag_reorder_drop' +} + // drag_reorder_get returns the current drag state for the given // widget namespace key, or a default if none exists. fn drag_reorder_get(mut w Window, key string) DragReorderState { @@ -186,19 +198,68 @@ fn drag_reorder_ids_changed(state DragReorderState, meta DragReorderIdsMeta) boo return state.ids_len != meta.ids_len || state.ids_hash != meta.ids_hash } +fn drag_reorder_drop_set(mut w Window, drag_key string, drop DragReorderDrop) { + mut sm := state_map[string, DragReorderDrop](mut w, ns_drag_reorder_drop, cap_few) + sm.set(drag_key, drop) +} + +fn drag_reorder_drop_take(mut w Window, drag_key string) ?DragReorderDrop { + mut sm := state_map[string, DragReorderDrop](mut w, ns_drag_reorder_drop, cap_few) + drop := sm.get(drag_key) or { return none } + sm.delete(drag_key) + return drop +} + +fn drag_reorder_drop_clear(mut w Window, drag_key string) { + mut sm := state_map[string, DragReorderDrop](mut w, ns_drag_reorder_drop, cap_few) + sm.delete(drag_key) +} + +fn drag_reorder_dispatch_drop(drag_key string, mut w Window) bool { + mut layout := w.find_layout_by_id(drag_reorder_drop_handler_id(drag_key)) or { + w.find_layout_by_id(drag_key) or { + drag_reorder_drop_clear(mut w, drag_key) + return false + } + } + if !layout.shape.has_events() || layout.shape.events.on_scroll == unsafe { nil } { + drag_reorder_drop_clear(mut w, drag_key) + return false + } + layout.shape.events.on_scroll(layout, mut w) + return true +} + +fn drag_reorder_apply_drop(drag_key string, on_reorder fn (string, string, mut Window), mut w Window) bool { + drop := drag_reorder_drop_take(mut w, drag_key) or { return false } + if on_reorder == unsafe { nil } { + return false + } + on_reorder(drop.moved_id, drop.before_id, mut w) + return true +} + +fn drag_reorder_apply_tree_drop(drag_key string, on_reorder fn (string, string, string, mut Window), mut w Window) bool { + drop := drag_reorder_drop_take(mut w, drag_key) or { return false } + if on_reorder == unsafe { nil } { + return false + } + on_reorder(drop.moved_id, drop.before_id, drop.parent_id, mut w) + return true +} + // drag_reorder_make_lock builds a MouseLockCfg that implements the // full drag lifecycle: threshold detection, tracking with FLIP // animation, and drop/cancel. fn drag_reorder_make_lock(drag_key string, axis DragReorderAxis, - item_ids []string, - on_reorder fn (string, string, mut Window)) MouseLockCfg { + item_ids []string) MouseLockCfg { return MouseLockCfg{ mouse_move: fn [drag_key, axis] (_ &Layout, mut e Event, mut w Window) { drag_reorder_on_mouse_move(drag_key, axis, e.mouse_x, e.mouse_y, mut w) } - mouse_up: fn [drag_key, item_ids, on_reorder] (_ &Layout, mut e Event, mut w Window) { - drag_reorder_on_mouse_up(drag_key, item_ids, on_reorder, mut w) + mouse_up: fn [drag_key, item_ids] (_ &Layout, mut e Event, mut w Window) { + drag_reorder_on_mouse_up(drag_key, item_ids, mut w) } } } @@ -330,7 +391,6 @@ fn drag_reorder_on_mouse_move(drag_key string, // source position. before_id is "" when dropping at the end. fn drag_reorder_on_mouse_up(drag_key string, item_ids []string, - on_reorder fn (string, string, mut Window), mut w Window) { state := drag_reorder_get(mut w, drag_key) was_active := state.active @@ -354,15 +414,21 @@ fn drag_reorder_on_mouse_up(drag_key string, // drop at gap index (src) or the gap immediately following it (src+1) // is a no-op since the item is already between those positions. if was_active && !state.cancelled && gap != src && gap != src + 1 { - if on_reorder != unsafe { nil } && src >= 0 && src < item_ids.len { + if src >= 0 && src < item_ids.len { moved_id := item_ids[src] before_id := if gap < item_ids.len { item_ids[gap] } else { '' } - w.animate_layout(LayoutTransitionCfg{}) - on_reorder(moved_id, before_id, mut w) + drag_reorder_drop_set(mut w, drag_key, DragReorderDrop{ + moved_id: moved_id + before_id: before_id + parent_id: state.parent_id + }) + if drag_reorder_dispatch_drop(drag_key, mut w) { + w.animate_layout(LayoutTransitionCfg{}) + } } } w.update_window() @@ -398,10 +464,10 @@ fn drag_reorder_start(drag_key string, item_id string, axis DragReorderAxis, item_ids []string, - on_reorder fn (string, string, mut Window), item_layout_ids []string, mids_offset int, id_scroll u32, + parent_id string, layout &Layout, e &Event, mut w Window) { @@ -461,6 +527,7 @@ fn drag_reorder_start(drag_key string, parent_x: parent_x parent_y: parent_y item_id: item_id + parent_id: parent_id id_scroll: id_scroll container_start: container_start container_end: container_end @@ -470,7 +537,7 @@ fn drag_reorder_start(drag_key string, mids_offset: mids_offset } drag_reorder_set(mut w, drag_key, state) - w.mouse_lock(drag_reorder_make_lock(drag_key, axis, item_ids, on_reorder)) + w.mouse_lock(drag_reorder_make_lock(drag_key, axis, item_ids)) } // drag_reorder_calc_index estimates the drop target index from diff --git a/event_handlers.v b/event_handlers.v index b1d78a2..bc01949 100644 --- a/event_handlers.v +++ b/event_handlers.v @@ -101,14 +101,37 @@ fn key_down_scroll_handler(layout &Layout, mut e Event, mut w Window) { } } +fn dispatch_mouse_lock_down(layout &Layout, mut e Event, mut w Window) bool { + callback := w.view_state.mouse_lock.mouse_down or { return false } + w.mouse_lock_dispatch_begin() + callback(layout, mut e, mut w) + w.mouse_lock_dispatch_end() + return true +} + +fn dispatch_mouse_lock_move(layout &Layout, mut e Event, mut w Window) bool { + callback := w.view_state.mouse_lock.mouse_move or { return false } + w.mouse_lock_dispatch_begin() + callback(layout, mut e, mut w) + w.mouse_lock_dispatch_end() + return true +} + +fn dispatch_mouse_lock_up(layout &Layout, mut e Event, mut w Window) bool { + callback := w.view_state.mouse_lock.mouse_up or { return false } + w.mouse_lock_dispatch_begin() + callback(layout, mut e, mut w) + w.mouse_lock_dispatch_end() + return true +} + // mouse_down_handler handles mouse button press events. // Traverses reverse (topmost first) and delivers to element under cursor. // Also handles focus changes on click. fn mouse_down_handler(layout &Layout, in_handler bool, mut e Event, mut w Window) { // Check mouse lock (only at top level to avoid repeated checks) if !in_handler { - if w.view_state.mouse_lock.mouse_down != none { - w.view_state.mouse_lock.mouse_down(layout, mut e, mut w) + if dispatch_mouse_lock_down(layout, mut e, mut w) { return } } @@ -142,8 +165,7 @@ fn mouse_down_handler(layout &Layout, in_handler bool, mut e Event, mut w Window // Traverses reverse (topmost first) and delivers to element under cursor. fn mouse_move_handler(layout &Layout, mut e Event, mut w Window) { // Check mouse lock - if w.view_state.mouse_lock.mouse_move != none { - w.view_state.mouse_lock.mouse_move(layout, mut e, mut w) + if dispatch_mouse_lock_move(layout, mut e, mut w) { return } // Skip if mouse is outside application window @@ -173,8 +195,7 @@ fn mouse_move_handler(layout &Layout, mut e Event, mut w Window) { // Traverses reverse (topmost first) and delivers to element under cursor. fn mouse_up_handler(layout &Layout, mut e Event, mut w Window) { // Check mouse lock - if w.view_state.mouse_lock.mouse_up != none { - w.view_state.mouse_lock.mouse_up(layout, mut e, mut w) + if dispatch_mouse_lock_up(layout, mut e, mut w) { return } // Traverse children in reverse (topmost/last child first) diff --git a/layout.v b/layout.v index de7a3c4..a68b658 100644 --- a/layout.v +++ b/layout.v @@ -168,6 +168,7 @@ fn layout_hover(mut layout Layout, mut w Window) bool { ctx.mbtn_mask & 0x04 > 0 { MouseButton.middle } else { MouseButton.invalid } } + mut ev := Event{ frame_count: ctx.frame typ: .invalid @@ -182,7 +183,10 @@ fn layout_hover(mut layout Layout, mut w Window) bool { window_width: ctx.width window_height: ctx.height } - layout.shape.events.on_hover(mut layout, mut ev, mut w) + on_hover := layout.shape.events.on_hover + w.layout_callback_lifetime.lifetime.suspend(fn [on_hover, mut layout, mut ev, mut w] () { + on_hover(mut layout, mut ev, mut w) + }) or { panic(err) } return ev.is_handled } } diff --git a/markdown_math.v b/markdown_math.v index 36fdd2f..c827dc6 100644 --- a/markdown_math.v +++ b/markdown_math.v @@ -145,139 +145,156 @@ fn fetch_math_http(url string) !http.Response { } fn fetch_math_async(mut window Window, latex string, hash i64, request_id u64, dpi int, fg_color Color) { - spawn fn [mut window, latex, hash, request_id, dpi, fg_color] () { - safe_latex := sanitize_latex(latex) - - // Build codecogs URL with DPI and optional color prefix. - // Use named color to avoid bracket syntax that breaks - // when percent-encoded. - dpi_str := '${dpi}' - lum := 0.299 * f64(fg_color.r) + 0.587 * f64(fg_color.g) + 0.114 * f64(fg_color.b) - color_cmd := if lum > 128.0 { '\\color{white}' } else { '' } - prefix := '\\dpi{${dpi_str}}${color_cmd}' - // Replace spaces with {} (empty group) — acts as - // command terminator without visible output. V's - // http library re-encodes %20 as + which codecogs - // renders as literal plus signs. - encoded := (prefix + safe_latex).replace(' ', '{}').replace('#', '%23').replace('&', - '%26') - url := 'https://latex.codecogs.com/png.image?${encoded}' - result := fetch_math_http(url) or { - err_msg := err.msg() - window.queue_command(fn [hash, request_id, err_msg] (mut w Window) { - if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, - request_id) { - return - } - w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ - state: .error - error: err_msg - request_id: request_id - }) - w.update_window() - }) - return - } - if result.status_code == 200 { - // Reject oversized responses (>10MB) - if result.body.len > 10 * 1024 * 1024 { - body_len := result.body.len - window.queue_command(fn [hash, request_id, body_len] (mut w Window) { - if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, - hash, request_id) { + window.suspend_layout_callback_tracking(fn [mut window, latex, hash, request_id, dpi, fg_color] () { + spawn fn [mut window, latex, hash, request_id, dpi, fg_color] () { + if latex.len > max_latex_source_len { + window.queue_command(fn [hash, request_id] (mut w Window) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, + request_id) { return } w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ state: .error - error: 'Response too large (>${body_len / 1024 / 1024}MB)' + error: 'LaTeX source too large' request_id: request_id }) w.update_window() }) return } - png_bytes := result.body.bytes() - img := stbi.load_from_memory(png_bytes.data, png_bytes.len) or { + safe_latex := sanitize_latex(latex) + + // Build codecogs URL with DPI and optional color prefix. + // Use named color to avoid bracket syntax that breaks + // when percent-encoded. + dpi_str := '${dpi}' + lum := 0.299 * f64(fg_color.r) + 0.587 * f64(fg_color.g) + 0.114 * f64(fg_color.b) + color_cmd := if lum > 128.0 { '\\color{white}' } else { '' } + prefix := '\\dpi{${dpi_str}}${color_cmd}' + // Replace spaces with {} (empty group) — acts as + // command terminator without visible output. V's + // http library re-encodes %20 as + which codecogs + // renders as literal plus signs. + encoded := (prefix + safe_latex).replace(' ', '{}').replace('#', '%23').replace('&', + '%26') + url := 'https://latex.codecogs.com/png.image?${encoded}' + result := fetch_math_http(url) or { err_msg := err.msg() window.queue_command(fn [hash, request_id, err_msg] (mut w Window) { - if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, - hash, request_id) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, + request_id) { return } w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ state: .error - error: 'Failed to decode PNG: ${err_msg}' + error: err_msg request_id: request_id }) w.update_window() }) return } + if result.status_code == 200 { + // Reject oversized responses (>10MB) + if result.body.len > 10 * 1024 * 1024 { + body_len := result.body.len + window.queue_command(fn [hash, request_id, body_len] (mut w Window) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, + request_id) { + return + } + w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ + state: .error + error: 'Response too large (>${body_len / 1024 / 1024}MB)' + request_id: request_id + }) + w.update_window() + }) + return + } + png_bytes := result.body.bytes() + img := stbi.load_from_memory(png_bytes.data, png_bytes.len) or { + err_msg := err.msg() + window.queue_command(fn [hash, request_id, err_msg] (mut w Window) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, + request_id) { + return + } + w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ + state: .error + error: 'Failed to decode PNG: ${err_msg}' + request_id: request_id + }) + w.update_window() + }) + return + } - // No transparent fill — keep PNG alpha for blending - // with any background color + // No transparent fill — keep PNG alpha for blending + // with any background color - tmp_path := write_stbi_temp('math', hash, img) or { + tmp_path := write_stbi_temp('math', hash, img) or { + img.free() + err_msg := err.msg() + window.queue_command(fn [hash, request_id, err_msg] (mut w Window) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, + request_id) { + return + } + w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ + state: .error + error: 'Failed to write temp file: ${err_msg}' + request_id: request_id + }) + w.update_window() + }) + return + } + img_w := f32(img.width) + img_h := f32(img.height) + img_dpi := f32(dpi) img.free() - err_msg := err.msg() - window.queue_command(fn [hash, request_id, err_msg] (mut w Window) { - if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, - hash, request_id) { + window.queue_command(fn [hash, request_id, tmp_path, img_w, img_h, img_dpi] (mut w Window) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, + request_id) { + os.rm(tmp_path) or {} return } w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ - state: .error - error: 'Failed to write temp file: ${err_msg}' + state: .ready + png_path: tmp_path + width: img_w + height: img_h + dpi: img_dpi request_id: request_id }) + // No markdown_cache clear needed: parsed blocks + // don't change; RTF reads math dims from + // diagram_cache at render time. update_window + // triggers view rebuild picking up new + // dimensions via to_vglyph_rich_text_with_math. w.update_window() }) - return - } - img_w := f32(img.width) - img_h := f32(img.height) - img_dpi := f32(dpi) - img.free() - window.queue_command(fn [hash, request_id, tmp_path, img_w, img_h, img_dpi] (mut w Window) { - if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, - request_id) { - os.rm(tmp_path) or {} - return - } - w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ - state: .ready - png_path: tmp_path - width: img_w - height: img_h - dpi: img_dpi - request_id: request_id - }) - // No markdown_cache clear needed: parsed blocks - // don't change; RTF reads math dims from - // diagram_cache at render time. update_window - // triggers view rebuild picking up new - // dimensions via to_vglyph_rich_text_with_math. - w.update_window() - }) - } else { - body_preview := if result.body.len > 200 { - result.body[..200] + '...' } else { - result.body - } - status_code := result.status_code - window.queue_command(fn [hash, request_id, status_code, body_preview] (mut w Window) { - if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, - request_id) { - return + body_preview := if result.body.len > 200 { + result.body[..200] + '...' + } else { + result.body } - w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ - state: .error - error: 'HTTP ${status_code}: ${body_preview}' - request_id: request_id + status_code := result.status_code + window.queue_command(fn [hash, request_id, status_code, body_preview] (mut w Window) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, + request_id) { + return + } + w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ + state: .error + error: 'HTTP ${status_code}: ${body_preview}' + request_id: request_id + }) + w.update_window() }) - w.update_window() - }) - } - }() + } + }() + }) or { panic(err) } } diff --git a/markdown_mermaid.v b/markdown_mermaid.v index ab19842..4c2511c 100644 --- a/markdown_mermaid.v +++ b/markdown_mermaid.v @@ -32,7 +32,8 @@ struct DiagramCacheEntry { fn write_stbi_temp(prefix string, hash i64, img stbi.Image) !string { rand_suffix := rand.intn(1000000) or { 0 } tmp_path := os.join_path(os.temp_dir(), '${prefix}_${hash}_${rand_suffix}.png') - stbi.stbi_write_png(tmp_path, img.width, img.height, img.nr_channels, img.data, img.width * img.nr_channels)! + stbi.stbi_write_png(tmp_path, img.width, img.height, img.nr_channels, img.data, + img.width * img.nr_channels)! return tmp_path } @@ -106,168 +107,170 @@ fn mermaid_http_fetch(source string) !http.Response { // to the service provider. // Use MarkdownCfg.disable_external_apis to disable this. fn fetch_mermaid_async(mut window Window, source string, hash i64, request_id u64, max_width int, bg_r u8, bg_g u8, bg_b u8) { - spawn fn [mut window, source, hash, request_id, max_width, bg_r, bg_g, bg_b] () { - if source.len > max_mermaid_source_len { - window.queue_command(fn [hash, request_id] (mut w Window) { - if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, - request_id) { - return - } - w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ - state: .error - error: 'Mermaid source too large' - request_id: request_id - }) - w.update_window() - }) - return - } - - result := mermaid_http_fetch(source) or { - err_msg := err.msg() - window.queue_command(fn [hash, request_id, err_msg] (mut w Window) { - if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, - request_id) { - return - } - w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ - state: .error - error: err_msg - request_id: request_id - }) - w.update_window() - }) - return - } - if result.status_code == 200 { - // Reject oversized responses (>10MB) - if result.body.len > 10 * 1024 * 1024 { - body_len := result.body.len - window.queue_command(fn [hash, request_id, body_len] (mut w Window) { - if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, - hash, request_id) { + window.suspend_layout_callback_tracking(fn [mut window, source, hash, request_id, max_width, bg_r, bg_g, bg_b] () { + spawn fn [mut window, source, hash, request_id, max_width, bg_r, bg_g, bg_b] () { + if source.len > max_mermaid_source_len { + window.queue_command(fn [hash, request_id] (mut w Window) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, + request_id) { return } w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ state: .error - error: 'Response too large (>${body_len / 1024 / 1024}MB)' + error: 'Mermaid source too large' request_id: request_id }) w.update_window() }) return } - // Load PNG from memory and resize if needed - png_bytes := result.body.bytes() - img := stbi.load_from_memory(png_bytes.data, png_bytes.len) or { + + result := mermaid_http_fetch(source) or { err_msg := err.msg() window.queue_command(fn [hash, request_id, err_msg] (mut w Window) { - if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, - hash, request_id) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, + request_id) { return } w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ state: .error - error: 'Failed to decode PNG: ${err_msg}' + error: err_msg request_id: request_id }) w.update_window() }) return } - - // Scale down if wider than max_width - mut final_img := img - resized := img.width > max_width - if resized { - scale := f64(max_width) / f64(img.width) - new_h := int(f64(img.height) * scale) - final_img = stbi.resize_uint8(&img, max_width, new_h) or { - img.free() + if result.status_code == 200 { + // Reject oversized responses (>10MB) + if result.body.len > 10 * 1024 * 1024 { + body_len := result.body.len + window.queue_command(fn [hash, request_id, body_len] (mut w Window) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, + request_id) { + return + } + w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ + state: .error + error: 'Response too large (>${body_len / 1024 / 1024}MB)' + request_id: request_id + }) + w.update_window() + }) + return + } + // Load PNG from memory and resize if needed + png_bytes := result.body.bytes() + img := stbi.load_from_memory(png_bytes.data, png_bytes.len) or { err_msg := err.msg() window.queue_command(fn [hash, request_id, err_msg] (mut w Window) { - if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, - hash, request_id) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, + request_id) { return } w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ state: .error - error: 'Failed to resize: ${err_msg}' + error: 'Failed to decode PNG: ${err_msg}' request_id: request_id }) w.update_window() }) return } - } - // Fill transparent pixels with background color - fill_transparent_with_bg(final_img.data, final_img.width, final_img.height, - final_img.nr_channels, bg_r, bg_g, bg_b) + // Scale down if wider than max_width + mut final_img := img + resized := img.width > max_width + if resized { + scale := f64(max_width) / f64(img.width) + new_h := int(f64(img.height) * scale) + final_img = stbi.resize_uint8(&img, max_width, new_h) or { + img.free() + err_msg := err.msg() + window.queue_command(fn [hash, request_id, err_msg] (mut w Window) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, + hash, request_id) { + return + } + w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ + state: .error + error: 'Failed to resize: ${err_msg}' + request_id: request_id + }) + w.update_window() + }) + return + } + } + + // Fill transparent pixels with background color + fill_transparent_with_bg(final_img.data, final_img.width, final_img.height, + final_img.nr_channels, bg_r, bg_g, bg_b) - tmp_path := write_stbi_temp('mermaid', hash, final_img) or { + tmp_path := write_stbi_temp('mermaid', hash, final_img) or { + img.free() + if resized { + final_img.free() + } + err_msg := err.msg() + window.queue_command(fn [hash, request_id, err_msg] (mut w Window) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, + request_id) { + return + } + w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ + state: .error + error: 'Failed to write temp file: ${err_msg}' + request_id: request_id + }) + w.update_window() + }) + return + } + // Free stbi memory after writing PNG img.free() if resized { final_img.free() } - err_msg := err.msg() - window.queue_command(fn [hash, request_id, err_msg] (mut w Window) { - if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, - hash, request_id) { + final_w := f32(final_img.width) + final_h := f32(final_img.height) + window.queue_command(fn [hash, request_id, tmp_path, final_w, final_h] (mut w Window) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, + request_id) { + os.rm(tmp_path) or {} return } w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ - state: .error - error: 'Failed to write temp file: ${err_msg}' + state: .ready + png_path: tmp_path + width: final_w + height: final_h request_id: request_id }) w.update_window() }) - return - } - // Free stbi memory after writing PNG - img.free() - if resized { - final_img.free() - } - final_w := f32(final_img.width) - final_h := f32(final_img.height) - window.queue_command(fn [hash, request_id, tmp_path, final_w, final_h] (mut w Window) { - if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, - request_id) { - os.rm(tmp_path) or {} - return - } - w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ - state: .ready - png_path: tmp_path - width: final_w - height: final_h - request_id: request_id - }) - w.update_window() - }) - } else { - body_preview := if result.body.len > 200 { - result.body[..200] + '...' } else { - result.body - } - status_code := result.status_code - window.queue_command(fn [hash, request_id, status_code, body_preview] (mut w Window) { - if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, - request_id) { - return + body_preview := if result.body.len > 200 { + result.body[..200] + '...' + } else { + result.body } - w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ - state: .error - error: 'HTTP ${status_code}: ${body_preview}' - request_id: request_id + status_code := result.status_code + window.queue_command(fn [hash, request_id, status_code, body_preview] (mut w Window) { + if !diagram_cache_should_apply_result(&w.view_state.diagram_cache, hash, + request_id) { + return + } + w.view_state.diagram_cache.set(hash, DiagramCacheEntry{ + state: .error + error: 'HTTP ${status_code}: ${body_preview}' + request_id: request_id + }) + w.update_window() }) - w.update_window() - }) - } - }() + } + }() + }) or { panic(err) } } // BoundedDiagramCache is a FIFO cache for diagram entries. diff --git a/view_data_grid_events.v b/view_data_grid_events.v index e87d781..2ad38ec 100644 --- a/view_data_grid_events.v +++ b/view_data_grid_events.v @@ -5,6 +5,104 @@ module gui import hash.fnv1a import time +const ns_dg_quick_filter_debounce = 'gui.dg.quick_filter.debounce' +const ns_dg_quick_filter_debounce_seq = 'gui.dg.quick_filter.debounce_seq' + +struct DataGridQuickFilterDebounce { + token u64 + sorts []GridSort + filters []GridFilter + text string +} + +fn data_grid_quick_filter_debounce_handler_id(input_id string) string { + return '${input_id}:debounce_handler' +} + +fn data_grid_quick_filter_next_debounce_token(input_id string, mut w Window) u64 { + mut seqs := state_map[string, u64](mut w, ns_dg_quick_filter_debounce_seq, cap_moderate) + next := (seqs.get(input_id) or { u64(0) }) + 1 + seqs.set(input_id, next) + return next +} + +fn data_grid_quick_filter_set_debounce(input_id string, payload DataGridQuickFilterDebounce, mut w Window) u64 { + token := data_grid_quick_filter_next_debounce_token(input_id, mut w) + mut pending := state_map[string, DataGridQuickFilterDebounce](mut w, + ns_dg_quick_filter_debounce, cap_moderate) + pending.set(input_id, DataGridQuickFilterDebounce{ + token: token + sorts: payload.sorts + filters: payload.filters + text: payload.text + }) + return token +} + +fn data_grid_quick_filter_debounce_matches(input_id string, token u64, w &Window) bool { + pending := state_map_read[string, DataGridQuickFilterDebounce](w, + ns_dg_quick_filter_debounce) or { + return false + } + payload := pending.get(input_id) or { return false } + return payload.token == token +} + +fn data_grid_quick_filter_take_debounce(input_id string, token u64, mut w Window) ?DataGridQuickFilterDebounce { + mut pending := state_map[string, DataGridQuickFilterDebounce](mut w, + ns_dg_quick_filter_debounce, cap_moderate) + payload := pending.get(input_id) or { return none } + if payload.token != token { + return none + } + pending.delete(input_id) + return payload +} + +fn data_grid_quick_filter_clear_debounce(input_id string, mut w Window) { + mut pending := state_map[string, DataGridQuickFilterDebounce](mut w, + ns_dg_quick_filter_debounce, cap_moderate) + pending.delete(input_id) +} + +fn data_grid_quick_filter_dispatch_debounce(input_id string, token u64, mut w Window) { + if !data_grid_quick_filter_debounce_matches(input_id, token, &w) { + return + } + handler_id := data_grid_quick_filter_debounce_handler_id(input_id) + layout := w.find_layout_by_id(handler_id) or { + data_grid_quick_filter_clear_debounce(input_id, mut w) + return + } + if !layout.shape.has_events() || layout.shape.events.on_scroll == unsafe { nil } { + data_grid_quick_filter_clear_debounce(input_id, mut w) + return + } + layout.shape.events.on_scroll(layout, mut w) +} + +fn data_grid_quick_filter_apply_debounce(input_id string, query_callback fn (qs GridQueryState, mut e Event, mut w Window), mut w Window) { + if query_callback == unsafe { nil } { + data_grid_quick_filter_clear_debounce(input_id, mut w) + return + } + pending := state_map_read[string, DataGridQuickFilterDebounce](w, + ns_dg_quick_filter_debounce) or { + return + } + current := pending.get(input_id) or { return } + payload := data_grid_quick_filter_take_debounce(input_id, current.token, mut w) or { + return + } + next := GridQueryState{ + sorts: payload.sorts + filters: payload.filters + quick_filter: payload.text + } + mut e := Event{} + query_callback(next, mut e, mut w) +} + fn data_grid_quick_filter_row(cfg DataGridCfg) View { h := data_grid_quick_filter_height(cfg) query_callback := cfg.on_query_change @@ -15,8 +113,10 @@ fn data_grid_quick_filter_row(cfg DataGridCfg) View { matches_text := data_grid_quick_filter_matches_text(cfg) clear_disabled := value.len == 0 || query_callback == unsafe { nil } debounce := cfg.quick_filter_debounce + debounce_handler_id := data_grid_quick_filter_debounce_handler_id(input_id) return row( name: 'data_grid quick filter row' + id: debounce_handler_id height: h sizing: fill_fixed color: cfg.color_quick_filter @@ -25,6 +125,9 @@ fn data_grid_quick_filter_row(cfg DataGridCfg) View { padding: padding(0, cfg.padding_cell.right, 0, cfg.padding_cell.left) spacing: 6 v_align: .middle + on_scroll: fn [input_id, query_callback] (_ &Layout, mut w Window) { + data_grid_quick_filter_apply_debounce(input_id, query_callback, mut w) + } on_click: fn [input_focus_id] (_ &Layout, mut e Event, mut w Window) { if input_focus_id > 0 { w.set_id_focus(input_focus_id) @@ -74,17 +177,16 @@ fn data_grid_quick_filter_row(cfg DataGridCfg) View { // the latest keystroke fires. sorts := query.sorts.clone() filters := query.filters.clone() + token := data_grid_quick_filter_set_debounce(input_id, DataGridQuickFilterDebounce{ + sorts: sorts + filters: filters + text: text + }, mut w) w.animation_add(mut &Animate{ id: '${input_id}:debounce' delay: debounce - callback: fn [sorts, filters, text, query_callback] (mut an Animate, mut w Window) { - next := GridQueryState{ - sorts: sorts - filters: filters - quick_filter: text - } - mut e := Event{} - query_callback(next, mut e, mut w) + callback: fn [input_id, token] (mut an Animate, mut w Window) { + data_grid_quick_filter_dispatch_debounce(input_id, token, mut w) } }) } @@ -100,6 +202,7 @@ fn data_grid_quick_filter_row(cfg DataGridCfg) View { return } w.remove_animation('${input_id}:debounce') + data_grid_quick_filter_clear_debounce(input_id, mut w) next := GridQueryState{ sorts: query.sorts.clone() filters: query.filters.clone() diff --git a/view_form.v b/view_form.v index 0a59d25..8c87450 100644 --- a/view_form.v +++ b/view_form.v @@ -605,32 +605,41 @@ fn (mut w Window) form_on_field_event_for_form(form_id string, cfg FormFieldAdap validators := field.async_validators.clone() signal := controller.signal field_id := cfg.field_id - spawn fn [validators, field_snapshot, snapshot, signal, form_id, field_id, request_id] (mut win Window) { - mut issues := []FormIssue{} - for validator in validators { - if signal.is_aborted() { - return - } - result := validator(field_snapshot, snapshot, signal) or { - log.error('form async validator failed for form_id=${form_id} field_id=${field_id}: ${err.msg()}') - issues << FormIssue{ - code: 'async_error' - msg: form_async_issue_msg - kind: .error + w.pin_layout_callback_reclaim() or { panic(err) } + w.suspend_layout_callback_tracking(fn [validators, field_snapshot, snapshot, signal, form_id, field_id, request_id, mut w] () { + spawn fn [validators, field_snapshot, snapshot, signal, form_id, field_id, request_id] (mut win Window) { + mut issues := []FormIssue{} + for validator in validators { + if signal.is_aborted() { + win.form_queue_async_validation_pin_release() + return + } + result := validator(field_snapshot, snapshot, signal) or { + log.error('form async validator failed for form_id=${form_id} field_id=${field_id}: ${err.msg()}') + issues << FormIssue{ + code: 'async_error' + msg: form_async_issue_msg + kind: .error + } + continue + } + if result.len > 0 { + issues << result } - continue } - if result.len > 0 { - issues << result + if signal.is_aborted() { + win.form_queue_async_validation_pin_release() + return } - } - if signal.is_aborted() { - return - } - win.queue_command(fn [form_id, field_id, request_id, issues] (mut win Window) { - win.form_apply_async_result(form_id, field_id, request_id, issues) - }) - }(mut w) + win.queue_command(fn [form_id, field_id, request_id, issues] (mut win Window) { + win.form_apply_async_result(form_id, field_id, request_id, issues) + win.release_layout_callback_reclaim_pin() + }) + }(mut w) + }) or { + w.release_layout_callback_reclaim_pin() + panic(err) + } } else { field.pending = false field.active_abort = unsafe { nil } @@ -640,6 +649,12 @@ fn (mut w Window) form_on_field_event_for_form(form_id string, cfg FormFieldAdap form_state_set(mut w, form_id, state) } +fn (mut w Window) form_queue_async_validation_pin_release() { + w.queue_command(fn (mut win Window) { + win.release_layout_callback_reclaim_pin() + }) +} + fn (mut w Window) form_apply_async_result(form_id string, field_id string, request_id u64, issues []FormIssue) { mut state := form_state_get(mut w, form_id) mut field := state.fields[field_id] or { return } diff --git a/view_image.v b/view_image.v index 9e3cba3..35a0c0d 100644 --- a/view_image.v +++ b/view_image.v @@ -64,7 +64,10 @@ fn (mut iv ImageView) generate_layout(mut window Window) Layout { } else { '' } - spawn download_image(iv.src, base_path, auth_header, mut window) + image_url := iv.src + window.suspend_layout_callback_tracking(fn [image_url, base_path, auth_header, mut window] () { + spawn download_image(image_url, base_path, auth_header, mut window) + }) or { panic(err) } } mut layout := Layout{ shape: &Shape{ diff --git a/view_listbox.v b/view_listbox.v index b627c7e..ad8dd10 100644 --- a/view_listbox.v +++ b/view_listbox.v @@ -141,8 +141,8 @@ pub fn (mut window Window) list_box(cfg ListBoxCfg) View { 0, last_row_idx } - return list_box_from_range(first_visible, last_visible, resolved_cfg, virtualize, - row_height, drag_state, can_reorder) + return list_box_from_range(first_visible, last_visible, resolved_cfg, virtualize, row_height, + drag_state, can_reorder) } fn list_box_from_range(first_visible int, last_visible int, cfg ListBoxCfg, virtualize bool, row_height f32, drag DragReorderState, can_reorder bool) View { @@ -226,7 +226,7 @@ fn list_box_from_range(first_visible int, last_visible int, cfg ListBoxCfg, virt ghost_content = list_box_item_content(dat, cfg) } else { list << list_box_item_view(dat, cfg, item_drag_idx, item_ids, item_layout_ids, - mids_offset, on_reorder, can_reorder) + mids_offset, can_reorder) } } // Gap at end if dropping past last item. @@ -277,10 +277,14 @@ fn list_box_from_range(first_visible int, last_visible int, cfg ListBoxCfg, virt reorderable := can_reorder return column( name: 'list_box' + id: if reorderable { drag_reorder_drop_handler_id(list_box_id) } else { '' } a11y_role: .list a11y: list_a11y id_focus: cfg.id_focus id_scroll: cfg.id_scroll + on_scroll: fn [list_box_id, on_reorder] (_ &Layout, mut w Window) { + drag_reorder_apply_drop(list_box_id, on_reorder, mut w) + } on_keydown: fn [list_box_id, item_ids, is_multiple, on_select, selected_ids, reorderable, on_reorder] (_ &Layout, mut e Event, mut w Window) { list_box_on_keydown(list_box_id, item_ids, is_multiple, on_select, selected_ids, reorderable, on_reorder, mut e, mut w) @@ -302,7 +306,7 @@ fn list_box_from_range(first_visible int, last_visible int, cfg ListBoxCfg, virt ) } -fn list_box_item_view(dat ListBoxOption, cfg ListBoxCfg, drag_index int, item_ids []string, item_layout_ids []string, mids_offset int, on_reorder fn (string, string, mut Window), can_reorder bool) View { +fn list_box_item_view(dat ListBoxOption, cfg ListBoxCfg, drag_index int, item_ids []string, item_layout_ids []string, mids_offset int, can_reorder bool) View { color := if dat.id in cfg.selected_ids { cfg.color_select } else { @@ -327,9 +331,8 @@ fn list_box_item_view(dat ListBoxOption, cfg ListBoxCfg, drag_index int, item_id } id_scroll := cfg.id_scroll on_click_fn := if reorderable { - make_list_box_drag_click(list_box_id, dat_id, drag_index, item_ids, on_reorder, - item_layout_ids, mids_offset, id_scroll, is_multiple, on_select, has_on_select, - selected_ids) + make_list_box_drag_click(list_box_id, dat_id, drag_index, item_ids, item_layout_ids, + mids_offset, id_scroll, is_multiple, on_select, has_on_select, selected_ids) } else { fn [is_multiple, on_select, has_on_select, selected_ids, dat_id, is_sub] (_ &Layout, mut e Event, mut w Window) { if has_on_select && !is_sub { @@ -399,7 +402,6 @@ fn list_box_item_content(dat ListBoxOption, cfg ListBoxCfg) View { // initiates drag-reorder or falls back to selection. fn make_list_box_drag_click(list_box_id string, dat_id string, drag_index int, item_ids []string, - on_reorder fn (string, string, mut Window), item_layout_ids []string, mids_offset int, id_scroll u32, @@ -407,9 +409,9 @@ fn make_list_box_drag_click(list_box_id string, dat_id string, on_select fn ([]string, mut Event, mut Window), has_on_select bool, selected_ids []string) fn (&Layout, mut Event, mut Window) { - return fn [list_box_id, dat_id, drag_index, item_ids, on_reorder, item_layout_ids, mids_offset, id_scroll, is_multiple, on_select, has_on_select, selected_ids] (layout &Layout, mut e Event, mut w Window) { - drag_reorder_start(list_box_id, drag_index, dat_id, .vertical, item_ids, on_reorder, - item_layout_ids, mids_offset, id_scroll, layout, e, mut w) + return fn [list_box_id, dat_id, drag_index, item_ids, item_layout_ids, mids_offset, id_scroll, is_multiple, on_select, has_on_select, selected_ids] (layout &Layout, mut e Event, mut w Window) { + drag_reorder_start(list_box_id, drag_index, dat_id, .vertical, item_ids, + item_layout_ids, mids_offset, id_scroll, '', layout, e, mut w) // Set keyboard focus index so Alt+Arrow works after click. mut lbf := state_map[string, int](mut w, ns_list_box_focus, cap_moderate) lbf.set(list_box_id, drag_index) @@ -591,25 +593,26 @@ fn list_box_source_start_request(cfg ListBoxCfg, request_key string, mut state L state.active_abort = controller state.request_count++ list_box_id := cfg.id - spawn fn [source, req, list_box_id, next_request_id] (mut w Window) { - result := source.fetch_data(req) or { + window.suspend_layout_callback_tracking(fn [source, req, list_box_id, next_request_id, mut window] () { + spawn fn [source, req, list_box_id, next_request_id] (mut w Window) { + result := source.fetch_data(req) or { + if req.signal.is_aborted() { + return + } + err_msg := err.msg() + w.queue_command(fn [list_box_id, next_request_id, err_msg] (mut w Window) { + list_box_source_apply_error(list_box_id, next_request_id, err_msg, mut w) + }) + return + } if req.signal.is_aborted() { return } - err_msg := err.msg() - w.queue_command(fn [list_box_id, next_request_id, err_msg] (mut w Window) { - list_box_source_apply_error(list_box_id, next_request_id, err_msg, mut - w) + w.queue_command(fn [list_box_id, next_request_id, result] (mut w Window) { + list_box_source_apply_success(list_box_id, next_request_id, result, mut w) }) - return - } - if req.signal.is_aborted() { - return - } - w.queue_command(fn [list_box_id, next_request_id, result] (mut w Window) { - list_box_source_apply_success(list_box_id, next_request_id, result, mut w) - }) - }(mut window) + }(mut window) + }) or { panic(err) } } fn list_box_source_apply_success(list_box_id string, request_id u64, result ListBoxDataResult, mut window Window) { diff --git a/view_progress_bar.v b/view_progress_bar.v index 7aeb50f..8de4e28 100644 --- a/view_progress_bar.v +++ b/view_progress_bar.v @@ -95,33 +95,36 @@ pub fn progress_bar(cfg ProgressBarCfg) View { // Register animation if missing anim_id := '${id}_indefinite' if anim_id !in w.animations { - mut anim := KeyframeAnimation{ - id: anim_id - repeat: true - duration: 1500 * time.millisecond - keyframes: [ - Keyframe{ - at: 0.0 - value: 0.0 - }, - Keyframe{ - at: 0.5 - value: 1.0 - easing: ease_in_out_quad - }, - Keyframe{ - at: 1.0 - value: 0.0 - easing: ease_in_out_quad - }, - ] - on_value: fn [id] (v f32, mut w Window) { - mut pm := state_map[string, f32](mut w, ns_progress, cap_moderate) - pm.set(id, v) + w.animation_add_from_layout(fn [mut w, id, anim_id] () { + mut anim := KeyframeAnimation{ + id: anim_id + repeat: true + duration: 1500 * time.millisecond + keyframes: [ + Keyframe{ + at: 0.0 + value: 0.0 + }, + Keyframe{ + at: 0.5 + value: 1.0 + easing: ease_in_out_quad + }, + Keyframe{ + at: 1.0 + value: 0.0 + easing: ease_in_out_quad + }, + ] + on_value: fn [id] (v f32, mut w Window) { + mut pm := state_map[string, f32](mut w, ns_progress, + cap_moderate) + pm.set(id, v) + } } - } - anim.start = time.now() - w.animation_add(mut anim) + anim.start = time.now() + w.animation_add(mut anim) + }) or { panic(err) } } // Read current animation progress diff --git a/view_sidebar.v b/view_sidebar.v index 89ec438..413fc41 100644 --- a/view_sidebar.v +++ b/view_sidebar.v @@ -4,9 +4,13 @@ import time struct SidebarRuntimeState { mut: - prev_open bool - anim_frac f32 - initialized bool + prev_open bool + current_frac f32 + tween_from f32 + tween_to f32 + tween_progress f32 + tween_active bool + initialized bool } @[minify] @@ -72,51 +76,88 @@ fn sidebar_animated_width(mut w Window, cfg SidebarCfg) f32 { target := if cfg.open { f32(1) } else { f32(0) } if !rt.initialized { - rt.anim_frac = target + rt.current_frac = target + rt.tween_from = target + rt.tween_to = target + rt.tween_progress = 1 + rt.tween_active = false rt.prev_open = cfg.open rt.initialized = true sm.set(cfg.id, rt) return cfg.width * target } + current_frac := sidebar_resolve_fraction(rt, cfg.tween_easing) if cfg.open != rt.prev_open { rt.prev_open = cfg.open + rt.current_frac = current_frac sm.set(cfg.id, rt) - sidebar_start_animation(cfg.id, rt.anim_frac, target, cfg.spring, cfg.tween_duration, - cfg.tween_easing, mut w) + sidebar_start_animation(cfg.id, current_frac, target, cfg.spring, cfg.tween_duration, mut w) } - return cfg.width * f32_max(0, rt.anim_frac) + rt = sm.get(cfg.id) or { rt } + return cfg.width * f32_max(0, sidebar_resolve_fraction(rt, cfg.tween_easing)) } -fn sidebar_on_anim_value(id string) fn (f32, mut Window) { +fn sidebar_resolve_fraction(rt SidebarRuntimeState, easing EasingFn) f32 { + if rt.tween_active { + progress := f32_clamp(rt.tween_progress, 0, 1) + return lerp(rt.tween_from, rt.tween_to, easing(progress)) + } + return rt.current_frac +} + +fn sidebar_on_spring_value(id string) fn (f32, mut Window) { return fn [id] (v f32, mut w Window) { mut sm := state_map[string, SidebarRuntimeState](mut w, ns_sidebar, cap_few) mut rt := sm.get(id) or { SidebarRuntimeState{} } - rt.anim_frac = v + rt.current_frac = v + rt.tween_active = false sm.set(id, rt) } } -fn sidebar_start_animation(sidebar_id string, from f32, to f32, spring_cfg SpringCfg, tween_dur time.Duration, tween_easing EasingFn, mut w Window) { - anim_id := 'sidebar:${sidebar_id}' - on_value := sidebar_on_anim_value(sidebar_id) - if tween_dur > 0 { - w.animation_add(mut TweenAnimation{ - id: anim_id - from: from - to: to - duration: tween_dur - easing: tween_easing - on_value: on_value - }) - } else { - mut spring := SpringAnimation{ - id: anim_id - config: spring_cfg - on_value: on_value +fn sidebar_on_tween_progress(id string) fn (f32, mut Window) { + return fn [id] (progress f32, mut w Window) { + mut sm := state_map[string, SidebarRuntimeState](mut w, ns_sidebar, cap_few) + mut rt := sm.get(id) or { SidebarRuntimeState{} } + rt.tween_progress = f32_clamp(progress, 0, 1) + rt.tween_active = rt.tween_progress < 1 + if !rt.tween_active { + rt.current_frac = rt.tween_to } - spring.spring_to(from, to) - w.animation_add(mut spring) + sm.set(id, rt) } } + +fn sidebar_start_animation(sidebar_id string, from f32, to f32, spring_cfg SpringCfg, tween_dur time.Duration, mut w Window) { + w.animation_add_from_layout(fn [mut w, sidebar_id, from, to, spring_cfg, tween_dur] () { + anim_id := 'sidebar:${sidebar_id}' + if tween_dur > 0 { + mut sm := state_map[string, SidebarRuntimeState](mut w, ns_sidebar, cap_few) + mut rt := sm.get(sidebar_id) or { SidebarRuntimeState{} } + rt.current_frac = from + rt.tween_from = from + rt.tween_to = to + rt.tween_progress = 0 + rt.tween_active = true + sm.set(sidebar_id, rt) + w.animation_add(mut TweenAnimation{ + id: anim_id + from: 0 + to: 1 + duration: tween_dur + easing: ease_linear + on_value: sidebar_on_tween_progress(sidebar_id) + }) + } else { + mut spring := SpringAnimation{ + id: anim_id + config: spring_cfg + on_value: sidebar_on_spring_value(sidebar_id) + } + spring.spring_to(from, to) + w.animation_add(mut spring) + } + }) or { panic(err) } +} diff --git a/view_state.v b/view_state.v index 91cac7c..8737db8 100644 --- a/view_state.v +++ b/view_state.v @@ -11,16 +11,19 @@ import sokol.sapp // dedicated fields for type safety. struct ViewState { mut: - cursor_on_sticky bool // keeps the cursor visible during cursor movement - id_focus u32 // current view that has focus - input_cursor_on bool = true // used by cursor blink animation - menu_key_nav bool // true, menu navigated by keyboard - mouse_cursor sapp.MouseCursor // arrow, finger, ibeam, etc. - mouse_lock MouseLockCfg // mouse down/move/up/scroll/sliders, etc. use this - rtf_tooltip_rect gg.Rect // RTF abbreviation tooltip anchor rect - rtf_tooltip_text string // RTF abbreviation tooltip text - tooltip TooltipState // State for the active tooltip - registry StateRegistry // generic per-widget state maps + cursor_on_sticky bool // keeps the cursor visible during cursor movement + id_focus u32 // current view that has focus + input_cursor_on bool = true // used by cursor blink animation + menu_key_nav bool // true, menu navigated by keyboard + mouse_cursor sapp.MouseCursor // arrow, finger, ibeam, etc. + mouse_lock MouseLockCfg // mouse down/move/up/scroll/sliders, etc. use this + mouse_lock_pinned bool // mouse_lock holds layout callbacks alive while installed + mouse_lock_dispatch_depth int // nested mouse_lock callback dispatch depth + mouse_lock_release_pending int // pins to release after the active dispatch returns + rtf_tooltip_rect gg.Rect // RTF abbreviation tooltip anchor rect + rtf_tooltip_text string // RTF abbreviation tooltip text + tooltip TooltipState // State for the active tooltip + registry StateRegistry // generic per-widget state maps // link_handler, when set, is called before opening a link in the OS browser. // If the handler sets e.is_handled = true the default os.open_uri is skipped. link_handler ?fn (url string, mut e Event, mut w Window) @@ -190,13 +193,19 @@ pub: // clear_view_state resets all GUI state for this window. // Call when window destroyed or needs full GUI state reinitialization. fn (mut w Window) clear_view_state() { + w.release_mouse_lock_pin() + mouse_lock_dispatch_depth := w.view_state.mouse_lock_dispatch_depth + mouse_lock_release_pending := w.view_state.mouse_lock_release_pending mut ctx := w.context() w.view_state.image_map.clear(mut ctx) w.view_state.diagram_cache.clear() w.view_state.svg_cache.clear() w.view_state.markdown_cache.clear() w.view_state.registry.clear() - w.view_state = ViewState{} + w.view_state = ViewState{ + mouse_lock_dispatch_depth: mouse_lock_dispatch_depth + mouse_lock_release_pending: mouse_lock_release_pending + } } fn (mut w Window) clear_input_selections() { diff --git a/view_svg.v b/view_svg.v index 758e256..b4dcd0b 100644 --- a/view_svg.v +++ b/view_svg.v @@ -84,29 +84,32 @@ fn (mut sv SvgView) generate_layout(mut window Window) Layout { anim_start.set(anim_hash, now_ns) } anim_id := 'svg_anim:${anim_hash}' - if !window.has_animation(anim_id) { - window.animation_add(mut &Animate{ - id: anim_id - delay: animation_cycle - repeat: true - callback: fn [anim_hash] (mut an Animate, mut w Window) { - // Check staleness: if SVG left the layout tree, stop - mut seen_map := state_map[string, i64](mut w, ns_svg_anim_seen, cap_moderate) - if seen := seen_map.get(anim_hash) { - elapsed := time.now().unix_nano() - seen - if elapsed > 200_000_000 { - // >200ms since last seen → SVG removed + window.animation_add_from_layout(fn [mut window, anim_id, anim_hash] () { + if !window.has_animation(anim_id) { + window.animation_add(mut &Animate{ + id: anim_id + delay: animation_cycle + repeat: true + callback: fn [anim_hash] (mut an Animate, mut w Window) { + // Check staleness: if SVG left the layout tree, stop + mut seen_map := state_map[string, i64](mut w, ns_svg_anim_seen, + cap_moderate) + if seen := seen_map.get(anim_hash) { + elapsed := time.now().unix_nano() - seen + if elapsed > 200_000_000 { + // >200ms since last seen → SVG removed + an.stopped = true + return + } + } else { an.stopped = true return } - } else { - an.stopped = true - return + w.update_window() } - w.update_window() - } - }) - } + }) + } + }) or { panic(err) } } mut events := unsafe { &EventHandlers(nil) } diff --git a/view_tab_control.v b/view_tab_control.v index 8c635e4..27474af 100644 --- a/view_tab_control.v +++ b/view_tab_control.v @@ -199,7 +199,7 @@ fn tab_control_build(cfg TabControlCfg, drag DragReorderState) View { } tab_on_click := if is_draggable { make_tab_drag_click(cfg.id, item.id, item_drag_idx, tab_ids, tab_layout_ids, - on_reorder, cfg.on_select, cfg.id_focus) + cfg.on_select, cfg.id_focus) } else { make_tab_on_click(cfg.on_select, item.id, cfg.id_focus) } @@ -285,6 +285,9 @@ fn tab_control_build(cfg TabControlCfg, drag DragReorderState) View { spacing: cfg.spacing disabled: cfg.disabled invisible: cfg.invisible + on_scroll: fn [tab_id, on_reorder] (_ &Layout, mut w Window) { + drag_reorder_apply_drop(tab_id, on_reorder, mut w) + } on_keydown: fn [disabled, tab_nav_ids, tab_nav_disabled, selected, on_select, id_focus, reorderable, on_reorder, tab_id, tab_ids] (_ &Layout, mut e Event, mut w Window) { tab_control_on_keydown(disabled, tab_nav_ids, tab_nav_disabled, selected, on_select, id_focus, reorderable, on_reorder, tab_id, tab_ids, mut e, mut @@ -331,12 +334,11 @@ fn make_tab_on_click(on_select fn (string, mut Event, mut Window), id string, id fn make_tab_drag_click(control_id string, item_id string, drag_index int, tab_ids []string, tab_layout_ids []string, - on_reorder fn (string, string, mut Window), on_select fn (string, mut Event, mut Window), id_focus u32) fn (&Layout, mut Event, mut Window) { - return fn [control_id, item_id, drag_index, tab_ids, tab_layout_ids, on_reorder, on_select, id_focus] (layout &Layout, mut e Event, mut w Window) { - drag_reorder_start(control_id, drag_index, item_id, .horizontal, tab_ids, on_reorder, - tab_layout_ids, 0, u32(0), layout, e, mut w) + return fn [control_id, item_id, drag_index, tab_ids, tab_layout_ids, on_select, id_focus] (layout &Layout, mut e Event, mut w Window) { + drag_reorder_start(control_id, drag_index, item_id, .horizontal, tab_ids, + tab_layout_ids, 0, u32(0), '', layout, e, mut w) on_select(item_id, mut e, mut w) if id_focus > 0 { w.set_id_focus(id_focus) diff --git a/view_tree.v b/view_tree.v index 04a01fd..433e50c 100644 --- a/view_tree.v +++ b/view_tree.v @@ -78,7 +78,6 @@ struct TreeFlatRow { // tree_flat_row_view and make_tree_drag_click. struct TreeDragContext { cfg_id string - on_reorder fn (string, string, string, mut Window) = unsafe { nil } parent_id string id_scroll u32 sibling_index int @@ -192,7 +191,6 @@ pub fn (mut window Window) tree(cfg TreeCfg) View { pid := fr.parent_id drag_ctx := TreeDragContext{ cfg_id: cfg_id - on_reorder: on_reorder parent_id: pid id_scroll: cfg.id_scroll sibling_index: sibling_idx @@ -224,6 +222,7 @@ pub fn (mut window Window) tree(cfg TreeCfg) View { return column( name: 'tree' + id: if can_reorder { drag_reorder_drop_handler_id(cfg_id) } else { '' } a11y_role: .tree a11y_label: a11y_label(cfg.a11y_label, cfg.id) a11y_description: cfg.a11y_description @@ -233,6 +232,9 @@ pub fn (mut window Window) tree(cfg TreeCfg) View { spacing: cfg.spacing height: cfg.height max_height: cfg.max_height + on_scroll: fn [cfg_id, on_reorder] (_ &Layout, mut w Window) { + drag_reorder_apply_tree_drop(cfg_id, on_reorder, mut w) + } on_keydown: fn [cfg_id, on_select, on_lazy_load, visible_ids, can_reorder, on_reorder, sibling_ids_by_parent, sibling_index_of, parent_of] (_ &Layout, mut e Event, mut w Window) { tree_on_keydown(cfg_id, on_select, on_lazy_load, visible_ids, can_reorder, on_reorder, sibling_ids_by_parent, sibling_index_of, parent_of, mut e, mut @@ -511,21 +513,17 @@ fn make_tree_drag_click(drag_ctx TreeDragContext, id string, is_expanded bool, has_children bool, is_lazy bool, node_has_real_children bool) fn (&Layout, mut Event, mut Window) { cfg_id := drag_ctx.cfg_id - on_reorder := drag_ctx.on_reorder parent_id := drag_ctx.parent_id id_scroll := drag_ctx.id_scroll sibling_index := drag_ctx.sibling_index sibling_ids := drag_ctx.sibling_ids - reorder_wrapped := fn [on_reorder, parent_id] (moved string, before string, mut w Window) { - on_reorder(moved, before, parent_id, mut w) - } mut sibling_layout_ids := []string{cap: sibling_ids.len} for sid in sibling_ids { sibling_layout_ids << 'tr_${cfg_id}_${sid}' } - return fn [cfg_id, sibling_index, sibling_ids, sibling_layout_ids, id_scroll, id, on_select, on_lazy_load, is_expanded, has_children, is_lazy, node_has_real_children, reorder_wrapped] (layout &Layout, mut e Event, mut w Window) { - drag_reorder_start(cfg_id, sibling_index, id, .vertical, sibling_ids, reorder_wrapped, - sibling_layout_ids, 0, id_scroll, layout, e, mut w) + return fn [cfg_id, parent_id, sibling_index, sibling_ids, sibling_layout_ids, id_scroll, id, on_select, on_lazy_load, is_expanded, has_children, is_lazy, node_has_real_children] (layout &Layout, mut e Event, mut w Window) { + drag_reorder_start(cfg_id, sibling_index, id, .vertical, sibling_ids, + sibling_layout_ids, 0, id_scroll, parent_id, layout, e, mut w) tree_row_click(cfg_id, on_select, on_lazy_load, is_expanded, has_children, is_lazy, node_has_real_children, id, mut e, mut w) } diff --git a/window.v b/window.v index 45c8f54..55fad68 100644 --- a/window.v +++ b/window.v @@ -19,41 +19,42 @@ pub type WindowCommand = fn (mut Window) pub struct Window { mut: - commands_mutex &sync.Mutex = sync.new_mutex() // Mutex for command queue - focused bool = true // Window focus state - mutex &sync.Mutex = sync.new_mutex() // Mutex for thread-safety - on_event fn (e &Event, mut w Window) = fn (_ &Event, mut _ Window) {} // Global event handler - state voidptr = unsafe { nil } // User state passed to the window - text_system &vglyph.TextSystem = unsafe { nil } // Text rendering system - ui &gg.Context = &gg.Context{} // Main sokol/gg graphics context - view_generator fn (&Window) View = empty_view // Function to generate the UI view - a11y A11y // Accessibility backend state (lazily initialized) - animations map[string]Animation // Active animations (keyed by id) - commands []WindowCommand // Atomic command queue for UI state updates - debug_layout bool // enable layout performance stats - inspector_enabled bool // dev-only inspector overlay (F12) - inspector_tree_cache []TreeNodeCfg // previous-frame tree for inspector - inspector_props_cache map[string]InspectorNodeProps // previous-frame node properties - dialog_cfg DialogCfg // Configuration for the active dialog (if any) - filter_state SvgFilterState // Offscreen state for SVG filters - ime IME // Input Method Editor state (lazily initialized) - init_error string // error during initialization (e.g. text system fail) - layout Layout // The current calculated layout tree - layout_stats LayoutStats // populated when debug_layout is true - pip Pipelines // GPU rendering pipelines (lazily initialized) - refresh_layout bool // Trigger full view/layout/renderer rebuild next frame - refresh_render_only bool // Trigger renderer-only rebuild from existing layout - render_guard_warned map[string]bool // Renderer kinds warned by render guard (prod only) - renderers []Renderer // Flat list of drawing instructions for the current frame - scratch ScratchPools // Bounded scratch arrays reused in hot paths - stats Stats // Rendering statistics - clip_radius f32 // rounded clip radius, render-time only - toasts []ToastNotification // active toast queue - toast_counter u64 // monotonic toast id - view_state ViewState // Manages state for widgets (scroll, selection, etc.) - window_size gg.Size // cached, gg.window_size() relatively slow - file_access FileAccessState // security-scoped bookmark state - file_access_mutex &sync.Mutex = sync.new_mutex() // guards file access state + commands_mutex &sync.Mutex = sync.new_mutex() // Mutex for command queue + focused bool = true // Window focus state + mutex &sync.Mutex = sync.new_mutex() // Mutex for thread-safety + on_event fn (e &Event, mut w Window) = fn (_ &Event, mut _ Window) {} // Global event handler + state voidptr = unsafe { nil } // User state passed to the window + text_system &vglyph.TextSystem = unsafe { nil } // Text rendering system + ui &gg.Context = &gg.Context{} // Main sokol/gg graphics context + view_generator fn (&Window) View = empty_view // Function to generate the UI view + a11y A11y // Accessibility backend state (lazily initialized) + animations map[string]Animation // Active animations (keyed by id) + commands []WindowCommand // Atomic command queue for UI state updates + debug_layout bool // enable layout performance stats + inspector_enabled bool // dev-only inspector overlay (F12) + inspector_tree_cache []TreeNodeCfg // previous-frame tree for inspector + inspector_props_cache map[string]InspectorNodeProps // previous-frame node properties + dialog_cfg DialogCfg // Configuration for the active dialog (if any) + filter_state SvgFilterState // Offscreen state for SVG filters + ime IME // Input Method Editor state (lazily initialized) + init_error string // error during initialization (e.g. text system fail) + layout Layout // The current calculated layout tree + layout_callback_lifetime LayoutCallbackLifetime // Owns callbacks created while rebuilding layout epochs + layout_stats LayoutStats // populated when debug_layout is true + pip Pipelines // GPU rendering pipelines (lazily initialized) + refresh_layout bool // Trigger full view/layout/renderer rebuild next frame + refresh_render_only bool // Trigger renderer-only rebuild from existing layout + render_guard_warned map[string]bool // Renderer kinds warned by render guard (prod only) + renderers []Renderer // Flat list of drawing instructions for the current frame + scratch ScratchPools // Bounded scratch arrays reused in hot paths + stats Stats // Rendering statistics + clip_radius f32 // rounded clip radius, render-time only + toasts []ToastNotification // active toast queue + toast_counter u64 // monotonic toast id + view_state ViewState // Manages state for widgets (scroll, selection, etc.) + window_size gg.Size // cached, gg.window_size() relatively slow + file_access FileAccessState // security-scoped bookmark state + file_access_mutex &sync.Mutex = sync.new_mutex() // guards file access state } // Window is the main application window. `state` holds app state. @@ -117,10 +118,11 @@ pub fn window(cfg &WindowCfg) &Window { log.set_always_flush(true) mut window := &Window{ - state: cfg.state - on_event: cfg.on_event - debug_layout: cfg.debug_layout - file_access: FileAccessState{ + state: cfg.state + on_event: cfg.on_event + debug_layout: cfg.debug_layout + layout_callback_lifetime: new_layout_callback_lifetime() + file_access: FileAccessState{ app_id: cfg.app_id } } diff --git a/window_api.v b/window_api.v index f8cf6af..8acc031 100644 --- a/window_api.v +++ b/window_api.v @@ -135,15 +135,54 @@ pub fn (window &Window) mouse_is_locked() bool { || window.view_state.mouse_lock.mouse_up != none } +fn (mut window Window) release_mouse_lock_pin() { + if !window.view_state.mouse_lock_pinned { + return + } + window.view_state.mouse_lock_pinned = false + if window.view_state.mouse_lock_dispatch_depth > 0 { + window.view_state.mouse_lock_release_pending++ + return + } + window.release_layout_callback_reclaim_pin() +} + +fn (mut window Window) mouse_lock_dispatch_begin() { + window.view_state.mouse_lock_dispatch_depth++ +} + +fn (mut window Window) mouse_lock_dispatch_end() { + if window.view_state.mouse_lock_dispatch_depth > 0 { + window.view_state.mouse_lock_dispatch_depth-- + } + if window.view_state.mouse_lock_dispatch_depth > 0 { + return + } + for window.view_state.mouse_lock_release_pending > 0 { + window.view_state.mouse_lock_release_pending-- + window.release_layout_callback_reclaim_pin() + } +} + // mouse_lock locks the mouse so all mouse events go to the // handlers in MouseLockCfg pub fn (mut window Window) mouse_lock(cfg MouseLockCfg) { + window.release_mouse_lock_pin() window.view_state.mouse_lock = cfg + if !window.mouse_is_locked() { + return + } + window.pin_layout_callback_reclaim() or { + window.view_state.mouse_lock = MouseLockCfg{} + return + } + window.view_state.mouse_lock_pinned = true } // mouse_unlock returns mouse handling events to normal behavior pub fn (mut window Window) mouse_unlock() { window.view_state.mouse_lock = MouseLockCfg{} + window.release_mouse_lock_pin() sapp.lock_mouse(false) } diff --git a/window_update.v b/window_update.v index 63e5c19..bfa5f47 100644 --- a/window_update.v +++ b/window_update.v @@ -80,7 +80,6 @@ fn (mut window Window) update() { clip_rect := window.window_rect() background_color := window.color_background() - mut view := window.view_generator(window) $if !prod { if window.inspector_enabled { window.inspector_props_cache = map[string]InspectorNodeProps{} @@ -89,13 +88,17 @@ fn (mut window Window) update() { window.inspector_props_cache) } } - layout_clear(mut window.layout) - window.layout = window.compose_layout(mut view) + window.layout_callback_frame(fn [mut window] () { + mut view := window.view_generator(window) + layout_clear(mut window.layout) + window.layout = window.compose_layout(mut view) + view_clear(mut view) + }) or { panic(err) } + window.reclaim_old_layout_callbacks() window.build_renderers(background_color, clip_rect) window.unlock() //-------------------------------------------- - view_clear(mut view) window.stats.update_max_renderers(usize(window.renderers.len)) }