From bcce34cda1c7d1fb87932e6675b866c48f4d9844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 17 Oct 2026 01:37:54 -0400 Subject: [PATCH] Upgrade MyGo to v0.5.5 MyGo v0.5.5 returns ui.Element as a checked value handle instead of a pointer. Migrated with `mygo migrate-ui`, then fixed what the codemod leaves to review: - Element nil checks become .Valid(); absent elements are ui.Element{}. - Input handlers and paint callbacks run outside the build pass, where elements expire and Context methods are inert: read IsDisabled into a local during the build, keep a ui.Handle for Focus, use c.Services() for clipboard access, create ui.Local state during the build. - Add core.AutoFocus: v0.5.5 records an overlay's opener after its content builds, so same-frame autofocus lost focus restore on close. It focuses one frame later; cascader rows defer their focus likewise. - core.OverlayLayer identifies build passes by the root element, not c.Now(), which repeats across passes and under the Tester clock. - Context menus survive the opening press counted as PressedOutside; split button tooltip no longer reorders its open menu; disabled menubar rows pass presses to the panel; Form defers blur-started async checks so the growing message no longer moves Submit away from the release; Combobox drops its disabled copy now that MyGo blocks typing under a disabled wrapper (canary flipped to a guard). - The Tester clock is virtual: tests use Advance/Now instead of time.Sleep/time.Now and read element state inside the view. The web mirror advances the clock to wall time before each frame and input. - AGENTS.md and docs describe the value Element API and the new pin. Snapshot goldens are darwin-only and were not re-verified here. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 12 +++--- account/account_switcher.go | 13 +++--- account/api_key_manager.go | 6 +-- account/coachmark.go | 8 ++-- account/coachmark_test.go | 2 +- account/example_account_test.go | 2 +- account/feedback_widget.go | 4 +- account/keyboard_shortcuts_list.go | 6 +-- account/login_form.go | 6 +-- account/oauth_buttons.go | 4 +- account/onboarding_wizard.go | 6 +-- account/profile_editor.go | 6 +-- account/session_list.go | 4 +- account/settings_layout.go | 10 ++--- account/settings_layout_test.go | 4 +- account/settings_search.go | 4 +- account/shortcut_recorder.go | 4 +- account/two_factor_input.go | 2 +- account/two_factor_input_test.go | 2 +- account/usage_quota.go | 4 +- account/user_menu.go | 2 +- account/user_menu_test.go | 2 +- account/whats_new_dialog.go | 4 +- account/workspace_switcher.go | 6 +-- agent/agent_diff.go | 4 +- agent/agent_parts.go | 16 ++++---- agent/agent_plan.go | 2 +- agent/agent_progress.go | 2 +- agent/agent_status.go | 2 +- agent/agent_status_test.go | 6 +-- agent/agent_step_list.go | 4 +- agent/artifact_panel.go | 2 +- agent/artifact_version_switcher.go | 2 +- agent/checkpoint_list.go | 2 +- agent/file_change_card.go | 2 +- agent/human_input_request.go | 2 +- agent/mcp_server_list.go | 4 +- agent/memory_panel.go | 6 +-- agent/multi_file_diff_review.go | 6 +-- agent/permission_prompt.go | 4 +- agent/sandbox_status.go | 2 +- agent/sub_agent_tree.go | 2 +- agent/tool_approval_dialog.go | 4 +- agent/tool_call_group.go | 2 +- chart/area_chart_test.go | 2 +- chart/bar_chart_test.go | 2 +- chart/box_plot_test.go | 2 +- chart/bullet_chart_test.go | 4 +- chart/calendar_heatmap_test.go | 2 +- chart/candle_countdown.go | 2 +- chart/candlestick_chart.go | 2 +- chart/candlestick_chart_test.go | 5 +-- chart/chart_annotation.go | 4 +- chart/chart_annotation_test.go | 4 +- chart/chart_axis_test.go | 2 +- chart/chart_brush.go | 10 ++--- chart/chart_brush_test.go | 13 +++--- chart/chart_crosshair_test.go | 2 +- chart/chart_empty_state_test.go | 13 +++--- chart/chart_frame.go | 10 ++--- chart/chart_frame_test.go | 2 +- chart/chart_legend.go | 6 +-- chart/chart_tooltip.go | 4 +- chart/chart_tooltip_test.go | 2 +- chart/chord_diagram_test.go | 5 ++- chart/drawing_toolbar.go | 2 +- chart/drawing_tools.go | 2 +- chart/funnel_chart_test.go | 2 +- chart/gauge_test.go | 2 +- chart/heatmap_chart_test.go | 2 +- chart/indicator_pane.go | 4 +- chart/indicator_selector.go | 4 +- chart/interval_selector.go | 2 +- chart/line_chart_test.go | 18 +++++++-- chart/market_heatmap.go | 2 +- chart/market_heatmap_test.go | 6 +-- chart/market_input.go | 6 +-- chart/market_large_test.go | 8 +++- chart/market_layers.go | 2 +- chart/market_review_regression_test.go | 7 ++-- chart/market_states_test.go | 10 +++-- chart/multi_chart_layout.go | 2 +- chart/network_graph.go | 7 ++-- chart/network_graph_test.go | 2 +- chart/ohlc_chart_test.go | 6 ++- chart/parallel_coordinates.go | 2 +- chart/parallel_coordinates_test.go | 7 ++-- chart/pie_chart_test.go | 2 +- chart/price_alert_line_test.go | 8 ++-- chart/radar_chart_test.go | 2 +- chart/sankey_chart_test.go | 2 +- chart/scatter_chart.go | 2 +- chart/scatter_chart_test.go | 2 +- chart/spark_bar_test.go | 8 ++-- chart/sunburst.go | 2 +- chart/sunburst_test.go | 2 +- chart/treemap.go | 2 +- chart/trend_indicator.go | 2 +- chart/violin_plot_test.go | 5 ++- chart/volume_chart.go | 5 ++- chart/volume_chart_test.go | 4 +- chat/artifact_card.go | 2 +- chat/attachment_chip.go | 2 +- chat/audio_message.go | 2 +- chat/capability_cards.go | 4 +- chat/capability_cards_test.go | 6 +-- chat/citation_badge.go | 23 ++++++----- chat/citation_badge_test.go | 13 +++--- chat/code_block.go | 4 +- chat/context_chips.go | 4 +- chat/context_mention_menu.go | 4 +- chat/context_window_meter.go | 2 +- chat/conversation_export.go | 6 +-- chat/conversation_item.go | 6 +-- chat/conversation_list.go | 4 +- chat/conversation_search.go | 2 +- chat/cost_estimator.go | 2 +- chat/date_separator.go | 2 +- chat/drag_drop_overlay.go | 2 +- chat/error_message.go | 2 +- chat/feedback_form.go | 2 +- chat/file_message.go | 2 +- chat/image_grid.go | 4 +- chat/link_preview_card.go | 2 +- chat/markdown_view.go | 6 +-- chat/message_actions.go | 2 +- chat/message_bubble.go | 2 +- chat/message_editor.go | 2 +- chat/message_header.go | 2 +- chat/message_list.go | 2 +- chat/mode_selector.go | 2 +- chat/model_selector.go | 4 +- chat/parameter_panel.go | 2 +- chat/paste_image_preview.go | 2 +- chat/project_knowledge_panel.go | 4 +- chat/project_list.go | 12 +++--- chat/prompt_composer.go | 5 ++- chat/prompt_library.go | 8 ++-- chat/quote_reply.go | 2 +- chat/regenerate_menu.go | 2 +- chat/scroll_to_bottom_button.go | 4 +- chat/send_button.go | 4 +- chat/slash_command_menu.go | 17 ++++---- chat/slash_command_menu_test.go | 8 ++-- chat/source_card.go | 5 +-- chat/sources_panel.go | 2 +- chat/stop_generating_button.go | 2 +- chat/streaming_text.go | 2 +- chat/suggestion_chips.go | 4 +- chat/system_prompt_editor.go | 2 +- chat/thinking_indicator.go | 2 +- chat/thinking_indicator_test.go | 3 +- chat/token_counter.go | 2 +- chat/tool_toggle_menu.go | 4 +- chat/typing_indicator.go | 2 +- chat/voice_waveform.go | 2 +- code/breakpoint_list.go | 8 ++-- code/code_shared.go | 10 ++--- code/code_viewer.go | 2 +- code/command_history.go | 2 +- code/completion_menu.go | 11 ++--- code/debug_toolbar.go | 2 +- code/find_widget.go | 2 +- code/flamegraph.go | 2 +- code/hex_viewer.go | 2 +- code/output_panel.go | 2 +- code/problems_panel.go | 2 +- code/process_list.go | 2 +- code/search_panel.go | 2 +- code/symbol_outline.go | 2 +- code/terminal.go | 29 ++++++++++---- code/terminal_tabs.go | 6 +-- code/variables_panel.go | 4 +- core/autofocus.go | 19 +++++++++ core/base_disabled_test.go | 19 ++++----- core/choice_result.go | 4 +- core/datetime.go | 8 ++-- core/icon_action.go | 2 +- core/overlay_layers.go | 27 +++++++------ core/overlay_motion.go | 4 +- core/severity.go | 2 +- core/spinner.go | 4 +- data/accordion.go | 6 +-- data/card.go | 2 +- data/carousel.go | 6 +-- data/carousel_test.go | 12 ++---- data/collapsible.go | 8 ++-- data/data_table.go | 2 +- data/data_table_columns.go | 4 +- data/description_list.go | 2 +- data/image_viewer.go | 6 +-- data/list.go | 8 ++-- data/statistic.go | 2 +- data/timeline.go | 2 +- data/tree.go | 2 +- datetime/attendee_list.go | 4 +- datetime/calendar.go | 2 +- datetime/calendar_events.go | 4 +- datetime/calendar_month_view.go | 2 +- datetime/calendar_time_grid.go | 6 +-- datetime/countdown.go | 2 +- datetime/current_time_indicator.go | 2 +- datetime/date_picker.go | 2 +- datetime/date_range_picker.go | 2 +- datetime/datetime_grid.go | 4 +- datetime/datetime_result.go | 6 +-- datetime/datetime_review2_test.go | 8 ++-- datetime/event_chip.go | 2 +- datetime/event_editor.go | 14 +++---- datetime/event_popover.go | 6 +-- datetime/recurrence_editor.go | 2 +- datetime/relative_time.go | 2 +- datetime/time_picker.go | 4 +- datetime/timezone_select.go | 2 +- devtools/alert_list.go | 4 +- devtools/api_request_builder.go | 2 +- devtools/devtools_parts.go | 2 +- devtools/helpers_test.go | 9 ++--- devtools/incident_card.go | 2 +- devtools/json_editor.go | 2 +- devtools/json_viewer.go | 2 +- devtools/key_value_input.go | 2 +- devtools/refresh_interval_selector.go | 2 +- devtools/refresh_interval_selector_test.go | 13 +++--- devtools/resource_gauge.go | 2 +- devtools/service_status.go | 4 +- devtools/system_monitor.go | 2 +- devtools/trace_waterfall.go | 2 +- display/avatar.go | 2 +- display/avatar_group.go | 16 ++++---- display/badge.go | 4 +- display/example_display_test.go | 2 +- display/heading.go | 2 +- display/icon.go | 4 +- display/image.go | 10 ++--- display/kbd.go | 4 +- display/link.go | 4 +- display/tag.go | 6 +-- display/text.go | 6 +-- docs/compat.md | 4 +- docs/components/002-title-bar.md | 2 +- docs/components/004-container.md | 2 +- docs/components/005-stack.md | 2 +- docs/components/006-grid.md | 4 +- docs/components/007-scroll-area.md | 2 +- docs/components/009-aspect-ratio.md | 2 +- docs/components/010-divider.md | 2 +- docs/components/011-text.md | 2 +- docs/components/012-heading.md | 2 +- docs/components/013-icon.md | 2 +- docs/components/015-avatar.md | 2 +- docs/components/017-badge.md | 2 +- docs/components/019-link.md | 2 +- docs/components/020-kbd.md | 2 +- docs/components/021-button.md | 2 +- docs/components/022-icon-button.md | 2 +- docs/components/029-checkbox.md | 2 +- docs/components/031-switch.md | 2 +- docs/components/032-toggle.md | 2 +- docs/components/041-form.md | 2 +- docs/components/042-form-field.md | 4 +- docs/components/060-segmented-control.md | 2 +- docs/components/065-navigation-menu.md | 2 +- docs/components/067-toolbar.md | 6 +-- docs/components/069-card.md | 2 +- docs/components/076-timeline.md | 2 +- docs/components/078-statistic.md | 2 +- docs/components/081-dialog.md | 2 +- docs/components/083-drawer.md | 2 +- docs/components/084-popover.md | 2 +- docs/components/085-tooltip.md | 2 +- docs/components/086-hover-card.md | 2 +- docs/components/087-dropdown-menu.md | 2 +- docs/components/088-context-menu.md | 2 +- docs/components/089-popconfirm.md | 2 +- docs/components/090-overlay.md | 2 +- docs/components/093-progress.md | 4 +- docs/components/094-spinner.md | 2 +- docs/components/095-skeleton.md | 2 +- docs/components/098-status-indicator.md | 2 +- docs/components/122-chart-tooltip.md | 2 +- docs/components/133-trend-indicator.md | 2 +- docs/components/203-message-bubble.md | 2 +- docs/components/204-message-header.md | 2 +- docs/components/205-streaming-text.md | 2 +- docs/components/212-link-preview-card.md | 2 +- docs/components/213-quote-reply.md | 2 +- .../components/219-scroll-to-bottom-button.md | 2 +- docs/components/220-typing-indicator.md | 2 +- docs/components/251-date-separator.md | 2 +- docs/components/252-thinking-indicator.md | 2 +- docs/components/255-cost-estimator.md | 2 +- docs/components/267-agent-status.md | 2 +- docs/components/268-agent-progress.md | 2 +- docs/components/276-sandbox-status.md | 2 +- docs/components/287-dots-loader.md | 2 +- docs/components/288-bars-loader.md | 2 +- docs/components/289-pulse-loader.md | 2 +- docs/components/290-orbit-loader.md | 2 +- docs/components/291-wave-loader.md | 2 +- docs/components/292-shimmer.md | 2 +- docs/components/293-count-up.md | 2 +- docs/components/295-shimmer-text.md | 2 +- docs/components/296-stagger.md | 2 +- docs/components/297-reorder-transition.md | 2 +- docs/components/298-layout-transition.md | 2 +- docs/components/299-presence.md | 2 +- docs/components/300-blink-highlight.md | 2 +- docs/components/301-diff-viewer.md | 2 +- docs/components/302-inline-diff.md | 2 +- docs/components/313-blame-view.md | 2 +- docs/components/317-diff-stat.md | 2 +- docs/components/318-git-status-badge.md | 2 +- docs/components/320-ansi-text.md | 2 +- docs/components/321-terminal.md | 2 +- docs/components/334-completion-menu.md | 2 +- docs/components/345-video-player.md | 2 +- docs/components/348-audio-player.md | 2 +- docs/components/350-audio-spectrum.md | 2 +- docs/components/353-media-controls.md | 2 +- .../356-screen-recorder-controls.md | 2 +- docs/components/358-mic-level-meter.md | 2 +- docs/components/426-event-chip.md | 2 +- docs/components/428-event-popover.md | 2 +- docs/components/429-current-time-indicator.md | 2 +- docs/components/438-relative-time.md | 2 +- docs/components/461-settings-layout.md | 4 +- docs/components/496-masonry.md | 2 +- docs/components/499-qr-code.md | 2 +- docs/components/500-activity-feed.md | 2 +- examples/gallery/app.go | 2 +- examples/gallery/composite_files.go | 2 +- examples/gallery/composite_form.go | 10 ++--- examples/gallery/composite_settings.go | 24 +++++------ examples/gallery/composite_test.go | 3 +- examples/gallery/overture.go | 2 +- examples/gallery/pages_account.go | 2 +- examples/gallery/pages_account_more.go | 2 +- examples/gallery/pages_charts2_parts.go | 2 +- examples/gallery/pages_chatmsg_blocks.go | 2 +- examples/gallery/pages_chatsession_demos.go | 2 +- examples/gallery/pages_display.go | 2 +- examples/gallery/pages_display_parts.go | 2 +- examples/gallery/pages_essentials_more.go | 4 +- examples/gallery/pages_form.go | 8 ++-- examples/gallery/pages_layout.go | 2 +- examples/gallery/pages_mktchart_demo.go | 2 +- examples/gallery/pages_motion_fx.go | 6 +-- examples/gallery/pages_overlay.go | 4 +- examples/gallery/web.go | 8 ++++ examples/gallery/web_input.go | 1 + feedback/activity_feed.go | 8 ++-- feedback/bars_loader.go | 2 +- feedback/blink_highlight.go | 2 +- feedback/blink_highlight_test.go | 3 +- feedback/count_up.go | 2 +- feedback/count_up_test.go | 6 +-- feedback/dots_loader.go | 6 +-- feedback/empty_state.go | 2 +- feedback/eval_result_table.go | 2 +- feedback/layout_transition.go | 2 +- feedback/layout_transition_test.go | 3 +- feedback/loaders_test.go | 12 +++--- feedback/loading_overlay.go | 2 +- feedback/notification_center.go | 6 +-- feedback/notification_center_test.go | 2 +- feedback/orbit_loader.go | 2 +- feedback/presence.go | 6 +-- feedback/presence_test.go | 6 +-- feedback/progress.go | 8 ++-- feedback/pulse_loader.go | 2 +- feedback/reorder_transition.go | 6 +-- feedback/reorder_transition_test.go | 5 ++- feedback/result.go | 2 +- feedback/shimmer.go | 4 +- feedback/shimmer_test.go | 3 +- feedback/shimmer_text.go | 2 +- feedback/shimmer_text_test.go | 3 +- feedback/skeleton.go | 2 +- feedback/skeleton_test.go | 6 +-- feedback/stagger.go | 4 +- feedback/stagger_test.go | 9 ++--- feedback/status_indicator.go | 2 +- feedback/toast.go | 2 +- feedback/toast_test.go | 9 ++--- feedback/tool_registry_panel.go | 4 +- feedback/trace_viewer.go | 2 +- feedback/typewriter_test.go | 6 +-- feedback/undo_toast.go | 2 +- feedback/undo_toast_test.go | 6 +-- feedback/wave_loader.go | 2 +- files/archive_viewer.go | 2 +- files/file_explorer.go | 2 +- files/file_grid.go | 2 +- files/file_icon.go | 2 +- files/file_operation_progress.go | 2 +- files/file_preview.go | 2 +- files/path_bar.go | 4 +- files/presence_avatars.go | 10 ++--- files/reactions.go | 8 ++-- files/recent_files.go | 2 +- files/remote_cursor.go | 2 +- files/rename_inline.go | 11 ++--- files/share_dialog.go | 4 +- files/storage_usage.go | 2 +- files/transfer_queue.go | 2 +- finance/asset_allocation_chart.go | 2 +- finance/bid_ask_bar.go | 2 +- finance/currency_converter.go | 8 ++-- finance/depth_ladder.go | 4 +- finance/greeks_table.go | 8 ++-- finance/index_card.go | 2 +- finance/level2_quotes.go | 2 +- finance/margin_indicator.go | 2 +- finance/market_overview.go | 4 +- finance/market_status.go | 2 +- finance/news_feed.go | 4 +- finance/option_chain.go | 2 +- finance/order_book.go | 4 +- finance/order_confirm_dialog.go | 2 +- finance/order_entry.go | 10 ++--- finance/position_table.go | 2 +- finance/price_change_badge.go | 2 +- finance/price_text.go | 2 +- finance/price_text_test.go | 3 +- finance/quick_trade_buttons.go | 4 +- finance/quote_card.go | 2 +- finance/risk_meter.go | 2 +- finance/screener.go | 4 +- finance/sentiment_gauge.go | 2 +- finance/sentiment_gauge_test.go | 15 +++---- finance/spread_indicator.go | 2 +- finance/symbol_badge.go | 2 +- finance/symbol_search.go | 4 +- finance/ticker_tape.go | 6 +-- finance/ticker_tape_test.go | 9 ++--- finance/trade_history_table.go | 2 +- finance/trading_session_clock.go | 4 +- finance/watchlist.go | 2 +- git/ansi_text.go | 2 +- git/blame_view.go | 2 +- git/blame_view_test.go | 2 +- git/branch_selector.go | 6 +-- git/changes_list.go | 2 +- git/command_block.go | 2 +- git/commit_input.go | 2 +- git/commit_list.go | 2 +- git/diff_stat.go | 2 +- git/diff_viewer.go | 2 +- git/git_status_badge.go | 2 +- git/inline_diff.go | 2 +- git/review_comment.go | 2 +- git/three_way_merge.go | 2 +- go.mod | 3 +- go.sum | 6 ++- input/button.go | 2 +- input/button_group.go | 4 +- input/cascader.go | 15 ++++--- input/checkbox.go | 4 +- input/choice_chips.go | 2 +- input/choice_review_test.go | 7 ++-- input/color_palette.go | 4 +- input/color_picker.go | 6 +-- input/combobox.go | 14 ++----- input/copy_button.go | 2 +- input/copy_button_test.go | 3 +- input/currency_input.go | 2 +- input/dock_panel.go | 4 +- input/example_form_test.go | 4 +- input/file_drop_zone.go | 2 +- input/file_picker.go | 4 +- input/form.go | 40 ++++++++++++++----- input/form_field.go | 14 +++---- input/form_field_test.go | 14 +++---- input/form_test.go | 14 +++---- input/hold_to_confirm.go | 4 +- input/hold_to_confirm_test.go | 9 ++--- input/icon_button.go | 2 +- input/input_group.go | 12 +++--- input/input_review_test.go | 4 ++ input/knob.go | 2 +- input/masked_input.go | 10 +++-- input/masonry.go | 4 +- input/mention_input.go | 39 ++++++++++++------ input/multi_select.go | 16 ++++---- input/number_input.go | 2 +- input/password_input.go | 6 +-- input/pin_input.go | 2 +- input/qr_code.go | 2 +- input/radio_group.go | 4 +- input/range_slider.go | 6 +-- input/rating_test.go | 6 +-- input/resizable_panel_group.go | 12 +++--- input/search_input.go | 2 +- input/select.go | 19 ++++----- input/signature_pad.go | 6 ++- input/slider.go | 4 +- input/spinner_test.go | 6 +-- input/stable_keys_test.go | 4 +- input/switch.go | 4 +- input/tags_input.go | 14 +++---- input/text_area.go | 2 +- input/text_input.go | 14 +++---- input/toggle.go | 8 ++-- input/toggle_group.go | 4 +- input/transfer.go | 4 +- input/tree_select.go | 8 ++-- input/vertical_slider.go | 2 +- internal/layout.go | 2 +- internal/navigation.go | 2 +- internal/paint.go | 2 +- layout/app_shell.go | 2 +- layout/aspect_ratio.go | 2 +- layout/container.go | 2 +- layout/divider.go | 2 +- layout/grid.go | 6 +-- layout/scroll_area.go | 4 +- layout/split_pane.go | 18 ++++----- layout/stack.go | 2 +- layout/status_bar.go | 18 ++++----- layout/title_bar.go | 2 +- media/audio_player.go | 4 +- media/audio_spectrum.go | 6 +-- media/audio_waveform.go | 2 +- media/image_annotator.go | 4 +- media/image_compare.go | 2 +- media/image_cropper.go | 2 +- media/image_cropper_geom.go | 2 +- media/lightbox.go | 4 +- media/media_controls.go | 2 +- media/mic_level_meter.go | 2 +- media/mic_level_meter_test.go | 13 +++--- media/playlist.go | 2 +- media/screen_recorder_controls.go | 2 +- media/subtitle_editor.go | 2 +- media/video_player.go | 6 +-- media/video_scrubber.go | 2 +- media/video_thumbnail_strip.go | 2 +- messaging/call_controls.go | 2 +- messaging/channel_list.go | 2 +- messaging/chat_message.go | 2 +- messaging/emoji_picker.go | 8 ++-- messaging/incoming_call_dialog.go | 6 +-- messaging/mail_composer.go | 4 +- messaging/mail_list.go | 2 +- messaging/mail_reader.go | 4 +- messaging/member_list.go | 2 +- messaging/online_status.go | 2 +- messaging/pinned_messages.go | 2 +- messaging/read_receipt.go | 4 +- messaging/recipient_input.go | 2 +- messaging/snooze_picker.go | 2 +- messaging/status_setter.go | 2 +- messaging/thread_panel.go | 2 +- messaging/unread_divider.go | 2 +- messaging/user_profile_card.go | 2 +- messaging/video_call_grid.go | 4 +- navigation/breadcrumb.go | 6 +-- navigation/command_palette.go | 8 ++-- navigation/menubar.go | 6 +-- navigation/menubar_panel.go | 11 +++-- navigation/navigation_menu.go | 10 ++--- navigation/navigation_review_test.go | 10 ++--- navigation/pagination.go | 6 +-- navigation/segmented_control.go | 2 +- navigation/steps.go | 2 +- navigation/tabs.go | 8 ++-- navigation/tabs_test.go | 3 +- navigation/toolbar.go | 6 +-- overlay/alert_dialog.go | 4 +- overlay/context_menu.go | 21 ++++++---- overlay/dialog.go | 8 ++-- overlay/drawer.go | 16 ++++---- overlay/drawer_test.go | 2 +- overlay/dropdown_menu.go | 2 +- overlay/hover_card.go | 14 +++---- overlay/hover_card_test.go | 3 +- overlay/overlay.go | 10 ++--- overlay/overlay_fit_test.go | 2 +- overlay/overlay_snapshot_test.go | 3 +- overlay/overlay_test.go | 4 +- overlay/popconfirm.go | 10 ++--- overlay/popover.go | 8 ++-- overlay/popover_test.go | 6 +-- overlay/popup_menu.go | 24 +++++------ overlay/split_button.go | 8 +++- overlay/tooltip.go | 4 +- overlay/tooltip_test.go | 3 +- project/assignee_picker.go | 8 ++-- project/issue_card.go | 2 +- project/issue_id_badge_test.go | 3 +- project/kanban_board.go | 4 +- project/kanban_board_test.go | 4 +- project/label_manager.go | 4 +- project/milestone_progress.go | 2 +- project/pomodoro_timer.go | 2 +- project/priority_indicator.go | 2 +- project/projects.go | 4 +- project/status_select.go | 6 +-- project/subtask_list.go | 2 +- project/task_detail_panel.go | 2 +- project/task_item.go | 2 +- project/workload_view.go | 2 +- 603 files changed, 1366 insertions(+), 1277 deletions(-) create mode 100644 core/autofocus.go diff --git a/AGENTS.md b/AGENTS.md index a68f4e1..c8c729f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,11 +9,11 @@ Planned: once 001–500 land, the root package splits into category sub-packages | Item | Value | | --- | --- | | Go | 1.27.1 (MyGo's minimum) | -| MyGo | `github.com/egoist/mygo v0.2.10` (commit 459b512), pinned in go.mod | +| MyGo | `github.com/egoist/mygo v0.5.5` (commit 4d8673c), pinned in go.mod | | Icons | Lucide 1.52.0, ISC, copied into `icons/svg` | | Web mirror | `examples/gallery -web :8080`: Go stdlib server drives the Gallery through `ui.NewTester` and streams frames to a canvas. The browser shows pixels; Go runs the components. | -Read MyGo source before using an API: `go env GOMODCACHE`/github.com/egoist/mygo@v0.2.10 (`ui/*.go`, `docs/ui/*.md`). Never guess. +Read MyGo source before using an API: `go env GOMODCACHE`/github.com/egoist/mygo@v0.5.5 (`ui/*.go`, `docs/ui/*.md`, migration guide `docs/ui/migration.md`). Never guess. ## Layout @@ -36,8 +36,8 @@ Gallery categories map to packages: Inputs, Choices, Forms, Essentials -> input; ## Component rules -- Signature: `func Name(c *ui.Context, value *T, opts NameOptions) *ui.Element`. Composites may return a named struct. -- Result structs expose the element as a named field `Element *ui.Element`, never embedded; element plus changed is `ChoiceResult`. +- Signature: `func Name(c *ui.Context, value *T, opts NameOptions) ui.Element`. Composites may return a named struct. +- Result structs expose the element as a named field `Element ui.Element`, never embedded; element plus changed is `ChoiceResult`. - Shared helpers: `iconAction` (borderless icon button), `overlayStyle(c, panel, overlayEnter(c, panel))` (popup surface and entry fade), `internal.RuleDiamond` / `internal.Frame` (ornaments), `colorFade` (state color transition). - Zero `Options` gives the default look. No `map[string]any` config. - Explicit font sizes: `FontSize(c, theme.CaptionSize)`; it applies the desktop text scale. @@ -50,7 +50,9 @@ Gallery categories map to packages: Inputs, Choices, Forms, Essentials -> input; - Durations: `core.Motion(c, theme.StateDuration)`; popups use `theme.PopupDuration`. - Disabled blocks input before handling it (`Disabled(true)`); MyGo dims disabled elements, do not add opacity. - Copy shown by a component comes from `core.Msg(c, key)`; business copy comes from the caller. -- No cross-frame `*ui.Element` caching, no goroutines touching elements. +- `ui.Element` is a handle valid only during its build pass: no cross-frame caching, no goroutines touching elements, no element methods inside `HandleInput` or other callbacks (read state such as `IsDisabled` into a local during the build). Keep a `ui.Handle` for persistent identity and `c.Services()` for clipboard or URLs from callbacks. The absent element is `ui.Element{}`; test it with `.Valid()`. +- Overlay content takes initial focus with `core.AutoFocus(c, e)`, not `e.AutoFocus()`: MyGo v0.5.5 records an overlay's opener after its content builds, so same-frame autofocus loses focus restore on close. +- Tests run on the Tester's virtual clock: `tt.Advance(d)` and `tt.Now()`, never `time.Sleep` or `time.Now()`; read element state inside the view. - Fail fast: panic on programmer errors (bad options); never swallow errors. - Log state transitions in composites with `slog.Debug("mujica: ", ...)`. Never log passwords. - Files under 500 lines. Comments: one line, only when needed. diff --git a/account/account_switcher.go b/account/account_switcher.go index 1f0508c..4d24407 100644 --- a/account/account_switcher.go +++ b/account/account_switcher.go @@ -29,7 +29,7 @@ type AccountSwitcherOptions struct { // AccountSwitcherResult reports account changes and addition requests. type AccountSwitcherResult struct { - Element *ui.Element + Element ui.Element changed bool add bool } @@ -75,7 +75,7 @@ func AccountSwitcher(c *ui.Context, current *string, accounts []Account, opts Ac if a.Email != "" { label += " · " + a.Email } - ui.Column(c).Key(a.ID).Shrink(0).Children(func() { + ui.Column(c.Key(a.ID)).Shrink(0).Children(func() { if m.Check(label, &on, overlay.PopupMenuItemOptions{}) && a.ID != *current && !b.IsDisabled() { *current, r.changed = a.ID, true slog.Debug("mujica: account_switcher switched") @@ -92,7 +92,7 @@ func AccountSwitcher(c *ui.Context, current *string, accounts []Account, opts Ac return r } -func accountSwitcherLarge(c *ui.Context, b *ui.Element, open *bool, current *string, accounts []Account, r *AccountSwitcherResult) { +func accountSwitcherLarge(c *ui.Context, b ui.Element, open *bool, current *string, accounts []Account, r *AccountSwitcherResult) { overlay.Popover(c, b, open, overlay.PopoverOptions{Label: core.Msg(c, "account_switcher.accounts"), Width: 320}, func() { cursor := ui.Local(b, "cursor", func() int { return -1 }) rows := ui.Local(b, "list", func() ui.ListState { return ui.ListState{} }) @@ -100,7 +100,7 @@ func accountSwitcherLarge(c *ui.Context, b *ui.Element, open *bool, current *str rows.Label = func(i int) string { return accounts[i].Name } rows.Selected = cursor restore := data.DataSelectTheme(c, 0) - list := ui.List(c, rows, len(accounts), func(i int) { + list := core.AutoFocus(c, ui.List(c, rows, len(accounts), func(i int) { a := accounts[i] row := workspaceRow(c, Workspace{ID: a.ID, Name: a.Name, Detail: a.Email}, a.ID == *current, i == *cursor) if row.Focused() && !b.IsDisabled() { @@ -109,7 +109,8 @@ func accountSwitcherLarge(c *ui.Context, b *ui.Element, open *bool, current *str if row.Clicked() && !row.IsDisabled() && !b.IsDisabled() { r.changed, *current, *open = a.ID != *current, a.ID, false } - }).Height(280).MinHeight(0).AutoFocus() + }).Height(280).MinHeight(0)) + restore() internal.Ring(list, core.Tokens(c).Focus, theme.ControlRadius) if list.Changed() && !b.IsDisabled() { @@ -129,7 +130,7 @@ func accountSwitcherLarge(c *ui.Context, b *ui.Element, open *bool, current *str } // accountTrigger displays an avatar, name and detail. -func accountTrigger(c *ui.Context, name, detail string, avatar *ui.Bitmap, disabled bool) *ui.Element { +func accountTrigger(c *ui.Context, name, detail string, avatar *ui.Bitmap, disabled bool) ui.Element { k := core.Tokens(c) d := core.Density(c) b := ui.ButtonBase(c).Role(ui.RoleMenuButton).MinHeight(d.ControlHeight()+d.Unit()*2).Padding(d.Unit(), d.Unit()*3, d.Unit(), d.Unit()*2). diff --git a/account/api_key_manager.go b/account/api_key_manager.go index 16a0094..dbdbe83 100644 --- a/account/api_key_manager.go +++ b/account/api_key_manager.go @@ -57,7 +57,7 @@ type ApiKeyManagerOptions struct { // ApiKeyManagerResult reports creation and revocation requests. type ApiKeyManagerResult struct { - Element *ui.Element + Element ui.Element create, revoke string } @@ -102,7 +102,7 @@ func ApiKeyManager(c *ui.Context, s *ApiKeyManagerState, keys []APIKey, opts Api } accountRows(c, len(keys), func(i int) any { return keys[i].ID }, func(i int) { key := keys[i] - ui.Row(c).Key(key.ID).MinHeight(d.ControlHeight()+d.Unit()*4).Gap(d.Unit()*3).AlignItems(ui.Center). + ui.Row(c.Key(key.ID)).MinHeight(d.ControlHeight()+d.Unit()*4).Gap(d.Unit()*3).AlignItems(ui.Center). BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() { ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() { ui.Text(c, key.Name).FontWeight(600).SingleLine() @@ -191,7 +191,7 @@ func apiKeyCreateDialog(c *ui.Context, s *ApiKeyManagerState, disabled bool) (st } res := input.TextInput(c, &s.name, input.TextInputOptions{Label: core.Msg(c, "api_key_manager.name"), Placeholder: core.Msg(c, "api_key_manager.name"), Disabled: s.pending, Error: s.err}) - res.Input.AutoFocus() + core.AutoFocus(c, res.Input) if res.Submitted() && strings.TrimSpace(s.name) != "" && !s.pending { asked = true } diff --git a/account/coachmark.go b/account/coachmark.go index 10ba834..b1f0c82 100644 --- a/account/coachmark.go +++ b/account/coachmark.go @@ -14,13 +14,13 @@ import ( // CoachmarkStep uses an anchor built in the current frame. type CoachmarkStep struct { - Anchor *ui.Element + Anchor ui.Element Title, Body string } // CoachmarkResult reports tour completion or dismissal. type CoachmarkResult struct { - Element *ui.Element + Element ui.Element finished, dismissed bool } @@ -40,7 +40,7 @@ func Coachmark(c *ui.Context, open *bool, step *int, steps []CoachmarkStep) Coac return r } cur := steps[*step] - if cur.Anchor == nil || cur.Title == "" { + if !cur.Anchor.Valid() || cur.Title == "" { panic("mujica: CoachmarkStep needs an Anchor and a Title") } if cur.Anchor.IsDisabled() { @@ -76,7 +76,7 @@ func Coachmark(c *ui.Context, open *bool, step *int, steps []CoachmarkStep) Coac if last { next = core.Msg(c, "coachmark.done") } - if input.Button(c, next, input.ButtonOptions{}).AutoFocus().Clicked() { + if core.AutoFocus(c, input.Button(c, next, input.ButtonOptions{})).Clicked() { if last { *open, r.finished = false, true } else { diff --git a/account/coachmark_test.go b/account/coachmark_test.go index a089c39..b81aebf 100644 --- a/account/coachmark_test.go +++ b/account/coachmark_test.go @@ -9,7 +9,7 @@ import ( ) func coachmarkView(c *ui.Context, open *bool, step *int, r *CoachmarkResult) { - var a, b *ui.Element + var a, b ui.Element ui.Row(c).Gap(40).Padding(20).Children(func() { a = input.Button(c, "Compose", input.ButtonOptions{}) b = input.Button(c, "Archive", input.ButtonOptions{Variant: input.Secondary}) diff --git a/account/example_account_test.go b/account/example_account_test.go index b68ccf3..f0c25c2 100644 --- a/account/example_account_test.go +++ b/account/example_account_test.go @@ -19,7 +19,7 @@ func ExampleSettingsLayout() { core.Use(c, core.Settings{}) account.SettingsLayout(c, &page, pages, account.SettingsLayoutOptions{}, func(id string) { account.SettingsSection(c, "Notifications", func() { - account.SettingsRow(c, "Sound", account.SettingsRowOptions{ID: "sound"}, func() *ui.Element { + account.SettingsRow(c, "Sound", account.SettingsRowOptions{ID: "sound"}, func() ui.Element { return input.Switch(c, &sound, "", input.SwitchOptions{}) }) }) diff --git a/account/feedback_widget.go b/account/feedback_widget.go index 4ca8865..7b96ddd 100644 --- a/account/feedback_widget.go +++ b/account/feedback_widget.go @@ -53,7 +53,7 @@ type FeedbackWidgetOptions struct { // FeedbackWidgetResult carries the submitted feedback. type FeedbackWidgetResult struct { - Element *ui.Element + Element ui.Element feedback Feedback submitted bool } @@ -106,7 +106,7 @@ func FeedbackWidget(c *ui.Context, s *FeedbackWidgetState, opts FeedbackWidgetOp return fmt.Sprintf(core.Msg(c, "feedback_widget.short"), minLen) } return "" - }}, func() *ui.Element { + }}, func() ui.Element { return input.TextArea(c, &s.Draft.Text, input.TextAreaOptions{MinHeight: 96, MaxLength: 2000, Disabled: locked}).Input }) if opts.Attachments != nil { diff --git a/account/keyboard_shortcuts_list.go b/account/keyboard_shortcuts_list.go index 2d3ddeb..884bb02 100644 --- a/account/keyboard_shortcuts_list.go +++ b/account/keyboard_shortcuts_list.go @@ -35,7 +35,7 @@ type shortcutsListState struct { } // KeyboardShortcutsList displays searchable, grouped shortcuts using visible rows. -func KeyboardShortcutsList(c *ui.Context, query *string, groups []ShortcutGroup, opts KeyboardShortcutsListOptions) *ui.Element { +func KeyboardShortcutsList(c *ui.Context, query *string, groups []ShortcutGroup, opts KeyboardShortcutsListOptions) ui.Element { k := core.Tokens(c) d := core.Density(c) label := opts.Label @@ -96,11 +96,11 @@ func shortcutsListPlan(st *shortcutsListState, groups []ShortcutGroup, q string) } } -func accountRows(c *ui.Context, n int, key func(int) any, row func(int)) *ui.Element { +func accountRows(c *ui.Context, n int, key func(int) any, row func(int)) ui.Element { if n <= 8 { return ui.Column(c).Children(func() { for i := range n { - ui.Column(c).Key(key(i)).Children(func() { row(i) }) + ui.Column(c.Key(key(i))).Children(func() { row(i) }) } }) } diff --git a/account/login_form.go b/account/login_form.go index c0ac872..f8d7744 100644 --- a/account/login_form.go +++ b/account/login_form.go @@ -40,7 +40,7 @@ type LoginFormOptions struct { } // LoginForm collects credentials; it never checks or stores them. -func LoginForm(c *ui.Context, s *LoginFormState, opts LoginFormOptions) *ui.Element { +func LoginForm(c *ui.Context, s *LoginFormState, opts LoginFormOptions) ui.Element { if opts.OnSubmit == nil { panic("mujica: LoginForm needs OnSubmit") } @@ -61,11 +61,11 @@ func LoginForm(c *ui.Context, s *LoginFormState, opts LoginFormOptions) *ui.Elem submit := false var acc, pw input.InputResult e.Children(func() { - input.FormField(c, accountLabel, input.FormFieldOptions{Required: true}, func() *ui.Element { + input.FormField(c, accountLabel, input.FormFieldOptions{Required: true}, func() ui.Element { acc = input.TextInput(c, &s.Account, input.TextInputOptions{Disabled: disabled || s.submitting, Error: missing(s.Account)}) return acc.Input }) - input.FormField(c, core.Msg(c, "login_form.password"), input.FormFieldOptions{Required: true}, func() *ui.Element { + input.FormField(c, core.Msg(c, "login_form.password"), input.FormFieldOptions{Required: true}, func() ui.Element { pw = input.PasswordInput(c, &s.password, input.PasswordInputOptions{Disabled: disabled || s.submitting, Error: missing(s.password)}) return pw.Input }) diff --git a/account/oauth_buttons.go b/account/oauth_buttons.go index 6a048cd..3490e91 100644 --- a/account/oauth_buttons.go +++ b/account/oauth_buttons.go @@ -25,7 +25,7 @@ type OAuthButtonsOptions struct { // OAuthButtonsResult carries the selected provider. type OAuthButtonsResult struct { - Element *ui.Element + Element ui.Element chosen string } @@ -47,7 +47,7 @@ func OAuthButtons(c *ui.Context, providers []OAuthProvider, opts OAuthButtonsOpt panic("mujica: OAuthProvider needs an ID and a Name") } label := fmt.Sprintf(core.Msg(c, "oauth_buttons.continue"), p.Name) - ui.Column(c).Key(p.ID).AlignItems(ui.Stretch).Children(func() { + ui.Column(c.Key(p.ID)).AlignItems(ui.Stretch).Children(func() { b := input.Button(c, label, input.ButtonOptions{Variant: input.Secondary, Icon: p.Icon, Loading: opts.Pending == p.ID, Disabled: opts.Pending != "" && opts.Pending != p.ID}) if b.Clicked() && !disabled && opts.Pending == "" { diff --git a/account/onboarding_wizard.go b/account/onboarding_wizard.go index 5fb5d0e..18d22e0 100644 --- a/account/onboarding_wizard.go +++ b/account/onboarding_wizard.go @@ -28,7 +28,7 @@ type OnboardingWizardOptions struct { // OnboardingWizardResult reports completion or skipping. type OnboardingWizardResult struct { - Element *ui.Element + Element ui.Element finished, skipped bool } @@ -60,7 +60,7 @@ func OnboardingWizard(c *ui.Context, step *int, steps []WizardStep, opts Onboard at := *step navigation.Steps(c, &at, head, navigation.StepsOptions{Label: core.Msg(c, "onboarding_wizard.progress")}) cur := steps[*step] - ui.Column(c).Key(*step).Gap(d.Unit() * 3).Children(func() { + ui.Column(c.Key(*step)).Gap(d.Unit() * 3).Children(func() { ui.Text(c, fmt.Sprintf(core.Msg(c, "onboarding_wizard.count"), *step+1, len(steps))).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)) ui.Text(c, cur.Title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H2Size)).FontWeight(600) if cur.Description != "" { @@ -84,7 +84,7 @@ func OnboardingWizard(c *ui.Context, step *int, steps []WizardStep, opts Onboard if last { next = core.Msg(c, "onboarding_wizard.finish") } - if input.Button(c, next, input.ButtonOptions{Disabled: !ready}).AutoFocus().Clicked() && ready { + if core.AutoFocus(c, input.Button(c, next, input.ButtonOptions{Disabled: !ready})).Clicked() && ready { if last { r.finished = true } else { diff --git a/account/profile_editor.go b/account/profile_editor.go index aed4ef4..a8aa0b6 100644 --- a/account/profile_editor.go +++ b/account/profile_editor.go @@ -34,7 +34,7 @@ type ProfileEditorOptions struct { // ProfileEditorResult reports save, avatar and discard requests. type ProfileEditorResult struct { - Element *ui.Element + Element ui.Element save, avatar, reset bool } @@ -73,10 +73,10 @@ func ProfileEditor(c *ui.Context, p *Profile, opts ProfileEditorOptions) Profile r.avatar = true } }) - input.FormField(c, core.Msg(c, "profile_editor.name"), input.FormFieldOptions{Required: true}, func() *ui.Element { + input.FormField(c, core.Msg(c, "profile_editor.name"), input.FormFieldOptions{Required: true}, func() ui.Element { return input.TextInput(c, &p.Name, input.TextInputOptions{Disabled: locked, Error: nameErr}).Input }) - input.FormField(c, core.Msg(c, "profile_editor.bio"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, core.Msg(c, "profile_editor.bio"), input.FormFieldOptions{}, func() ui.Element { return input.TextArea(c, &p.Bio, input.TextAreaOptions{MaxLength: limit, Disabled: locked}).Input }) ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() { diff --git a/account/session_list.go b/account/session_list.go index 6fe8720..48a5219 100644 --- a/account/session_list.go +++ b/account/session_list.go @@ -30,7 +30,7 @@ type SessionListOptions struct { // SessionListResult reports individual and bulk sign-out requests. type SessionListResult struct { - Element *ui.Element + Element ui.Element revoke string others bool } @@ -74,7 +74,7 @@ func SessionList(c *ui.Context, sessions []Session, opts SessionListOptions) Ses ui.Column(c).Role(ui.RoleList).Label(core.Msg(c, "session_list.label")).Children(func() { accountRows(c, len(sessions), func(i int) any { return sessions[i].ID }, func(i int) { s := sessions[i] - ui.Row(c).Key(s.ID).MinHeight(d.ControlHeight()+d.Unit()*4).Gap(d.Unit()*3).AlignItems(ui.Center). + ui.Row(c.Key(s.ID)).MinHeight(d.ControlHeight()+d.Unit()*4).Gap(d.Unit()*3).AlignItems(ui.Center). BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Role(ui.RoleListItem).Children(func() { ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() { ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { diff --git a/account/settings_layout.go b/account/settings_layout.go index f634c8b..e57ef42 100644 --- a/account/settings_layout.go +++ b/account/settings_layout.go @@ -58,14 +58,14 @@ func SettingsLayout(c *ui.Context, page *string, pages []SettingsPage, opts Sett slog.Debug("mujica: settings_layout page", "page", *page) } ui.Scroll(c).Grow(1).MinWidth(0).Padding(d.Unit()*6, d.Unit()*8).Children(func() { - ui.Column(c).Key(*page).MaxWidth(720).Gap(d.Unit() * 8).Children(func() { content(*page) }) + ui.Column(c.Key(*page)).MaxWidth(720).Gap(d.Unit() * 8).Children(func() { content(*page) }) }) }) return r } // SettingsSection groups rows under a serif heading. -func SettingsSection(c *ui.Context, title string, rows func()) *ui.Element { +func SettingsSection(c *ui.Context, title string, rows func()) ui.Element { k := core.Tokens(c) d := core.Density(c) e := ui.Column(c).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(title) @@ -94,7 +94,7 @@ func settingsReveal(c *ui.Context, id string) { type settingsRowState struct{ at time.Time } // SettingsRow associates a label and description with its control. -func SettingsRow(c *ui.Context, label string, opts SettingsRowOptions, control func() *ui.Element) *ui.Element { +func SettingsRow(c *ui.Context, label string, opts SettingsRowOptions, control func() ui.Element) ui.Element { if label == "" || control == nil { panic("mujica: SettingsRow needs a label and a control") } @@ -106,7 +106,7 @@ func SettingsRow(c *ui.Context, label string, opts SettingsRowOptions, control f } st := ui.Local(row, "reveal", func() settingsRowState { return settingsRowState{} }) reveal := ui.Local(c.Root(), settingsRevealKey{}, func() string { return "" }) - var ctrl, text *ui.Element + var ctrl, text ui.Element row.Children(func() { text = ui.Column(c).Grow(1).MinWidth(0).Gap(d.Unit()) text.Children(func() { @@ -117,7 +117,7 @@ func SettingsRow(c *ui.Context, label string, opts SettingsRowOptions, control f }) ui.Row(c).Shrink(0).Children(func() { ctrl = control() }) }) - if ctrl == nil { + if !ctrl.Valid() { panic("mujica: SettingsRow " + label + ": control returned nil") } ctrl.Label(label).Description(opts.Description) diff --git a/account/settings_layout_test.go b/account/settings_layout_test.go index 37106b5..23f8a52 100644 --- a/account/settings_layout_test.go +++ b/account/settings_layout_test.go @@ -24,13 +24,13 @@ func settingsView(c *ui.Context, page *string, sound, track *bool, changes *int) switch id { case "general": SettingsSection(c, "Notifications", func() { - SettingsRow(c, "Sound", SettingsRowOptions{ID: "sound", Description: "Chime on new messages."}, func() *ui.Element { + SettingsRow(c, "Sound", SettingsRowOptions{ID: "sound", Description: "Chime on new messages."}, func() ui.Element { return input.Switch(c, sound, "", input.SwitchOptions{}) }) }) case "privacy": SettingsSection(c, "Data", func() { - SettingsRow(c, "Usage reports", SettingsRowOptions{ID: "track"}, func() *ui.Element { + SettingsRow(c, "Usage reports", SettingsRowOptions{ID: "track"}, func() ui.Element { return input.Switch(c, track, "", input.SwitchOptions{}) }) }) diff --git a/account/settings_search.go b/account/settings_search.go index 9a73769..b78f315 100644 --- a/account/settings_search.go +++ b/account/settings_search.go @@ -32,7 +32,7 @@ type SettingsSearchOptions struct { // SettingsSearchResult carries the selected destination. type SettingsSearchResult struct { - Element *ui.Element + Element ui.Element chosen *SettingEntry } @@ -135,7 +135,7 @@ func settingsSearchPlan(st *settingsSearchState, entries []SettingEntry, q strin func settingsSearchHit(c *ui.Context, e SettingEntry, q string, current bool) bool { k := core.Tokens(c) d := core.Density(c) - b := ui.ButtonBase(c).Key(e.ID).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*3).Justify(ui.Start). + b := ui.ButtonBase(c.Key(e.ID)).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*3).Justify(ui.Start). Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(e.Label).Description(e.Description).Selected(current).Gap(d.Unit() * 2) internal.Ring(b, k.Focus, theme.ControlRadius) switch { diff --git a/account/shortcut_recorder.go b/account/shortcut_recorder.go index 530d963..1aa3228 100644 --- a/account/shortcut_recorder.go +++ b/account/shortcut_recorder.go @@ -145,7 +145,7 @@ func ShortcutRecorder(c *ui.Context, value *Shortcut, opts ShortcutRecorderOptio } if st.recording { b.HandleInput(func(ev ui.InputEvent) bool { - if b.IsDisabled() { + if disabled { return false } return shortcutRecorderKey(st, ev) @@ -178,7 +178,7 @@ func shortcutRecorderKey(st *shortcutRecorderState, ev ui.InputEvent) bool { } // shortcutRecorderLook displays the shortcut or recording prompt. -func shortcutRecorderLook(c *ui.Context, b *ui.Element, st *shortcutRecorderState, value Shortcut, name string) { +func shortcutRecorderLook(c *ui.Context, b ui.Element, st *shortcutRecorderState, value Shortcut, name string) { k := core.Tokens(c) internal.Ring(b, k.Focus, theme.ControlRadius) label := name + ": " + value.String() diff --git a/account/two_factor_input.go b/account/two_factor_input.go index d123be2..178876c 100644 --- a/account/two_factor_input.go +++ b/account/two_factor_input.go @@ -25,7 +25,7 @@ type TwoFactorInputOptions struct { // TwoFactorResult reports completion and resend events. type TwoFactorResult struct { - Element *ui.Element + Element ui.Element completed, resend bool } diff --git a/account/two_factor_input_test.go b/account/two_factor_input_test.go index d2f9dd3..1499cbf 100644 --- a/account/two_factor_input_test.go +++ b/account/two_factor_input_test.go @@ -37,7 +37,7 @@ func TestTwoFactorInputPasteCompleteAndResend(t *testing.T) { if resent != 1 { t.Fatal("resend not reported") } - opts.ResendAt = time.Now().Add(65 * time.Second) + opts.ResendAt = tt.Now().Add(65 * time.Second) opts.Error = "That code has expired." tt.Frame() if !tt.HasText("Resend in 1:05") && !tt.HasText("Resend in 1:04") || !tt.HasText("That code has expired.") { diff --git a/account/usage_quota.go b/account/usage_quota.go index 063a96e..6c5db1b 100644 --- a/account/usage_quota.go +++ b/account/usage_quota.go @@ -29,7 +29,7 @@ type UsageQuotaOptions struct { } // UsageQuota displays usage bars and reset time. -func UsageQuota(c *ui.Context, items []QuotaItem, opts UsageQuotaOptions) *ui.Element { +func UsageQuota(c *ui.Context, items []QuotaItem, opts UsageQuotaOptions) ui.Element { warn := opts.Warn if warn == 0 { warn = 0.8 @@ -54,7 +54,7 @@ func UsageQuota(c *ui.Context, items []QuotaItem, opts UsageQuotaOptions) *ui.El col, icon, note = k.Warning, "triangle-alert", core.Msg(c, "usage_quota.near") } amount := fmt.Sprintf(core.Msg(c, "usage_quota.amount"), data.StatisticFormat(it.Used, 0), data.StatisticFormat(it.Limit, 0), it.Unit) - ui.Column(c).Key(i).Gap(d.Unit()).Role(ui.RoleMeter).Label(it.Label). + ui.Column(c.Key(i)).Gap(d.Unit()).Role(ui.RoleMeter).Label(it.Label). Description(fmt.Sprintf("%s, %s%% %s", amount, data.StatisticFormat(share*100, 0), note)).Children(func() { ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { ui.Text(c, it.Label).FontWeight(500).Grow(1) diff --git a/account/user_menu.go b/account/user_menu.go index e8141b5..b573741 100644 --- a/account/user_menu.go +++ b/account/user_menu.go @@ -32,7 +32,7 @@ type UserMenuOptions struct { // UserMenuResult reports presence, settings and sign-out events. type UserMenuResult struct { - Element *ui.Element + Element ui.Element changed, settings, leave bool } diff --git a/account/user_menu_test.go b/account/user_menu_test.go index 3cdce90..3946651 100644 --- a/account/user_menu_test.go +++ b/account/user_menu_test.go @@ -46,7 +46,7 @@ func TestUserMenuSnapshot(t *testing.T) { ui.Row(c).Padding(20).Gap(16).Children(func() { for s := UserStatusOnline; s <= UserStatusInvisible; s++ { st := s - ui.Box(c).Key(int(s)).Children(func() { UserMenu(c, &st, UserMenuOptions{Name: "Sakiko Togawa"}) }) + ui.Box(c.Key(int(s))).Children(func() { UserMenu(c, &st, UserMenuOptions{Name: "Sakiko Togawa"}) }) } }) }) diff --git a/account/whats_new_dialog.go b/account/whats_new_dialog.go index 90a5520..0111b9c 100644 --- a/account/whats_new_dialog.go +++ b/account/whats_new_dialog.go @@ -70,7 +70,7 @@ func WhatsNewDialog(c *ui.Context, open *bool, hide *bool, releases []Release, o r.Element = overlay.Dialog(c, open, overlay.DialogOptions{Title: title, Width: 480, Decorated: true, Actions: func() { input.Checkbox(c, hide, core.Msg(c, "whats_new_dialog.hide"), input.CheckboxOptions{}) ui.Box(c).Grow(1) - if input.Button(c, core.Msg(c, "whats_new_dialog.close"), input.ButtonOptions{}).AutoFocus().Clicked() { + if core.AutoFocus(c, input.Button(c, core.Msg(c, "whats_new_dialog.close"), input.ButtonOptions{})).Clicked() { *open = false } }}, func() { @@ -78,7 +78,7 @@ func WhatsNewDialog(c *ui.Context, open *bool, hide *bool, releases []Release, o if rel.Version == "" { panic("mujica: Release needs a Version") } - ui.Column(c).Key(i).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(rel.Version).Children(func() { + ui.Column(c.Key(i)).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(rel.Version).Children(func() { ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.End).Children(func() { ui.Text(c, rel.Version).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600) if rel.Date != "" { diff --git a/account/workspace_switcher.go b/account/workspace_switcher.go index 887f070..55fe1d5 100644 --- a/account/workspace_switcher.go +++ b/account/workspace_switcher.go @@ -72,7 +72,7 @@ func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts overlay.Popover(c, b, &st.open, overlay.PopoverOptions{Width: 300, Label: core.Msg(c, "workspace_switcher.label")}, func() { res := input.SearchInput(c, &st.query, input.SearchInputOptions{Placeholder: core.Msg(c, "workspace_switcher.search"), Label: core.Msg(c, "workspace_switcher.search")}) in := res.Input - in.AutoFocus() + core.AutoFocus(c, in) q := strings.ToLower(strings.TrimSpace(st.query)) if !st.ready || st.filtered != q || !slices.Equal(st.source, spaces) { st.source, st.filtered, st.ready = slices.Clone(spaces), q, true @@ -115,10 +115,10 @@ func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts } // workspaceRow displays a clickable workspace with a selection mark. -func workspaceRow(c *ui.Context, w Workspace, current, cursor bool) *ui.Element { +func workspaceRow(c *ui.Context, w Workspace, current, cursor bool) ui.Element { k := core.Tokens(c) d := core.Density(c) - b := ui.ButtonBase(c).Key(w.ID).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*2).Gap(d.Unit() * 2). + b := ui.ButtonBase(c.Key(w.ID)).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*2).Gap(d.Unit() * 2). Justify(ui.Start).Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(w.Name).Selected(current) internal.Ring(b, k.Focus, theme.ControlRadius) switch { diff --git a/agent/agent_diff.go b/agent/agent_diff.go index 71c54a3..0e4e964 100644 --- a/agent/agent_diff.go +++ b/agent/agent_diff.go @@ -52,7 +52,7 @@ func (dc *agentDiffCache) update(ch FileChange) { } // agentDiffOf returns the cached diff of ch kept on holder. -func agentDiffOf(holder *ui.Element, ch FileChange) *agentDiffCache { +func agentDiffOf(holder ui.Element, ch FileChange) *agentDiffCache { dc := ui.Local(holder, "mujica.agent.diff", func() *agentDiffCache { return &agentDiffCache{} }) (*dc).update(ch) return *dc @@ -98,7 +98,7 @@ func agentDiffRow(c *ui.Context, l internal.DiffLine) { } // agentDiffView shows lines[from:to] in a capped well, virtualized when long. -func agentDiffView(c *ui.Context, lines []internal.DiffLine, maxHeight float32) *ui.Element { +func agentDiffView(c *ui.Context, lines []internal.DiffLine, maxHeight float32) ui.Element { w := AgentWellRows(c, len(lines), func(i int) { agentDiffRow(c, lines[i]) }, maxHeight) return w.Padding(core.Density(c).Unit(), 0) } diff --git a/agent/agent_parts.go b/agent/agent_parts.go index 5084638..92ce39b 100644 --- a/agent/agent_parts.go +++ b/agent/agent_parts.go @@ -67,7 +67,7 @@ func agentTone(k theme.Tokens, s AgentState) (ui.Color, *ui.SVG) { } // agentMark draws s; running spins unless still. -func agentMark(c *ui.Context, s AgentState, size float32, still bool) *ui.Element { +func agentMark(c *ui.Context, s AgentState, size float32, still bool) ui.Element { tone, icon := agentTone(core.Tokens(c), s) if s == AgentRunning && !still { return core.Spinner(c, core.SpinnerOptions{Size: size, Color: tone, Label: core.Msg(c, "agent_status.running")}) @@ -95,7 +95,7 @@ func AgentDuration(d time.Duration) string { } // AgentCard is the bordered surface every agent card sits on. -func AgentCard(c *ui.Context) *ui.Element { +func AgentCard(c *ui.Context) ui.Element { k := core.Tokens(c) u := core.Density(c).Unit() return ui.Column(c).Shrink(0).Padding(u*3).Gap(u*2).Radius(theme.CardRadius). @@ -103,12 +103,12 @@ func AgentCard(c *ui.Context) *ui.Element { } // AgentCaption is small muted text. -func AgentCaption(c *ui.Context, s string) *ui.Element { +func AgentCaption(c *ui.Context, s string) ui.Element { return ui.Text(c, s).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(core.Tokens(c).TextMuted) } // agentWell styles a sunken monospace well. -func agentWell(c *ui.Context, l *ui.Element, maxHeight float32) *ui.Element { +func agentWell(c *ui.Context, l ui.Element, maxHeight float32) ui.Element { k := core.Tokens(c) u := core.Density(c).Unit() return l.Shrink(1).MaxHeight(maxHeight).Padding(u*2, u*3).Radius(theme.ControlRadius). @@ -117,12 +117,12 @@ func agentWell(c *ui.Context, l *ui.Element, maxHeight float32) *ui.Element { } // AgentWellRows builds n rows in a well. -func AgentWellRows(c *ui.Context, n int, row func(i int), maxHeight float32) *ui.Element { +func AgentWellRows(c *ui.Context, n int, row func(i int), maxHeight float32) ui.Element { return agentWell(c, AgentRows(c, n, row), maxHeight) } // AgentRows builds n scrolling rows, virtualized when many. -func AgentRows(c *ui.Context, n int, row func(i int)) *ui.Element { +func AgentRows(c *ui.Context, n int, row func(i int)) ui.Element { if n > agentVirtualRows { return ui.List(c, nil, n, row) } @@ -196,7 +196,7 @@ func agentCodeLine(c *ui.Context, language, line string, inBlock bool) { } // AgentCode shows highlighted text in a capped well. -func AgentCode(c *ui.Context, language, text string, maxHeight float32) *ui.Element { +func AgentCode(c *ui.Context, language, text string, maxHeight float32) ui.Element { holder := ui.Column(c).Shrink(1) cc := ui.Local(holder, "mujica.agent.code", func() *agentCodeCache { return &agentCodeCache{} }) (*cc).update(language, text) @@ -260,7 +260,7 @@ func agentAnsiSpans(k theme.Tokens, in []internal.AnsiSpan) []ui.Span { } // agentBar is a thin progress bar named label. -func agentBar(c *ui.Context, frac float64, label string) *ui.Element { +func agentBar(c *ui.Context, frac float64, label string) ui.Element { if frac < 0 || frac > 1 || frac != frac { panic(fmt.Sprintf("mujica: progress %v outside 0..1", frac)) } diff --git a/agent/agent_plan.go b/agent/agent_plan.go index 1d65179..66d4f76 100644 --- a/agent/agent_plan.go +++ b/agent/agent_plan.go @@ -72,7 +72,7 @@ func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) core.Cho agentBar(c, frac, fmt.Sprintf(core.Msg(c, "agent_plan.progress"), done, counted)) list := *items for i := range list { - ui.Row(c).Key(list[i].ID).Gap(u * 2).AlignItems(ui.Center).MinHeight(core.Density(c).ControlHeight()).Children(func() { + ui.Row(c.Key(list[i].ID)).Gap(u * 2).AlignItems(ui.Center).MinHeight(core.Density(c).ControlHeight()).Children(func() { if agentPlanRow(c, &list[i], locked, opts.Still) { r = core.NewChoiceResult(r.Element, true) } diff --git a/agent/agent_progress.go b/agent/agent_progress.go index da1780e..d5edfe5 100644 --- a/agent/agent_progress.go +++ b/agent/agent_progress.go @@ -19,7 +19,7 @@ type AgentProgressOptions struct { } // AgentProgress shows progress 0 to 1 and the current action. -func AgentProgress(c *ui.Context, value float64, opts AgentProgressOptions) *ui.Element { +func AgentProgress(c *ui.Context, value float64, opts AgentProgressOptions) ui.Element { label := opts.Label if label == "" { label = core.Msg(c, "agent_progress.label") diff --git a/agent/agent_status.go b/agent/agent_status.go index 816656e..d7078e1 100644 --- a/agent/agent_status.go +++ b/agent/agent_status.go @@ -16,7 +16,7 @@ type AgentStatusOptions struct { } // AgentStatus shows a state by icon shape and word. -func AgentStatus(c *ui.Context, state AgentState, opts AgentStatusOptions) *ui.Element { +func AgentStatus(c *ui.Context, state AgentState, opts AgentStatusOptions) ui.Element { label := opts.Label if label == "" { label = agentWord(c, state) // panics on an unknown state diff --git a/agent/agent_status_test.go b/agent/agent_status_test.go index 4ea8e8f..35a3394 100644 --- a/agent/agent_status_test.go +++ b/agent/agent_status_test.go @@ -33,16 +33,14 @@ func TestAgentStatusAnimationStopsWhenStill(t *testing.T) { }, 300, 100) tt.Frame() moving := string(tt.Image().Pix) - time.Sleep(80 * time.Millisecond) - tt.Frame() + tt.Advance(80 * time.Millisecond) if moving == string(tt.Image().Pix) { t.Fatal("running status did not animate") } still = true tt.Frame() a := string(tt.Image().Pix) - time.Sleep(80 * time.Millisecond) - tt.Frame() + tt.Advance(80 * time.Millisecond) if a != string(tt.Image().Pix) { t.Fatal("still status kept moving") } diff --git a/agent/agent_step_list.go b/agent/agent_step_list.go index 72e3b08..ea29bd1 100644 --- a/agent/agent_step_list.go +++ b/agent/agent_step_list.go @@ -30,7 +30,7 @@ type AgentStepListOptions struct { // AgentStepListResult reports this frame's events. type AgentStepListResult struct { - Element *ui.Element + Element ui.Element // Retry is the failed step to retry, else "". Retry string // Toggled is the step whose detail toggled, else "". @@ -55,7 +55,7 @@ func AgentStepList(c *ui.Context, steps []AgentStep, opts AgentStepListOptions) open := ui.Local(r.Element, "mujica.steps.open", func() map[string]bool { return map[string]bool{} }) r.Element.Children(func() { for i, s := range steps { - ui.Column(c).Key(s.ID).Children(func() { + ui.Column(c.Key(s.ID)).Children(func() { if agentStepRow(c, s, i, len(steps), i == current, (*open)[s.ID], opts.Still, &r) { (*open)[s.ID] = !(*open)[s.ID] r.Toggled = s.ID diff --git a/agent/artifact_panel.go b/agent/artifact_panel.go index e021eb0..9d72262 100644 --- a/agent/artifact_panel.go +++ b/agent/artifact_panel.go @@ -42,7 +42,7 @@ type ArtifactPanelOptions struct { // ArtifactPanelResult reports this frame's events. type ArtifactPanelResult struct { - Element *ui.Element + Element ui.Element // Copied reports the content went to the clipboard. Copied bool // Exported asks the caller to export the artifact. diff --git a/agent/artifact_version_switcher.go b/agent/artifact_version_switcher.go index 12c25d3..756bbaa 100644 --- a/agent/artifact_version_switcher.go +++ b/agent/artifact_version_switcher.go @@ -23,7 +23,7 @@ type ArtifactVersionSwitcherOptions struct { // ArtifactVersionResult reports this frame's events. type ArtifactVersionResult struct { - Element *ui.Element + Element ui.Element // Changed reports *current moved. Changed bool // Compare asks to diff current against the previous version. diff --git a/agent/checkpoint_list.go b/agent/checkpoint_list.go index 8f9c723..6f2b5bd 100644 --- a/agent/checkpoint_list.go +++ b/agent/checkpoint_list.go @@ -91,7 +91,7 @@ func checkpointRow(c *ui.Context, p Checkpoint, current, locked bool) bool { d := core.Density(c) u := d.Unit() asked := false - row := ui.Row(c).Key(p.ID).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3) + row := ui.Row(c.Key(p.ID)).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3) if current { row.Background(k.Selection) } diff --git a/agent/file_change_card.go b/agent/file_change_card.go index 6472e49..726e7df 100644 --- a/agent/file_change_card.go +++ b/agent/file_change_card.go @@ -20,7 +20,7 @@ type FileChangeCardOptions struct { // FileChangeResult reports this frame's events. type FileChangeResult struct { - Element *ui.Element + Element ui.Element // Decided reports that *decision changed. Decided bool // OpenDiff asks the caller to show the full diff. diff --git a/agent/human_input_request.go b/agent/human_input_request.go index 03b0308..492c4a5 100644 --- a/agent/human_input_request.go +++ b/agent/human_input_request.go @@ -28,7 +28,7 @@ type HumanInputRequestOptions struct { // HumanInputResult reports this frame's events. type HumanInputResult struct { - Element *ui.Element + Element ui.Element Submitted bool // TimedOut reports the frame the deadline passed. TimedOut bool diff --git a/agent/mcp_server_list.go b/agent/mcp_server_list.go index 9ff3a26..de095f8 100644 --- a/agent/mcp_server_list.go +++ b/agent/mcp_server_list.go @@ -53,7 +53,7 @@ type MCPServerListOptions struct { // MCPServerListResult reports this frame's request. type MCPServerListResult struct { - Element *ui.Element + Element ui.Element Action MCPAction // Server is the ID the action is for. Server string @@ -105,7 +105,7 @@ func mcpServerRow(c *ui.Context, s MCPServer, locked, still bool) MCPAction { words := [...]string{"stopped", "connecting", "connected", "error"} word := core.Msg(c, "mcp_server_list."+words[s.State]) act := MCPNone - ui.Row(c).Key(s.ID).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3). + ui.Row(c.Key(s.ID)).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3). Label(s.Name + ", " + word).Children(func() { switch s.State { case MCPConnecting: diff --git a/agent/memory_panel.go b/agent/memory_panel.go index 6c30c4e..07eb5ad 100644 --- a/agent/memory_panel.go +++ b/agent/memory_panel.go @@ -30,7 +30,7 @@ type MemoryPanelOptions struct { // MemoryPanelResult reports this frame's in-place edits. type MemoryPanelResult struct { - Element *ui.Element + Element ui.Element // Edited and Deleted are changed IDs, else "". Edited string Deleted string @@ -74,7 +74,7 @@ func MemoryPanel(c *ui.Context, entries *[]MemoryEntry, opts MemoryPanelOptions) } AgentRows(c, len(st.shown), func(i int) { idx := st.shown[i] - ui.Column(c).Key((*entries)[idx].ID).Children(func() { memoryRow(c, entries, idx, st, locked, &r) }) + ui.Column(c.Key((*entries)[idx].ID)).Children(func() { memoryRow(c, entries, idx, st, locked, &r) }) }).Shrink(1).MaxHeight(maxH).Dividers(theme.BorderWidth, core.Tokens(c).Border) }) if r.Deleted != "" { @@ -123,7 +123,7 @@ func memoryRow(c *ui.Context, entries *[]MemoryEntry, idx int, st *memoryPanelSt u := core.Density(c).Unit() ui.Column(c).Gap(u).Padding(u*2, 0).Children(func() { if st.editing == e.ID && !locked { - input.TextArea(c, &st.draft, input.TextAreaOptions{Label: core.Msg(c, "memory_panel.edit"), MinHeight: 60}).Input.AutoFocus() + core.AutoFocus(c, input.TextArea(c, &st.draft, input.TextAreaOptions{Label: core.Msg(c, "memory_panel.edit"), MinHeight: 60}).Input) ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() { if input.Button(c, core.Msg(c, "memory_panel.cancel"), input.ButtonOptions{Variant: input.Secondary}).Clicked() { st.editing = "" diff --git a/agent/multi_file_diff_review.go b/agent/multi_file_diff_review.go index faea810..02ca0d0 100644 --- a/agent/multi_file_diff_review.go +++ b/agent/multi_file_diff_review.go @@ -79,7 +79,7 @@ func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileC slog.Debug("mujica: multi_file_diff_review select", "path", files[i].Path) } } - var list *ui.Element + var list ui.Element if len(files) > agentVirtualRows { list = ui.List(c, nil, len(files), row) } else { @@ -109,7 +109,7 @@ func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileC func multiFileRow(c *ui.Context, f FileChange, d FileDecision, selected bool) bool { k := core.Tokens(c) u := core.Density(c).Unit() - row := ui.ButtonBase(c).Key(f.Path).Justify(ui.Start).Gap(u*2).MinHeight(core.Density(c).ControlHeight()). + row := ui.ButtonBase(c.Key(f.Path)).Justify(ui.Start).Gap(u*2).MinHeight(core.Density(c).ControlHeight()). Padding(0, u*2).Radius(theme.ControlRadius).Label(f.Path) internal.Ring(row, k.Focus, theme.ControlRadius) dc := agentDiffOf(row, f) @@ -133,7 +133,7 @@ func multiFileRow(c *ui.Context, f FileChange, d FileDecision, selected bool) bo func fileChangeDecide(c *ui.Context, d *FileDecision, f FileChange, locked bool) bool { u := core.Density(c).Unit() changed := false - holder := ui.Column(c).Key(f.Path).Grow(1).Shrink(1).Gap(u * 2) + holder := ui.Column(c.Key(f.Path)).Grow(1).Shrink(1).Gap(u * 2) dc := agentDiffOf(holder, f) holder.Children(func() { ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { diff --git a/agent/permission_prompt.go b/agent/permission_prompt.go index c2ef211..6c1f0a0 100644 --- a/agent/permission_prompt.go +++ b/agent/permission_prompt.go @@ -32,7 +32,7 @@ type PermissionPromptOptions struct { // PermissionResult reports the choice made in this frame. type PermissionResult struct { - Element *ui.Element + Element ui.Element Choice PermissionChoice } @@ -48,7 +48,7 @@ func PermissionPrompt(c *ui.Context, open *bool, opts PermissionPromptOptions) P r.Element = overlay.Dialog(c, open, overlay.DialogOptions{ Title: opts.Title, Description: opts.Description, KeepOnBackdrop: true, HideClose: true, Actions: func() { - if input.Button(c, core.Msg(c, "permission_prompt.deny"), input.ButtonOptions{Variant: input.Secondary}).AutoFocus().Clicked() { + if core.AutoFocus(c, input.Button(c, core.Msg(c, "permission_prompt.deny"), input.ButtonOptions{Variant: input.Secondary})).Clicked() { r.Choice = PermissionDeny } if input.Button(c, core.Msg(c, "permission_prompt.always"), input.ButtonOptions{Variant: input.Outline}).Clicked() { diff --git a/agent/sandbox_status.go b/agent/sandbox_status.go index b0f5532..9f8b4f5 100644 --- a/agent/sandbox_status.go +++ b/agent/sandbox_status.go @@ -45,7 +45,7 @@ type SandboxStatusOptions struct { } // SandboxStatus shows sandbox state, resources and time left. -func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *ui.Element { +func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) ui.Element { agent := map[SandboxState]AgentState{SandboxStarting: AgentPlanning, SandboxRunning: AgentRunning, SandboxStopped: AgentSkipped, SandboxFailed: AgentFailed} state, ok := agent[info.State] if !ok { diff --git a/agent/sub_agent_tree.go b/agent/sub_agent_tree.go index 91b64fb..eb52311 100644 --- a/agent/sub_agent_tree.go +++ b/agent/sub_agent_tree.go @@ -74,7 +74,7 @@ func subAgentRow(c *ui.Context, a SubAgent, depth int, open, still bool) bool { d := core.Density(c) u := d.Unit() parent := len(a.Children) > 0 - row := ui.ButtonBase(c).Key(a.ID).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()). + row := ui.ButtonBase(c.Key(a.ID)).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()). Padding(0, u*2, 0, u*2+float32(depth)*u*5).Radius(theme.ControlRadius). Label(a.Name + ", " + agentWord(c, a.State)) internal.Ring(row, k.Focus, theme.ControlRadius) diff --git a/agent/tool_approval_dialog.go b/agent/tool_approval_dialog.go index ffe968e..69a6727 100644 --- a/agent/tool_approval_dialog.go +++ b/agent/tool_approval_dialog.go @@ -22,7 +22,7 @@ type ToolApprovalOptions struct { // ToolApprovalResult reports the choice made in this frame. type ToolApprovalResult struct { - Element *ui.Element + Element ui.Element Allowed bool Denied bool } @@ -39,7 +39,7 @@ func ToolApprovalDialog(c *ui.Context, open *bool, opts ToolApprovalOptions) Too Title: core.Msg(c, "tool_approval_dialog.title"), Description: opts.Reason, KeepOnBackdrop: true, HideClose: true, Actions: func() { // Deny first: a reflexive Enter never runs the tool. - if input.Button(c, core.Msg(c, "tool_approval_dialog.deny"), input.ButtonOptions{Variant: input.Secondary}).AutoFocus().Clicked() { + if core.AutoFocus(c, input.Button(c, core.Msg(c, "tool_approval_dialog.deny"), input.ButtonOptions{Variant: input.Secondary})).Clicked() { r.Denied = true } if input.Button(c, core.Msg(c, "tool_approval_dialog.allow"), input.ButtonOptions{}).Clicked() { diff --git a/agent/tool_call_group.go b/agent/tool_call_group.go index 4ff51ab..345ac90 100644 --- a/agent/tool_call_group.go +++ b/agent/tool_call_group.go @@ -64,7 +64,7 @@ func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGro }) }}, func() { for _, call := range calls { - ui.Column(c).Key(call.ID).Children(func() { ToolCallCard(c, call, ToolCallCardOptions{Still: opts.Still}) }) + ui.Column(c.Key(call.ID)).Children(func() { ToolCallCard(c, call, ToolCallCardOptions{Still: opts.Still}) }) } }) } diff --git a/chart/area_chart_test.go b/chart/area_chart_test.go index 39cdaa5..015efa8 100644 --- a/chart/area_chart_test.go +++ b/chart/area_chart_test.go @@ -35,7 +35,7 @@ func TestAreaChartStackedReadout(t *testing.T) { if got := chartReadout(tt); got != "0 · Pews 3 · Candles 5" { t.Fatalf("readout shows the series values, not the stack: %q", got) } - b := h.res.Element.Bounds() + b := h.box(tt) tt.Move(b.X+52+(b.W-64)*0.5, b.Y+b.H/2) if got := chartReadout(tt); got != "2 · Pews no value · Candles 6" || h.res.Point != 2 { t.Fatalf("hover %q", got) diff --git a/chart/bar_chart_test.go b/chart/bar_chart_test.go index 5000468..0999bb0 100644 --- a/chart/bar_chart_test.go +++ b/chart/bar_chart_test.go @@ -44,7 +44,7 @@ func TestBarChartHoverStackedHorizontal(t *testing.T) { tt := ui.NewTester(h.view, 520, 420) tabTo(t, tt, "Tithes") found := map[string]bool{} - b := h.res.Element.Bounds() + b := h.box(tt) for y := b.Y + 30; y < b.Y+b.H-40; y += 4 { for x := b.X + 60; x < b.X+b.W-16; x += 8 { tt.Move(x, y) diff --git a/chart/box_plot_test.go b/chart/box_plot_test.go index 052d93b..7fe0175 100644 --- a/chart/box_plot_test.go +++ b/chart/box_plot_test.go @@ -33,7 +33,7 @@ func TestBoxPlotReadout(t *testing.T) { if got := chartReadout(tt); got != "Choir · median 5.5 · quartiles 3.25–7.75 · whiskers 1–9 · 1 outliers" { t.Fatalf("readout %q", got) } - b := h.res.Element.Bounds() + b := h.box(tt) tt.Key(0, ui.KeyEscape) tt.Move(b.X+52+(b.W-64)*0.75, b.Y+100) if got := chartReadout(tt); got != "Nave · median 5 · quartiles 5–6 · whiskers 4–7 · 0 outliers" { diff --git a/chart/bullet_chart_test.go b/chart/bullet_chart_test.go index 9e7b691..761ddc4 100644 --- a/chart/bullet_chart_test.go +++ b/chart/bullet_chart_test.go @@ -23,7 +23,7 @@ func TestBulletChartReadout(t *testing.T) { } } tt.Key(0, ui.KeyEscape) - b := h.res.Element.Bounds() + b := h.box(tt) plotW := b.W - 24 tt.Move(b.X+8+plotW*0.1, b.Y+6+21) // band 1, inside the value bar if got := chartReadout(tt); got != "Actual 270" { @@ -40,7 +40,7 @@ func TestBulletChartVerticalAndBad(t *testing.T) { return BulletChart(c, 40, BulletChartOptions{Frame: ChartFrame{Label: "Alms", Height: 200}, Vertical: true, Max: 100}) }} tt := ui.NewTester(h.view, 520, 420) - b := h.res.Element.Bounds() + b := h.box(tt) tt.Move(b.X+b.W/2, b.Y+8+184*0.8) // low on the scale: inside the bar if got := chartReadout(tt); got != "Actual 40" { t.Fatalf("vertical hover %q", got) diff --git a/chart/calendar_heatmap_test.go b/chart/calendar_heatmap_test.go index 100cb8a..51497b8 100644 --- a/chart/calendar_heatmap_test.go +++ b/chart/calendar_heatmap_test.go @@ -65,7 +65,7 @@ func TestCalendarHeatmapWeekStartHover(t *testing.T) { return CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: ChartFrame{Label: "Prayers"}, WeekStart: tc.start}) }} tt := ui.NewTester(h.view, 560, 420) - b := h.res.Element.Bounds() + b := h.box(tt) plotY := b.Y + b.H - 20 - 8 - 240 // above the readout line and gap step := min((b.W-36)/53, float32(240-48)/7) tt.Move(b.X+32+step/2, plotY+18+float32(tc.row)*step+step/2) diff --git a/chart/candle_countdown.go b/chart/candle_countdown.go index bea5dd8..b2f853c 100644 --- a/chart/candle_countdown.go +++ b/chart/candle_countdown.go @@ -19,7 +19,7 @@ type CandleCountdownOptions struct { } // CandleCountdown displays closing time and schedules visible updates. -func CandleCountdown(c *ui.Context, interval Interval, opts CandleCountdownOptions) *ui.Element { +func CandleCountdown(c *ui.Context, interval Interval, opts CandleCountdownOptions) ui.Element { loc := opts.Location if loc == nil { loc = time.UTC diff --git a/chart/candlestick_chart.go b/chart/candlestick_chart.go index 0b8ab0a..de0ae67 100644 --- a/chart/candlestick_chart.go +++ b/chart/candlestick_chart.go @@ -121,7 +121,7 @@ func CandlestickChart(c *ui.Context, candles []Candle, view *MarketView, opts Ca m.yDomain() m.alertsCheck() res, plot := ch.Build(m.spec()) - if plot != nil { + if plot.Valid() { m.input(plot) } if s := opts.Sync; s != nil { diff --git a/chart/candlestick_chart_test.go b/chart/candlestick_chart_test.go index ea7cafa..fc8ffa6 100644 --- a/chart/candlestick_chart_test.go +++ b/chart/candlestick_chart_test.go @@ -33,13 +33,12 @@ func mktState0(r ChartResult) *mktState { func TestCandlestickChartDrawsOnlyVisible(t *testing.T) { candles := sampleCandles(100000) var view MarketView - var res ChartResult + var st *mktState tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) - res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}}) + st = mktState0(CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}})) }, 900, 400) tt.Frame() - st := mktState0(res) if st.drawn != 120 { t.Fatalf("drew %d candles of 100000", st.drawn) } diff --git a/chart/chart_annotation.go b/chart/chart_annotation.go index 9a642f5..78b067b 100644 --- a/chart/chart_annotation.go +++ b/chart/chart_annotation.go @@ -123,7 +123,7 @@ type noteDrag struct { } // Drag handles movement; plot uses element coordinates. -func (a ChartAnnotationPart) Drag(plotElement *ui.Element, plot ui.Rect) bool { +func (a ChartAnnotationPart) Drag(plotElement ui.Element, plot ui.Rect) bool { if plotElement.IsDisabled() { return false } @@ -185,7 +185,7 @@ func (a ChartAnnotationPart) Drag(plotElement *ui.Element, plot ui.Rect) bool { return true } -func (a ChartAnnotationPart) keyboard(e *ui.Element) bool { +func (a ChartAnnotationPart) keyboard(e ui.Element) bool { index := ui.Local(e, "mujica.notes.key", func() int { return 0 }) dir := 0 if e.Shortcut(ui.Alt, ui.KeyPageDown) { diff --git a/chart/chart_annotation_test.go b/chart/chart_annotation_test.go index b3ebe02..9ebb60f 100644 --- a/chart/chart_annotation_test.go +++ b/chart/chart_annotation_test.go @@ -19,7 +19,7 @@ func TestChartAnnotationDrag(t *testing.T) { } h := &chartHarness{build: func(c *ui.Context) ChartResult { return partsChart(c, notes, false) }} tt := ui.NewTester(h.view, 520, 420) - b := h.res.Element.Bounds() + b := h.box(tt) plot := ChartInset(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: 240}, 52, 26) yAt := func(v float32) float32 { return plot.Y + plot.H*(1-v/100) } xAt := func(v float32) float32 { return plot.X + plot.W*v/10 } @@ -51,7 +51,7 @@ func TestChartAnnotationDisabledAndBad(t *testing.T) { notes := []ChartNote{{Kind: NoteHLine, Value: 50, Draggable: true}} h := &chartHarness{disabled: true, build: func(c *ui.Context) ChartResult { return partsChart(c, notes, false) }} tt := ui.NewTester(h.view, 520, 420) - b := h.res.Element.Bounds() + b := h.box(tt) plot := ChartInset(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: 240}, 52, 26) tt.Press(plot.X+100, plot.Y+plot.H/2) tt.Move(plot.X+100, plot.Y+20) diff --git a/chart/chart_axis_test.go b/chart/chart_axis_test.go index c0725b6..49b8a6e 100644 --- a/chart/chart_axis_test.go +++ b/chart/chart_axis_test.go @@ -96,7 +96,7 @@ func partsChart(c *ui.Context, notes []ChartNote, crosshair bool) ChartResult { return hair.Snap(len(pts), func(i int) (float32, float32) { return at(r, i) }, px, py) }, Anchor: at, - Input: func(plot *ui.Element, r ui.Rect) { notesPart.Drag(plot, plotOf(r)) }, + Input: func(plot ui.Element, r ui.Rect) { notesPart.Drag(plot, plotOf(r)) }, Draw: func(p *ui.Painter, r ui.Rect, active int) { plot := plotOf(r) grid.Draw(p, plot) diff --git a/chart/chart_brush.go b/chart/chart_brush.go index 462f8fa..7282868 100644 --- a/chart/chart_brush.go +++ b/chart/chart_brush.go @@ -94,7 +94,7 @@ func ChartBrush(c *ui.Context, value *[2]float64, opts ChartBrushOptions) core.C track.Draw(func(p *ui.Painter, r ui.Rect) { brushPaint(p, r, *value, opts, k, preview) }) track.Children(func() { x0, x1 := at(value[0]), at(value[1]) - end := func(i int, x float32) *ui.Element { + end := func(i int, x float32) ui.Element { e := ui.Box(c).Absolute().Left(x-theme.MinHitSize/2).Top(0).Size(theme.MinHitSize, h).Focusable(). Role(ui.RoleSlider).Label(fmt.Sprintf(core.Msg(c, []string{"chart_brush.start", "chart_brush.end"}[i]), val(value[i]))) internal.Ring(e, k.Focus, theme.ControlRadius) @@ -104,7 +104,7 @@ func ChartBrush(c *ui.Context, value *[2]float64, opts ChartBrushOptions) core.C }) } // Tab order runs start, window, end. - ends := [2]*ui.Element{end(0, x0)} + ends := [2]ui.Element{end(0, x0)} window := ui.Box(c).Absolute().Left(x0 + theme.MinHitSize/2).Top(0).Width(max(x1-x0-theme.MinHitSize, theme.MinHitSize)).Height(h).Focusable(). Role(ui.RoleGroup).Label(fmt.Sprintf(core.Msg(c, "chart_brush.window"), opts.Label, val(value[0]), val(value[1]))) internal.Ring(window, k.Focus, theme.ControlRadius) @@ -149,7 +149,7 @@ func ChartBrush(c *ui.Context, value *[2]float64, opts ChartBrushOptions) core.C } // brushKey handles arrow, Page, Home and End keys. -func brushKey(e *ui.Element, v float64, spec input.NumberInputSpec) (float64, bool) { +func brushKey(e ui.Element, v float64, spec input.NumberInputSpec) (float64, bool) { if e.IsDisabled() { return v, false } @@ -171,10 +171,10 @@ func brushKey(e *ui.Element, v float64, spec input.NumberInputSpec) (float64, bo } // brushPointer drags handles, the window, or a new range. -func brushPointer(track, window *ui.Element, ends [2]*ui.Element, drag *brushDrag, value *[2]float64, from func(float32) float64, set, move func(lo, hi float64)) { +func brushPointer(track, window ui.Element, ends [2]ui.Element, drag *brushDrag, value *[2]float64, from func(float32) float64, set, move func(lo, hi float64)) { px, _, _ := track.PointerPosition() at := from(px) - parts := []*ui.Element{ends[0], ends[1], window, track} + parts := []ui.Element{ends[0], ends[1], window, track} held := -1 for i, e := range parts { if _, _, ok := e.Dragged(); ok && held < 0 { diff --git a/chart/chart_brush_test.go b/chart/chart_brush_test.go index 6da3a53..cad24fd 100644 --- a/chart/chart_brush_test.go +++ b/chart/chart_brush_test.go @@ -14,14 +14,14 @@ type brushHarness struct { value [2]float64 changes int disabled bool - el *ui.Element + bounds ui.Rect } func (h *brushHarness) view(c *ui.Context) { core.Use(c, core.Settings{Mode: core.Light}) ui.Column(c).Padding(10).Width(420).Children(func() { r := ChartBrush(c, &h.value, ChartBrushOptions{Label: "Months", Min: 0, Max: 100, Step: 1, Disabled: h.disabled}) - h.el = r.Element + h.bounds = r.Element.Bounds() if r.Changed() { h.changes++ } @@ -57,7 +57,8 @@ func TestChartBrushKeyboard(t *testing.T) { func TestChartBrushDrag(t *testing.T) { h := &brushHarness{value: [2]float64{20, 40}} tt := ui.NewTester(h.view, 460, 200) - b := h.el.Bounds() + tt.Frame() // Bounds reads the previous frame + b := h.bounds x := func(v float32) float32 { return b.X + 400*v/100 } y := b.Y + 24 tt.Press(x(30), y) // inside the window @@ -86,7 +87,8 @@ func TestChartBrushDrag(t *testing.T) { func TestChartBrushDisabledAndBad(t *testing.T) { h := &brushHarness{value: [2]float64{20, 40}, disabled: true} tt := ui.NewTester(h.view, 460, 200) - b := h.el.Bounds() + tt.Frame() // Bounds reads the previous frame + b := h.bounds tt.Press(b.X+120, b.Y+24) tt.Move(b.X+200, b.Y+24) tt.Release(b.X+200, b.Y+24) @@ -128,7 +130,8 @@ func TestChartBrushSnapshot(t *testing.T) { func TestChartBrushWindowDragPreservesSpanAtEdges(t *testing.T) { h := &brushHarness{value: [2]float64{20, 60}} tt := ui.NewTester(h.view, 460, 200) - b := h.el.Bounds() + tt.Frame() // Bounds reads the previous frame + b := h.bounds tt.Press(b.X+160, b.Y+24) tt.Move(b.X+380, b.Y+24) tt.Release(b.X+380, b.Y+24) diff --git a/chart/chart_crosshair_test.go b/chart/chart_crosshair_test.go index 8b16cc9..ce618f5 100644 --- a/chart/chart_crosshair_test.go +++ b/chart/chart_crosshair_test.go @@ -14,7 +14,7 @@ import ( func TestChartCrosshairSnapsAndFollowsKeys(t *testing.T) { h := &chartHarness{build: func(c *ui.Context) ChartResult { return partsChart(c, nil, true) }} tt := ui.NewTester(h.view, 520, 420) - b := h.res.Element.Bounds() + b := h.box(tt) plot := ChartInset(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: 240}, 52, 26) // Point 1 is ten DIPs from the pointer. px, py := plot.X+plot.W/2, plot.Y+plot.H*0.2 diff --git a/chart/chart_empty_state_test.go b/chart/chart_empty_state_test.go index 16f4313..911b5bc 100644 --- a/chart/chart_empty_state_test.go +++ b/chart/chart_empty_state_test.go @@ -13,30 +13,33 @@ func TestChartEmptyStateKeepsSizeAndRetries(t *testing.T) { status := data.DataReady retries, disabled := 0, false var heights []float32 - var el *ui.Element + var box ui.Rect tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) ui.Column(c).Width(400).Disabled(disabled).Children(func() { r := ChartEmptyState(c, status, ChartEmptyStateOptions{Height: 180, Empty: "No hymns this week", Error: "The choir did not answer"}) - el = r.Element + box = r.Element.Bounds() if r.Changed() { retries++ } }) }, 440, 300) - heights = append(heights, el.Bounds().H) + tt.Frame() // Bounds reads the previous frame + heights = append(heights, box.H) if !tt.HasText("No hymns this week") { t.Fatalf("empty: %q", tt.Texts()) } status = data.DataLoading tt.Frame() - heights = append(heights, el.Bounds().H) + tt.Frame() // Bounds reads the previous frame + heights = append(heights, box.H) if !tt.HasText("Loading") { t.Fatalf("loading: %q", tt.Texts()) } status = data.DataFailed tt.Frame() - heights = append(heights, el.Bounds().H) + tt.Frame() // Bounds reads the previous frame + heights = append(heights, box.H) if err := tt.Click("Retry"); err != nil || retries != 1 || !tt.HasText("The choir did not answer") { t.Fatalf("retry: %v %d", err, retries) } diff --git a/chart/chart_frame.go b/chart/chart_frame.go index 6bfb7e9..67dfee3 100644 --- a/chart/chart_frame.go +++ b/chart/chart_frame.go @@ -26,7 +26,7 @@ type ChartFrame struct { // ChartResult is a built chart and this frame's events. type ChartResult struct { - Element *ui.Element + Element ui.Element // Point is the hovered or focused mark, -1 for none. Point int changed, retry bool @@ -69,7 +69,7 @@ type ChartSpec struct { // Tooltip writes mark i's tooltip; nil splits Readout at " · ". Tooltip func(i int) []ChartTooltipLine // Input takes the plot's other input, as a ChartAnnotation's drags. - Input func(plot *ui.Element, r ui.Rect) + Input func(plot ui.Element, r ui.Rect) } // Chart frames a custom chart: legend, focus, keys, readout, tooltip and empty states. @@ -120,10 +120,10 @@ func (ch *chart) Toggles(on bool) *map[string]bool { func (ch *chart) Hidden(name string) bool { return ch.st.hidden[name] } // Build lays out legend, plot and readout; returns the plot. -func (ch *chart) Build(spec ChartSpec) (ChartResult, *ui.Element) { +func (ch *chart) Build(spec ChartSpec) (ChartResult, ui.Element) { c, k, st := ch.c, core.Tokens(ch.c), ch.st u := core.Density(c).Unit() - var plot *ui.Element + var plot ui.Element ch.Res.Element.Children(func() { if len(spec.Legend) > 0 { if name, ok := ChartLegend(c, spec.Hidden, spec.Legend, ChartLegendOptions{}).Toggled(); ok { @@ -181,7 +181,7 @@ func (ch *chart) Build(spec ChartSpec) (ChartResult, *ui.Element) { } // input moves the keyboard mark, and takes clicks on marks. -func (ch *chart) input(plot *ui.Element, spec ChartSpec) { +func (ch *chart) input(plot ui.Element, spec ChartSpec) { st := ch.st if plot.IsDisabled() { return diff --git a/chart/chart_frame_test.go b/chart/chart_frame_test.go index 9483cab..e5af126 100644 --- a/chart/chart_frame_test.go +++ b/chart/chart_frame_test.go @@ -83,7 +83,7 @@ func TestChartsIgnoreInputWhenDisabled(t *testing.T) { tt.Key(0, ui.KeyEnter) tt.Key(0, ui.KeySpace) } - b := h.res.Element.Bounds() + b := h.box(tt) tt.Click("Under test") tt.ClickAt(b.X+b.W/2, b.Y+b.H/2) if h.res.Point != -1 || values != 0 { diff --git a/chart/chart_legend.go b/chart/chart_legend.go index 90e632b..f5c954f 100644 --- a/chart/chart_legend.go +++ b/chart/chart_legend.go @@ -29,7 +29,7 @@ type ChartLegendOptions struct { // ChartLegendResult exposes the element and legend events. type ChartLegendResult struct { - Element *ui.Element + Element ui.Element toggled string expanded, folded bool } @@ -68,7 +68,7 @@ func ChartLegend(c *ui.Context, hidden *map[string]bool, items []ChartLegendItem shown = items[:opts.MaxItems] } for _, it := range shown { - ui.Row(c).Key(it.Name).Children(func() { + ui.Row(c.Key(it.Name)).Children(func() { if name, ok := chartLegendEntry(c, hidden, it, res.Element.IsDisabled()); ok { res.toggled = name } @@ -100,7 +100,7 @@ func chartLegendEntry(c *ui.Context, hidden *map[string]bool, it ChartLegendItem k := core.Tokens(c) u := core.Density(c).Unit() on := hidden == nil || !(*hidden)[it.Name] - var e *ui.Element + var e ui.Element switched := false if hidden != nil { e = ui.CheckboxBase(c, &on).Disabled(disabled).Label(it.Name) diff --git a/chart/chart_tooltip.go b/chart/chart_tooltip.go index 0f088ea..1186f89 100644 --- a/chart/chart_tooltip.go +++ b/chart/chart_tooltip.go @@ -18,7 +18,7 @@ type ChartTooltipLine struct { } // ChartTooltip shows readouts beside a window point, avoiding edges. -func ChartTooltip(c *ui.Context, x, y float32, lines []ChartTooltipLine) *ui.Element { +func ChartTooltip(c *ui.Context, x, y float32, lines []ChartTooltipLine) ui.Element { if len(lines) == 0 { panic("mujica: ChartTooltip needs a line") } @@ -45,7 +45,7 @@ func ChartTooltip(c *ui.Context, x, y float32, lines []ChartTooltipLine) *ui.Ele } else { left = max(4, min(x-gap-w, ww-4-w)) } - var tip *ui.Element + var tip ui.Element ui.Overlay(c, func() { tip = ui.Column(c).Absolute().Left(left).Top(top).Size(w, h).Role(ui.RoleNone).PassThrough() core.OverlayStyle(c, tip, core.OverlayEnter(c, tip)) diff --git a/chart/chart_tooltip_test.go b/chart/chart_tooltip_test.go index 69a906e..d57b5dc 100644 --- a/chart/chart_tooltip_test.go +++ b/chart/chart_tooltip_test.go @@ -53,7 +53,7 @@ func TestChartTooltipFollowsFocusAndPointer(t *testing.T) { if tooltipShown(tt) { t.Fatal("tooltip stayed after Escape") } - b := h.res.Element.Bounds() + b := h.box(tt) tt.Move(b.X+b.W/2, b.Y+b.H/2) if !tooltipShown(tt) { t.Fatal("no tooltip under the pointer") diff --git a/chart/chord_diagram_test.go b/chart/chord_diagram_test.go index 9a45880..6b244f2 100644 --- a/chart/chord_diagram_test.go +++ b/chart/chord_diagram_test.go @@ -42,7 +42,7 @@ func TestChordDiagramReadout(t *testing.T) { t.Fatalf("chord %q", got) } tt.Key(0, ui.KeyEscape) - b := h.res.Element.Bounds() + b := h.box(tt) cx, cy, rad := b.X+b.W/2, b.Y+120, float32(240)/2-30 tt.Move(cx+(rad+6)*0.5, cy-(rad+6)*0.866) // 30°: inside the first arc if h.res.Point != 0 { @@ -95,8 +95,9 @@ func TestChordDiagramDenseDataBoundedAndDisabled(t *testing.T) { h := &chartHarness{build: func(c *ui.Context) ChartResult { return ChordDiagram(c, names, matrix, ChordDiagramOptions{Frame: ChartFrame{Label: "Dense chords"}}) }} + var shown *[]int + h.built = func(r ChartResult) { shown = ui.Local(r.Element, "mujica.chord.visible", func() []int { return nil }) } tt := ui.NewTester(h.view, 520, 420) - shown := ui.Local(h.res.Element, "mujica.chord.visible", func() []int { return nil }) if len(*shown) == 0 || len(*shown) > 360 { t.Fatalf("built %d chords", len(*shown)) } diff --git a/chart/drawing_toolbar.go b/chart/drawing_toolbar.go index 74a95cb..aef61a9 100644 --- a/chart/drawing_toolbar.go +++ b/chart/drawing_toolbar.go @@ -53,7 +53,7 @@ func DrawingToolbar(c *ui.Context, d *DrawingTools, opts DrawingToolbarOptions) for t := ToolSelect; t <= ToolText; t++ { name := fmt.Sprintf("%s (Alt+%d)", core.Msg(c, fmt.Sprintf("drawing_toolbar.tool%d", t)), t+1) on := d.Tool == t - b := ui.ButtonBase(c).Key(t).Size(theme.MinHitSize+4, theme.MinHitSize+4).Radius(theme.ControlRadius).Label(name).Tooltip(name).Disabled(d.Locked) + b := ui.ButtonBase(c.Key(t)).Size(theme.MinHitSize+4, theme.MinHitSize+4).Radius(theme.ControlRadius).Label(name).Tooltip(name).Disabled(d.Locked) internal.Ring(b, k.Focus, theme.ControlRadius) fg := k.Text switch { diff --git a/chart/drawing_tools.go b/chart/drawing_tools.go index cc838d2..5336756 100644 --- a/chart/drawing_tools.go +++ b/chart/drawing_tools.go @@ -126,7 +126,7 @@ func (d *DrawingTools) xy(m *mktCore, g mktGeom, pt DrawingPoint) (float32, floa } // input places, selects, moves, and deletes chart annotations. -func (d *DrawingTools) input(m *mktCore, plot *ui.Element, g mktGeom, x, y float32, over, held bool, dx, dy float32) bool { +func (d *DrawingTools) input(m *mktCore, plot ui.Element, g mktGeom, x, y float32, over, held bool, dx, dy float32) bool { if d.Tool < ToolSelect || d.Tool > ToolText { panic(fmt.Sprintf("mujica: unknown DrawingTool %d", d.Tool)) } diff --git a/chart/funnel_chart_test.go b/chart/funnel_chart_test.go index fffd0c3..da409fa 100644 --- a/chart/funnel_chart_test.go +++ b/chart/funnel_chart_test.go @@ -22,7 +22,7 @@ func TestFunnelChartReadout(t *testing.T) { t.Fatalf("readout %q", got) } tt.Key(0, ui.KeyEscape) - b := h.res.Element.Bounds() + b := h.box(tt) tt.Move(b.X+b.W/3, b.Y+4+232*0.6) // third stage band if got := chartReadout(tt); got != "Vows · 100 · 25.0% of the stage before · 10.0% of the first" { t.Fatalf("hover %q", got) diff --git a/chart/gauge_test.go b/chart/gauge_test.go index 870b15f..b32f7e3 100644 --- a/chart/gauge_test.go +++ b/chart/gauge_test.go @@ -28,7 +28,7 @@ func TestGaugeReadoutNamesSegment(t *testing.T) { tt.Key(0, ui.KeyEscape) for _, full = range []bool{false, true} { tt.Frame() - b := h.res.Element.Bounds() + b := h.box(tt) cx := b.X + b.W/2 var cy, rad float32 if full { diff --git a/chart/heatmap_chart_test.go b/chart/heatmap_chart_test.go index b99b9b5..c67fb4d 100644 --- a/chart/heatmap_chart_test.go +++ b/chart/heatmap_chart_test.go @@ -41,7 +41,7 @@ func TestHeatmapChartHover(t *testing.T) { return HeatmapChart(c, heatRows, heatCols, heatValues(), HeatmapChartOptions{Frame: ChartFrame{Label: "Hours"}}) }} tt := ui.NewTester(h.view, 520, 420) - b := h.res.Element.Bounds() + b := h.box(tt) plotW := b.W - 72 - 64 tt.Move(b.X+72+plotW*0.6, b.Y+4+212*0.2) // third column, first row if got := chartReadout(tt); got != "Mon · Vespers · 9" { diff --git a/chart/indicator_pane.go b/chart/indicator_pane.go index 2ebec4a..ba6e34d 100644 --- a/chart/indicator_pane.go +++ b/chart/indicator_pane.go @@ -44,7 +44,7 @@ type IndicatorPaneOptions struct { // IndicatorPaneResult reports the stack's events. type IndicatorPaneResult struct { - Element *ui.Element + Element ui.Element // Removed reports the closed pane; -1 means none. Removed int // Resized reports finished height changes, for saving. @@ -218,7 +218,7 @@ func indicatorPaneOne(c *ui.Context, candles []Candle, view *MarketView, spec In m.view = © } result, plot := ch.Build(spec2) - if plot != nil { + if plot.Valid() { m.input(plot) } return closed && !ch.Res.Element.IsDisabled(), result diff --git a/chart/indicator_selector.go b/chart/indicator_selector.go index ceb308f..f612802 100644 --- a/chart/indicator_selector.go +++ b/chart/indicator_selector.go @@ -50,7 +50,7 @@ func IndicatorSelector(c *ui.Context, overlays *[]IndicatorOverlay, panes *Indic continue } found++ - ui.Row(c).Key(name).AlignItems(ui.Center).Gap(u * 2).MinHeight(core.Density(c).ControlHeight()).Children(func() { + ui.Row(c.Key(name)).AlignItems(ui.Center).Gap(u * 2).MinHeight(core.Density(c).ControlHeight()).Children(func() { ui.Text(c, name).FontWeight(600).Width(56) where := "indicator_selector.pane" if indicatorInfo[kind].overlay { @@ -103,7 +103,7 @@ func IndicatorSelector(c *ui.Context, overlays *[]IndicatorOverlay, panes *Indic // indicatorSelectorActive edits parameters and reports removal of an indicator. func indicatorSelectorActive(c *ui.Context, u float32, key string, kind IndicatorKind, params *[]float64, changed *bool) bool { removed := false - ui.Row(c).Key(key).AlignItems(ui.Center).Gap(u * 2).Wrap().Children(func() { + ui.Row(c.Key(key)).AlignItems(ui.Center).Gap(u * 2).Wrap().Children(func() { ui.Text(c, kind.String()).FontWeight(600).Width(56) if len(kind.DefaultParams()) > 0 && IndicatorParamsField(c, kind, params).Changed() { *changed = true diff --git a/chart/interval_selector.go b/chart/interval_selector.go index 2195292..c99da2e 100644 --- a/chart/interval_selector.go +++ b/chart/interval_selector.go @@ -87,7 +87,7 @@ func IntervalSelector(c *ui.Context, value *Interval, favorites *[]Interval, opt // intervalSelectorRow shows a selectable period and its favorite toggle. func intervalSelectorRow(c *ui.Context, k theme.Tokens, u float32, iv Interval, on, fav bool, favorites *[]Interval) bool { chosen := false - ui.Row(c).Key(string(iv)).AlignItems(ui.Center).Gap(u).Children(func() { + ui.Row(c.Key(string(iv))).AlignItems(ui.Center).Gap(u).Children(func() { b := ui.ButtonBase(c).Grow(1).MinHeight(core.Density(c).ControlHeight()).Padding(0, u*2).Justify(ui.Start).Radius(theme.ControlRadius).Gap(u * 2) internal.Ring(b, k.Focus, theme.ControlRadius) if on { diff --git a/chart/line_chart_test.go b/chart/line_chart_test.go index 02105e8..8566688 100644 --- a/chart/line_chart_test.go +++ b/chart/line_chart_test.go @@ -14,15 +14,27 @@ import ( type chartHarness struct { res ChartResult + bounds ui.Rect changes int disabled bool build func(c *ui.Context) ChartResult + built func(r ChartResult) // reads the live element during the build +} + +// box is the plot's committed bounds: Bounds reads the previous frame, so one more frame settles it. +func (h *chartHarness) box(tt *ui.Tester) ui.Rect { + tt.Frame() + return h.bounds } func (h *chartHarness) view(c *ui.Context) { core.Use(c, core.Settings{Mode: core.Light}) ui.Column(c).Padding(16).Width(480).Disabled(h.disabled).Children(func() { h.res = h.build(c) + h.bounds = h.res.Element.Bounds() + if h.built != nil { + h.built(h.res) + } if h.res.Changed() { h.changes++ } @@ -84,7 +96,7 @@ func TestLineChartHoverAndToggle(t *testing.T) { return r }} tt := ui.NewTester(h.view, 520, 420) - b := h.res.Element.Bounds() + b := h.box(tt) // The plot spans 52..W-12; x 30 is three quarters across. px := b.X + 52 + (b.W-64)*0.75 tt.Move(px, b.Y+b.H/2) @@ -118,11 +130,11 @@ func TestLineChartTimeAxisAndStates(t *testing.T) { if got := chartReadout(tt); !strings.HasPrefix(got, "2026-10-05 10:00:00 · ") { t.Fatalf("time readout %q", got) } - heights = append(heights, h.res.Element.Bounds().H) + heights = append(heights, h.box(tt).H) for _, st := range []data.DataStatus{data.DataLoading, data.DataFailed} { status = st tt.Frame() - heights = append(heights, h.res.Element.Bounds().H) + heights = append(heights, h.box(tt).H) } if !tt.HasText("Retry") || heights[0] != heights[1] || heights[1] != heights[2] { t.Fatalf("states changed size: %v", heights) diff --git a/chart/market_heatmap.go b/chart/market_heatmap.go index 7911975..dc5aa28 100644 --- a/chart/market_heatmap.go +++ b/chart/market_heatmap.go @@ -127,7 +127,7 @@ func MarketHeatmap(c *ui.Context, path *[]string, root HeatNode, opts MarketHeat } } r, plot := ch.Build(spec) - if plot != nil && !plot.IsDisabled() && plot.Shortcut(0, ui.KeyBackspace) && len(*path) > 0 { + if plot.Valid() && !plot.IsDisabled() && plot.Shortcut(0, ui.KeyBackspace) && len(*path) > 0 { *path, res.value = (*path)[:len(*path)-1], true } res.Element, res.Point, res.changed, res.retry = r.Element, r.Point, r.changed, r.retry diff --git a/chart/market_heatmap_test.go b/chart/market_heatmap_test.go index 0ca2cb5..308bcfb 100644 --- a/chart/market_heatmap_test.go +++ b/chart/market_heatmap_test.go @@ -9,9 +9,9 @@ import ( func TestMarketHeatmapAreaDrillAndKeys(t *testing.T) { root := mktHeat() var path []string - var got ChartResult + built := false tt := chatTester(640, 400, func(c *ui.Context) { - got = MarketHeatmap(c, &path, root, MarketHeatmapOptions{Frame: ChartFrame{Label: "Market"}}) + built = MarketHeatmap(c, &path, root, MarketHeatmapOptions{Frame: ChartFrame{Label: "Market"}}).Element.Valid() }) tt.Key(0, ui.KeyTab) tt.Key(0, ui.KeyHome) @@ -26,7 +26,7 @@ func TestMarketHeatmapAreaDrillAndKeys(t *testing.T) { if len(path) != 0 { t.Fatalf("back %v", path) } - if got.Element == nil { + if !built { t.Fatal("element missing") } if root.total() != 9830 { diff --git a/chart/market_input.go b/chart/market_input.go index 9d8976e..898c1fb 100644 --- a/chart/market_input.go +++ b/chart/market_input.go @@ -12,7 +12,7 @@ import ( ) // input handles pan, zoom, alerts, drawings, and keyboard following. -func (m *mktCore) input(plot *ui.Element) { +func (m *mktCore) input(plot ui.Element) { if plot.IsDisabled() { return } @@ -20,7 +20,7 @@ func (m *mktCore) input(plot *ui.Element) { room := m.room() changed := false plot.HandleInput(func(ev ui.InputEvent) bool { - if ev.Kind != ui.InputScroll || plot.IsDisabled() || ev.DY == 0 { + if ev.Kind != ui.InputScroll || ev.DY == 0 { return false } f := 0.9 @@ -90,7 +90,7 @@ func (m *mktCore) input(plot *ui.Element) { } } -func (m *mktCore) alertsKeyboard(plot *ui.Element) { +func (m *mktCore) alertsKeyboard(plot ui.Element) { if m.opts.Alerts == nil || len(*m.opts.Alerts) == 0 || m.base != 0 { return } diff --git a/chart/market_large_test.go b/chart/market_large_test.go index 33d4229..74ea6ca 100644 --- a/chart/market_large_test.go +++ b/chart/market_large_test.go @@ -14,12 +14,14 @@ func TestDepthChartLargeDataBounded(t *testing.T) { bids[i] = DepthLevel{100 - float64(i)*0.0001, 1} asks[i] = DepthLevel{101 + float64(i)*0.0001, 2} } + var drawnAt *int var res ChartResult tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) res = DepthChart(c, bids, asks, DepthChartOptions{Frame: ChartFrame{Label: "Depth"}}) + drawnAt = ui.Local(res.Element, "mujica.depth.drawn", func() int { return 0 }) }, 640, 340) - drawn := *ui.Local(res.Element, "mujica.depth.drawn", func() int { return 0 }) + drawn := *drawnAt if drawn <= 0 || drawn > 4*640+8 { t.Fatalf("painted %d depth vertices", drawn) } @@ -35,12 +37,14 @@ func TestVolumeProfileLargeDataBounded(t *testing.T) { for i := range bins { bins[i] = ProfileBin{float64(i), float64(i + 1), float64(i%19 + 1)} } + var drawnAt *int var res ChartResult tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) res = VolumeProfile(c, bins, VolumeProfileOptions{Frame: ChartFrame{Label: "Profile"}}) + drawnAt = ui.Local(res.Element, "mujica.profile.drawn", func() int { return 0 }) }, 640, 340) - drawn := *ui.Local(res.Element, "mujica.profile.drawn", func() int { return 0 }) + drawn := *drawnAt if drawn <= 0 || drawn > 4*340+4 { t.Fatalf("painted %d profile bins", drawn) } diff --git a/chart/market_layers.go b/chart/market_layers.go index aa24a64..1e8df42 100644 --- a/chart/market_layers.go +++ b/chart/market_layers.go @@ -89,7 +89,7 @@ func CompareLegend(c *ui.Context, symbols *[]CompareSymbol) core.ChoiceResult { row := ui.Row(c).Wrap().Gap(u * 2).Role(ui.RoleList).Label(core.Msg(c, "compare_symbol.label")) row.Children(func() { for i, s := range *symbols { - ui.Row(c).Key(s.Name).AlignItems(ui.Center).Gap(u).Padding(0, 0, 0, u*2).Radius(6).Border(1, k.Border).Role(ui.RoleListItem).Label(s.Name).Children(func() { + ui.Row(c.Key(s.Name)).AlignItems(ui.Center).Gap(u).Padding(0, 0, 0, u*2).Radius(6).Border(1, k.Border).Role(ui.RoleListItem).Label(s.Name).Children(func() { col := pal[(i+1)%len(pal)] ui.Box(c).Size(10, 10).Draw(func(p *ui.Painter, r ui.Rect) { internal.Marker(p, i+1, r.X+5, r.Y+5, 9, col) }) ui.Text(c, s.Name).SingleLine() diff --git a/chart/market_review_regression_test.go b/chart/market_review_regression_test.go index e243906..9c7f8bd 100644 --- a/chart/market_review_regression_test.go +++ b/chart/market_review_regression_test.go @@ -133,15 +133,14 @@ func TestMarketMissingComparisonCache(t *testing.T) { for i := range s[0].Closes { s[0].Closes[i] = math.NaN() } - var r ChartResult + var memo *ChartMemoEntry tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) - r = CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "A"}, Compare: &s}) + r := CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "A"}, Compare: &s}) + memo = ui.Local(r.Element, "mujica.memo.compare.missing", func() ChartMemoEntry { return ChartMemoEntry{} }) }, 640, 320) - memo := ui.Local(r.Element, "mujica.memo.compare.missing", func() ChartMemoEntry { return ChartMemoEntry{} }) before := ChartID(memo.Val.([]float64)) tt.Frame() - memo = ui.Local(r.Element, "mujica.memo.compare.missing", func() ChartMemoEntry { return ChartMemoEntry{} }) after := ChartID(memo.Val.([]float64)) t.Logf("before=%s after=%s", before, after) if before != after { diff --git a/chart/market_states_test.go b/chart/market_states_test.go index c0ed67e..fef149f 100644 --- a/chart/market_states_test.go +++ b/chart/market_states_test.go @@ -25,6 +25,7 @@ func TestMarketChartsStatesDisabledAndBoundaries(t *testing.T) { status := data.DataReady disabled, empty := false, false var r ChartResult + var box ui.Rect tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{Density: theme.Compact}) ui.Column(c).Disabled(disabled).Width(600).Children(func() { @@ -75,6 +76,7 @@ func TestMarketChartsStatesDisabledAndBoundaries(t *testing.T) { } r = CandlestickChart(c, candles, &v, opts) } + box = r.Element.Bounds() }) }, 620, 350) for range 10 { @@ -105,13 +107,15 @@ func TestMarketChartsStatesDisabledAndBoundaries(t *testing.T) { } disabled = false tt.Frame() - height := r.Element.Bounds().H + tt.Frame() // Bounds reads the previous frame + height := box.H for _, st := range []data.DataStatus{data.DataLoading, data.DataFailed, data.DataReady} { status = st empty = st == data.DataReady tt.Frame() - if r.Point != -1 || r.Element.Bounds().H != height { - t.Fatalf("state %v: point %d height %v want %v", st, r.Point, r.Element.Bounds().H, height) + tt.Frame() + if r.Point != -1 || box.H != height { + t.Fatalf("state %v: point %d height %v want %v", st, r.Point, box.H, height) } } }) diff --git a/chart/multi_chart_layout.go b/chart/multi_chart_layout.go index 61916fd..18bba45 100644 --- a/chart/multi_chart_layout.go +++ b/chart/multi_chart_layout.go @@ -82,7 +82,7 @@ func MultiChartLayout(c *ui.Context, state *MultiChartState, opts MultiChartLayo grid := ui.Box(c).Grid().Columns(cols).Gap(u * 2).Height(h).FillWidth() grid.Children(func() { for i := range state.Count { - box := ui.Column(c).Key(i).MinWidth(0).MinHeight(0).Height((h - u*2*float32(rows-1)) / float32(rows)).Padding(u * 2). + box := ui.Column(c.Key(i)).MinWidth(0).MinHeight(0).Height((h - u*2*float32(rows-1)) / float32(rows)).Padding(u * 2). Background(k.Surface).Radius(theme.CardRadius).Clip().Role(ui.RoleGroup). Label(fmt.Sprintf(core.Msg(c, "multi_chart_layout.cell"), i+1, state.Cells[i].Symbol)) if i == state.Active && state.Count > 1 { diff --git a/chart/network_graph.go b/chart/network_graph.go index a6811c4..6408b6c 100644 --- a/chart/network_graph.go +++ b/chart/network_graph.go @@ -150,7 +150,7 @@ func NetworkGraph(c *ui.Context, s *NetworkGraphState, nodes []GraphNode, edges }) } res, plot := ch.Build(spec) - if plot == nil || plot.IsDisabled() { + if !plot.Valid() || plot.IsDisabled() { return res } hit := func(x, y float32) string { @@ -165,7 +165,7 @@ func NetworkGraph(c *ui.Context, s *NetworkGraphState, nodes []GraphNode, edges } // networkInput drags, pans, zooms and moves the focused node. -func networkInput(plot *ui.Element, s *NetworkGraphState, focus int, nodes []GraphNode, unit func(ui.Rect) float64, hit func(x, y float32) string) { +func networkInput(plot ui.Element, s *NetworkGraphState, focus int, nodes []GraphNode, unit func(ui.Rect) float64, hit func(x, y float32) string) { b := plot.Bounds() r := ui.Rect{W: b.W, H: b.H} move := func(id string, dx, dy float32) { @@ -187,8 +187,9 @@ func networkInput(plot *ui.Element, s *NetworkGraphState, focus int, nodes []Gra s.dragging = false slog.Debug("mujica: network_graph drag", "node", s.drag) } + disabled := plot.IsDisabled() plot.HandleInput(func(ev ui.InputEvent) bool { - if ev.Kind != ui.InputScroll || ev.DY == 0 || plot.IsDisabled() { + if ev.Kind != ui.InputScroll || ev.DY == 0 || disabled { return false } if ev.DY < 0 { diff --git a/chart/network_graph_test.go b/chart/network_graph_test.go index 28efa33..d5b6703 100644 --- a/chart/network_graph_test.go +++ b/chart/network_graph_test.go @@ -44,7 +44,7 @@ func TestNetworkGraphDragNodeAndPan(t *testing.T) { return NetworkGraph(c, &s, graphNodes, graphEdges, NetworkGraphOptions{Frame: ChartFrame{Label: "House"}}) }} tt := ui.NewTester(h.view, 520, 420) - b := h.res.Element.Bounds() + b := h.box(tt) // The 10×10 layout fits 240-48 DIPs, centered in the plot. cx, cy := b.X+b.W/2, b.Y+120 ax, ay := cx-96, cy-96 diff --git a/chart/ohlc_chart_test.go b/chart/ohlc_chart_test.go index 232078a..2d23287 100644 --- a/chart/ohlc_chart_test.go +++ b/chart/ohlc_chart_test.go @@ -14,6 +14,7 @@ func TestOHLCChartSharesDataAndDiffersInLook(t *testing.T) { view := MarketView{Count: 40} ohlc := true var res ChartResult + var st *mktState tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) if ohlc { @@ -21,11 +22,12 @@ func TestOHLCChartSharesDataAndDiffersInLook(t *testing.T) { } else { res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Bars"}}) } + st = mktState0(res) }, 600, 320) tt.Key(0, ui.KeyTab) tt.Key(0, ui.KeyEnd) - if res.Point != 59 || mktState0(res).drawn != 40 { - t.Fatalf("point %d drawn %d", res.Point, mktState0(res).drawn) + if res.Point != 59 || st.drawn != 40 { + t.Fatalf("point %d drawn %d", res.Point, st.drawn) } a := encode(t, tt.Image()) ohlc = false diff --git a/chart/parallel_coordinates.go b/chart/parallel_coordinates.go index 08fe657..330c805 100644 --- a/chart/parallel_coordinates.go +++ b/chart/parallel_coordinates.go @@ -137,7 +137,7 @@ func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []s } drag := ui.Local(ch.Res.Element, "mujica.pc.drag", func() pcDrag { return pcDrag{axis: -1} }) keyAxis := ui.Local(ch.Res.Element, "mujica.pc.axis", func() int { return 0 }) - spec.Input = func(plot *ui.Element, r ui.Rect) { + spec.Input = func(plot ui.Element, r ui.Rect) { if plot.IsDisabled() { *drag = pcDrag{axis: -1} return diff --git a/chart/parallel_coordinates_test.go b/chart/parallel_coordinates_test.go index ec95384..e2b416d 100644 --- a/chart/parallel_coordinates_test.go +++ b/chart/parallel_coordinates_test.go @@ -29,7 +29,7 @@ func TestParallelCoordinatesReadoutAndBrush(t *testing.T) { if got := chartReadout(tt); got != "Bede · Age 65 · Hymns 1 · Vigils no value" { t.Fatalf("readout %q", got) } - b := h.res.Element.Bounds() + b := h.box(tt) plot := ui.Rect{X: b.X + 36, Y: b.Y + 24, W: b.W - 72, H: 240 - 32} // Age runs 20..80 on the first axis: brush 30..55. y := func(v float32) float32 { return plot.Y + plot.H*(1-(v-20)/60) } @@ -58,7 +58,7 @@ func TestParallelCoordinatesHoverAndBad(t *testing.T) { return ParallelCoordinates(c, &filters, pcAxes, pcRows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Monks"}}) }} tt := ui.NewTester(h.view, 520, 420) - b := h.res.Element.Bounds() + b := h.box(tt) plot := ui.Rect{X: b.X + 36, Y: b.Y + 24, W: b.W - 72, H: 240 - 32} // Row 2 crosses this point between the first axes. tt.Move(plot.X+plot.W/8, plot.Y+plot.H*(1-0.53125)) @@ -121,8 +121,9 @@ func TestParallelCoordinatesLargeDataBounded(t *testing.T) { h := &chartHarness{build: func(c *ui.Context) ChartResult { return ParallelCoordinates(c, &filters, pcAxes, rows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Large parallel"}}) }} + var shown *[]int + h.built = func(r ChartResult) { shown = ui.Local(r.Element, "mujica.pc.visible", func() []int { return nil }) } tt := ui.NewTester(h.view, 520, 420) - shown := ui.Local(h.res.Element, "mujica.pc.visible", func() []int { return nil }) if len(*shown) == 0 || len(*shown) > 480 { t.Fatalf("built %d rows", len(*shown)) } diff --git a/chart/pie_chart_test.go b/chart/pie_chart_test.go index 6387a84..07bf6c1 100644 --- a/chart/pie_chart_test.go +++ b/chart/pie_chart_test.go @@ -34,7 +34,7 @@ func TestPieChartReadoutAndHover(t *testing.T) { if got := chartReadout(tt); got != "Other · 8 · 8.0%" { t.Fatalf("readout %q", got) } - b := h.res.Element.Bounds() + b := h.box(tt) plotTop := b.Y + 38 // below the one-line legend cy := plotTop + 120 tt.Move(b.X+b.W/2+60, cy-40) // upper right: the first slice diff --git a/chart/price_alert_line_test.go b/chart/price_alert_line_test.go index 3c1e84e..ee8dc08 100644 --- a/chart/price_alert_line_test.go +++ b/chart/price_alert_line_test.go @@ -13,18 +13,16 @@ func TestPriceAlertLineDragAndTrigger(t *testing.T) { view := MarketView{Count: 60} alerts := []PriceAlertLine{{ID: "a", Price: 100, Label: "Buy"}} var fired []string - var res ChartResult - _ = res + var st *mktState tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) - res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}, Alerts: &alerts, - OnAlert: func(a PriceAlertLine, last Candle) { fired = append(fired, a.ID) }}) + st = mktState0(CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}, Alerts: &alerts, + OnAlert: func(a PriceAlertLine, last Candle) { fired = append(fired, a.ID) }})) }, 800, 400) if !tt.HasText("BTC") { t.Fatal("not built") } // Drag the line from its own y, as painted. - st := mktState0(res) plot := st.plot y := st.ys.Map(100) tt.Press(plot.X+100, y) diff --git a/chart/radar_chart_test.go b/chart/radar_chart_test.go index 28f0b4c..d1ffb70 100644 --- a/chart/radar_chart_test.go +++ b/chart/radar_chart_test.go @@ -28,7 +28,7 @@ func TestRadarChartReadout(t *testing.T) { if got := chartReadout(tt); got != "Latin · Agnès 6 · Clare 9" { t.Fatalf("readout %q", got) } - b := h.res.Element.Bounds() + b := h.box(tt) cx, cy := b.X+b.W/2, b.Y+38+120+4 tt.Key(0, ui.KeyEscape) tt.Move(cx, cy-60) // straight up: the first axis diff --git a/chart/sankey_chart_test.go b/chart/sankey_chart_test.go index 5e7590c..7973182 100644 --- a/chart/sankey_chart_test.go +++ b/chart/sankey_chart_test.go @@ -47,7 +47,7 @@ func TestSankeyChartReadout(t *testing.T) { t.Fatalf("link %q", got) } tt.Key(0, ui.KeyEscape) - b := h.res.Element.Bounds() + b := h.box(tt) tt.Move(b.X+8+4, b.Y+10) // the first node, at the top left if got := chartReadout(tt); got != "Tithes · in 0 · out 60" { t.Fatalf("hover %q", got) diff --git a/chart/scatter_chart.go b/chart/scatter_chart.go index 845ff57..5149297 100644 --- a/chart/scatter_chart.go +++ b/chart/scatter_chart.go @@ -130,7 +130,7 @@ func ScatterChart(c *ui.Context, selected *[]ScatterKey, groups []ScatterGroup, } } res, plot := ch.Build(spec) - if plot != nil && !plot.IsDisabled() { + if plot.Valid() && !plot.IsDisabled() { x, y, _ := plot.PointerPosition() if _, _, held := plot.Dragged(); held { if !drag.on { diff --git a/chart/scatter_chart_test.go b/chart/scatter_chart_test.go index fc037a4..daf8003 100644 --- a/chart/scatter_chart_test.go +++ b/chart/scatter_chart_test.go @@ -47,7 +47,7 @@ func TestScatterChartBoxSelect(t *testing.T) { return ScatterChart(c, &sel, scatterGroups, ScatterChartOptions{Frame: ChartFrame{Label: "Choir"}}) }} tt := ui.NewTester(h.view, 520, 420) - b := h.res.Element.Bounds() + b := h.box(tt) top := b.Y + 38 // Nice domains: x 0..10 over 52..W-12; y 0..10 over top+8..top+214. x := func(v float32) float32 { return b.X + 52 + (b.W-64)*v/10 } diff --git a/chart/spark_bar_test.go b/chart/spark_bar_test.go index f33f140..7a79502 100644 --- a/chart/spark_bar_test.go +++ b/chart/spark_bar_test.go @@ -150,14 +150,14 @@ func TestSparkBarFiniteWidthAndStates(t *testing.T) { func TestSparkBarMinimumHitWidth(t *testing.T) { for _, values := range [][]float64{nil, {1}} { - var result ChartResult + var box ui.Rect tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) - result = SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "Bars"}}) + box = SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "Bars"}}).Element.Bounds() }, 200, 100) tt.Frame() - if result.Element.Bounds().W < 28 { - t.Fatalf("hit width %v", result.Element.Bounds().W) + if box.W < 28 { + t.Fatalf("hit width %v", box.W) } } } diff --git a/chart/sunburst.go b/chart/sunburst.go index d0c3102..cdf8412 100644 --- a/chart/sunburst.go +++ b/chart/sunburst.go @@ -146,7 +146,7 @@ func Sunburst(c *ui.Context, path *[]string, root ChartNode, opts SunburstOption ChartText(p, ChartValue(c, sum, opts.Format), cx, cy+2, core.FontSize(c, theme.BodySize), k.Text, 0.5, 0) } r, plot := ch.Build(spec) - if plot != nil && !plot.IsDisabled() && plot.Shortcut(0, ui.KeyBackspace) && len(*path) > 0 { + if plot.Valid() && !plot.IsDisabled() && plot.Shortcut(0, ui.KeyBackspace) && len(*path) > 0 { *path, res.value = (*path)[:len(*path)-1], true } res.Point, res.changed, res.retry = r.Point, r.changed, r.retry diff --git a/chart/sunburst_test.go b/chart/sunburst_test.go index e574acf..53a17c4 100644 --- a/chart/sunburst_test.go +++ b/chart/sunburst_test.go @@ -29,7 +29,7 @@ func TestSunburstReadoutAndDrill(t *testing.T) { if err := tt.Click("Abbey"); err != nil || len(path) != 0 { t.Fatalf("breadcrumb back: %v %v", err, path) } - b := h.res.Element.Bounds() + b := h.box(tt) plotTop := b.Y + b.H - 20 - 8 - 240 cx, cy := b.X+b.W/2, plotTop+120 tt.Key(0, ui.KeyEscape) diff --git a/chart/treemap.go b/chart/treemap.go index 920075b..14b017f 100644 --- a/chart/treemap.go +++ b/chart/treemap.go @@ -89,7 +89,7 @@ func Treemap(c *ui.Context, path *[]string, root ChartNode, opts TreemapOptions) } } r, plot := ch.Build(spec) - if plot != nil && !plot.IsDisabled() && plot.Shortcut(0, ui.KeyBackspace) && len(*path) > 0 { + if plot.Valid() && !plot.IsDisabled() && plot.Shortcut(0, ui.KeyBackspace) && len(*path) > 0 { *path, res.value = (*path)[:len(*path)-1], true } res.Element, res.Point, res.changed, res.retry = r.Element, r.Point, r.changed, r.retry diff --git a/chart/trend_indicator.go b/chart/trend_indicator.go index 3a0346d..074e29d 100644 --- a/chart/trend_indicator.go +++ b/chart/trend_indicator.go @@ -26,7 +26,7 @@ type TrendIndicatorOptions struct { } // TrendIndicator shows signed change, direction, and semantic color. -func TrendIndicator(c *ui.Context, change float64, opts TrendIndicatorOptions) *ui.Element { +func TrendIndicator(c *ui.Context, change float64, opts TrendIndicatorOptions) ui.Element { if opts.Precision < 0 || math.IsNaN(change) || math.IsInf(change, 0) { panic("mujica: TrendIndicator needs a number and Precision ≥ 0") } diff --git a/chart/violin_plot_test.go b/chart/violin_plot_test.go index 903c699..b3b931c 100644 --- a/chart/violin_plot_test.go +++ b/chart/violin_plot_test.go @@ -20,7 +20,7 @@ func TestViolinPlotReadout(t *testing.T) { t.Fatalf("readout %q", got) } tt.Key(0, ui.KeyEscape) - b := h.res.Element.Bounds() + b := h.box(tt) tt.Move(b.X+52+(b.W-64)/6, b.Y+120) if h.res.Point != 0 { t.Fatalf("hover %d", h.res.Point) @@ -62,13 +62,14 @@ func TestViolinPlotLargeDataAndDisabled(t *testing.T) { h := &chartHarness{build: func(c *ui.Context) ChartResult { return ViolinPlot(c, groups, ViolinPlotOptions{Frame: ChartFrame{Label: "Large violin"}}) }} + var shown *[]int + h.built = func(r ChartResult) { shown = ui.Local(r.Element, "mujica.violin.visible", func() []int { return nil }) } tt := ui.NewTester(h.view, 520, 420) tabTo(t, tt, "Large violin") tt.Key(0, ui.KeyRight) if chartReadout(tt) != "Sample · median 49.5 · quartiles 24.75–74.25 · 100000 values" { t.Fatalf("large readout %q", chartReadout(tt)) } - shown := ui.Local(h.res.Element, "mujica.violin.visible", func() []int { return nil }) if len(*shown) != 1 { t.Fatalf("built %d violins", len(*shown)) } diff --git a/chart/volume_chart.go b/chart/volume_chart.go index 8808351..78dffd1 100644 --- a/chart/volume_chart.go +++ b/chart/volume_chart.go @@ -74,6 +74,7 @@ func VolumeChart(c *ui.Context, candles []Candle, view *MarketView, opts VolumeC return strings.Join([]string{cd.Time.Format("2006-01-02 15:04"), core.Msg(c, "candlestick_chart.volume") + " " + data.StatisticFormat(cd.Volume, 0), core.TrendWord(c, core.TrendSign(cd.Close-cd.Open))}, " · ") } + st := m.st0() // painting runs after the build, when Local is gone spec.Draw = func(p *ui.Painter, r ui.Rect, active int) { plot, band, ys, ticks, step := geom(r) internal.AxisY(p, plot, ys, ticks, func(v float64) string { return volumeShort(v, step) }, size, k) @@ -99,14 +100,14 @@ func VolumeChart(c *ui.Context, candles []Candle, view *MarketView, opts VolumeC p.Fill(bar, col, 0) } } - m.st0().drawn = m.to - m.from + st.drawn = m.to - m.from if at >= m.from && at < m.to { x := internal.Snap(p, band.Center(at-m.from)) p.Line(x, plot.Y, x, plot.Y+plot.H, 1, k.TextMuted) } } res, plot := ch.Build(spec) - if plot != nil && !plot.IsDisabled() { + if plot.Valid() && !plot.IsDisabled() { room := m.room() switch { case plot.Shortcut(ui.Shift, ui.KeyLeft): diff --git a/chart/volume_chart_test.go b/chart/volume_chart_test.go index acef57b..de673ac 100644 --- a/chart/volume_chart_test.go +++ b/chart/volume_chart_test.go @@ -13,15 +13,17 @@ func TestVolumeChartSharesViewAndReads(t *testing.T) { candles := sampleCandles(100000) view := MarketView{Count: 60} var price, vol ChartResult + var volSt *mktState tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) ui.Column(c).Width(800).Children(func() { price = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Price"}}) vol = VolumeChart(c, candles, &view, VolumeChartOptions{Frame: ChartFrame{Label: "Volume"}}) + volSt = mktState0(vol) }) }, 820, 600) tt.Frame() - if d := mktState0(vol).drawn; d != 60 { + if d := volSt.drawn; d != 60 { t.Fatalf("volume drew %d bars", d) } tt.Key(0, ui.KeyTab) diff --git a/chat/artifact_card.go b/chat/artifact_card.go index 9930f16..704b2a8 100644 --- a/chat/artifact_card.go +++ b/chat/artifact_card.go @@ -38,7 +38,7 @@ func ArtifactCard(c *ui.Context, opts ArtifactCardOptions) core.ChoiceResult { k := core.Tokens(c) d := core.Density(c) kind := artifactKinds[opts.Kind] - var b *ui.Element + var b ui.Element ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { b = ui.ButtonBase(c).MinWidth(260).MaxWidth(420).MinHeight(56).Padding(d.Unit()*2, d.Unit()*3).Gap(d.Unit()*3). Justify(ui.Start).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder). diff --git a/chat/attachment_chip.go b/chat/attachment_chip.go index ffbd321..798be7e 100644 --- a/chat/attachment_chip.go +++ b/chat/attachment_chip.go @@ -28,7 +28,7 @@ type Attachment struct { // AttachmentChipResult carries the chip and requested action. type AttachmentChipResult struct { - Element *ui.Element + Element ui.Element removed, retried bool } diff --git a/chat/audio_message.go b/chat/audio_message.go index 4dc0c5b..f212995 100644 --- a/chat/audio_message.go +++ b/chat/audio_message.go @@ -26,7 +26,7 @@ type AudioMessageOptions struct { // AudioMessageResult is an AudioMessage's element and the user's requests this frame. type AudioMessageResult struct { - Element *ui.Element + Element ui.Element toggled bool seek time.Duration seeked bool diff --git a/chat/capability_cards.go b/chat/capability_cards.go index 211b8ed..a9531b1 100644 --- a/chat/capability_cards.go +++ b/chat/capability_cards.go @@ -26,7 +26,7 @@ type CapabilityCardsOptions struct { // CapabilityCardsView is built CapabilityCards and the example clicked this frame. type CapabilityCardsView struct { - Element *ui.Element + Element ui.Element // Example is the example prompt chosen, empty for none. Example string } @@ -49,7 +49,7 @@ func CapabilityCards(c *ui.Context, cards []CapabilityCard, opts CapabilityCards if card.Title == "" { panic("mujica: CapabilityCard needs a Title") } - box := ui.Column(c).Key(i).Gap(d.Unit()*2).Padding(d.Unit()*4).Background(k.Surface). + box := ui.Column(c.Key(i)).Gap(d.Unit()*2).Padding(d.Unit()*4).Background(k.Surface). Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).Role(ui.RoleGroup).Label(card.Title) box.Children(func() { ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { diff --git a/chat/capability_cards_test.go b/chat/capability_cards_test.go index 859a10e..bba7d2d 100644 --- a/chat/capability_cards_test.go +++ b/chat/capability_cards_test.go @@ -35,10 +35,10 @@ func TestCapabilityCardsExample(t *testing.T) { func TestCapabilityCardsColumnsAndDisabled(t *testing.T) { got := "" - var g *ui.Element + built := false tt := chatTester(900, 600, func(c *ui.Context) { v := CapabilityCards(c, chatCards(), CapabilityCardsOptions{Columns: 1, Disabled: true}) - g = v.Element + built = v.Element.Valid() if v.Example != "" { got = v.Example } @@ -51,7 +51,7 @@ func TestCapabilityCardsColumnsAndDisabled(t *testing.T) { } a, _ := tt.Find("Write") b, _ := tt.Find("Plan") - if b.Y <= a.Y || g == nil { + if b.Y <= a.Y || !built { t.Fatalf("one column should stack cards: %v %v", a, b) } } diff --git a/chat/citation_badge.go b/chat/citation_badge.go index 15e61c2..bc2683b 100644 --- a/chat/citation_badge.go +++ b/chat/citation_badge.go @@ -28,15 +28,19 @@ type CitationState struct { Active int hover int owner uint64 - seen time.Time - locate uint64 + // owned marks the hover owner built in this pass; painted, a paint since the last build. + owned, painted bool + locate uint64 } // lit reports whether source n is chosen or hovered anywhere. func (s *CitationState) lit(n int) bool { return n > 0 && (s.Active == n || s.hover == n) } // track refreshes pointer ownership for the current frame. -func (s *CitationState) track(c *ui.Context, e *ui.Element, n int) { +func (s *CitationState) track(c *ui.Context, e ui.Element, n int) { + if s.painted { // the first build after a paint starts a pass + s.painted, s.owned = false, false + } switch over := e.Hovered() && !e.IsDisabled(); { case over && s.hover != n: s.hover, s.owner = n, e.ID() @@ -46,12 +50,14 @@ func (s *CitationState) track(c *ui.Context, e *ui.Element, n int) { c.Invalidate() } if s.owner == e.ID() { - s.seen = c.Now() + s.owned = true } } -func (s *CitationState) expire(c *ui.Context, frame time.Time) { - if s.hover != 0 && s.seen != frame { +// expire drops a hover whose owner this pass did not build; it runs while painting. +func (s *CitationState) expire(c *ui.Context) { + s.painted = true + if s.hover != 0 && !s.owned { s.hover, s.owner = 0, 0 c.Invalidate() } @@ -73,9 +79,8 @@ func CitationBadge(c *ui.Context, n int, src Source, st *CitationState) core.Cho } else { b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border).TextColor(k.TextMuted) } - frame := c.Now() b.Draw(func(p *ui.Painter, r ui.Rect) { - st.expire(c, frame) + st.expire(c) if st.lit(n) { p.Fill(ui.Rect{X: r.X + 4, Y: r.Y + r.H - 4, W: r.W - 8, H: 2}, k.AccentText, 0) } @@ -96,7 +101,7 @@ const citationPreviewDelay = 500 * time.Millisecond // citationPreview displays a source after resting hover or focus. // Pass-through preserves badge clicks; Escape dismisses the preview. -func citationPreview(c *ui.Context, b *ui.Element, n int, src Source) { +func citationPreview(c *ui.Context, b ui.Element, n int, src Source) { st := ui.Local(b, "mujica.citation", func() overlay.TooltipState { return overlay.TooltipState{} }) size := ui.Local(b, "mujica.citation.size", func() ui.Rect { return ui.Rect{} }) if b.IsDisabled() || !b.Hovered() && !b.FocusVisible() { diff --git a/chat/citation_badge_test.go b/chat/citation_badge_test.go index 243ca1f..816e79d 100644 --- a/chat/citation_badge_test.go +++ b/chat/citation_badge_test.go @@ -50,8 +50,7 @@ func TestCitationBadgeActivatesAndPreviews(t *testing.T) { if a.st.hover != 3 { t.Fatalf("hover %d, want 3", a.st.hover) } - time.Sleep(600 * time.Millisecond) - tt.Frame() + tt.Advance(600 * time.Millisecond) if n := len(tt.Texts()); n == 0 || !tt.HasText("example.net") { t.Fatalf("no preview card: %q", tt.Texts()) } @@ -114,8 +113,7 @@ func TestCitationBadgeHitAreaAndDisabledPreview(t *testing.T) { t.Errorf("hit area %v, want at least 28x28", b) } tt.Key(0, ui.KeyTab) - time.Sleep(550 * time.Millisecond) - tt.Frame() + tt.Advance(550 * time.Millisecond) off = true tt.Frame() tt.Key(0, ui.KeyEnter) @@ -133,8 +131,7 @@ func TestCitationBadgePreviewFitsNarrowWindow(t *testing.T) { ui.Row(c).Padding(8).Children(func() { CitationBadge(c, 1, src, &st) }) }, 180, 120) tt.Key(0, ui.KeyTab) - time.Sleep(550 * time.Millisecond) - tt.Frame() + tt.Advance(550 * time.Millisecond) preview, ok := tt.Find("Source preview") if !ok || preview.X < 0 || preview.X+preview.W > 180 || preview.Y+preview.H > 120 { t.Fatalf("preview content outside window: %v found %v", preview, ok) @@ -147,12 +144,12 @@ func TestCitationBadgeRemovedOwnerClearsHover(t *testing.T) { tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) ui.Column(c).Children(func() { - ui.Row(c).Key("badge").Height(36).Children(func() { + ui.Row(c.Key("badge")).Height(36).Children(func() { if show { CitationBadge(c, 1, sourcesSample[0], &st) } }) - ui.Row(c).Key("card").Width(300).Children(func() { SourceCard(c, 1, sourcesSample[0], &st) }) + ui.Row(c.Key("card")).Width(300).Children(func() { SourceCard(c, 1, sourcesSample[0], &st) }) }) }, 400, 300) b, _ := tt.Find("Source 1: The Palace of Versailles") diff --git a/chat/code_block.go b/chat/code_block.go index a4540b7..6f4f446 100644 --- a/chat/code_block.go +++ b/chat/code_block.go @@ -16,7 +16,7 @@ import ( // CopyResult is a block's element and whether its copy action ran this frame. type CopyResult struct { - Element *ui.Element + Element ui.Element copied bool } @@ -83,7 +83,7 @@ func CodeBlock(c *ui.Context, code string, opts CodeBlockOptions) CopyResult { if st.collapsed { return } - var body *ui.Element + var body ui.Element if st.wrap { body = ui.Column(c).Padding(u*2, u*3).MinWidth(0) } else { diff --git a/chat/context_chips.go b/chat/context_chips.go index d3f09eb..59cd417 100644 --- a/chat/context_chips.go +++ b/chat/context_chips.go @@ -20,7 +20,7 @@ type ContextChipsOptions struct { // RemoveResult carries the element and removed item ID. type RemoveResult struct { - Element *ui.Element + Element ui.Element removed string } @@ -48,7 +48,7 @@ func ContextChips(c *ui.Context, items []ContextItem, opts ContextChipsOptions) shown = min(shown, limit) } for _, it := range items[:shown] { - ui.Row(c).Key(it.ID).Gap(u).AlignItems(ui.Center).Padding(0, 0, 0, u*2).MinHeight(core.Density(c).ControlHeight()). + ui.Row(c.Key(it.ID)).Gap(u).AlignItems(ui.Center).Padding(0, 0, 0, u*2).MinHeight(core.Density(c).ControlHeight()). Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleListItem). Children(func() { ui.Icon(c, icons.Must(it.Kind.icon())).Size(14, 14).Shrink(0).TextColor(k.AccentText) diff --git a/chat/context_mention_menu.go b/chat/context_mention_menu.go index 36b3ee5..e1938d1 100644 --- a/chat/context_mention_menu.go +++ b/chat/context_mention_menu.go @@ -53,7 +53,7 @@ type ContextMentionOptions struct { // ContextMentionResult carries the popup, query and chosen item. type ContextMentionResult struct { - Element *ui.Element + Element ui.Element query string queryChanged bool chosen ContextItem @@ -70,7 +70,7 @@ func (r ContextMentionResult) Chosen() (ContextItem, bool) { return r.chosen, r. // ContextMentionMenu groups results for the active mention query. // The caller filters results synchronously or asynchronously. func ContextMentionMenu(c *ui.Context, composer input.InputResult, value *string, items []ContextItem, opts ContextMentionOptions) ContextMentionResult { - if composer.Element == nil || composer.Input == nil || value == nil { + if !composer.Element.Valid() || !composer.Input.Valid() || value == nil { panic("mujica: ContextMentionMenu needs composer and value") } data := ui.Local(composer.Element, "mention-data", func() mentionMenuData { return mentionMenuData{} }) diff --git a/chat/context_window_meter.go b/chat/context_window_meter.go index 39ba47f..771e9d1 100644 --- a/chat/context_window_meter.go +++ b/chat/context_window_meter.go @@ -26,7 +26,7 @@ type ContextWindowMeterOptions struct { // ContextWindowMeter displays segmented usage and threshold warnings. // Palette colors and shaped legend markers identify segments. -func ContextWindowMeter(c *ui.Context, segments []ContextSegment, limit int, opts ContextWindowMeterOptions) *ui.Element { +func ContextWindowMeter(c *ui.Context, segments []ContextSegment, limit int, opts ContextWindowMeterOptions) ui.Element { if limit <= 0 || opts.Warn < 0 || opts.Warn > 1 { panic("mujica: ContextWindowMeter needs limit > 0 and 0 <= Warn <= 1") } diff --git a/chat/conversation_export.go b/chat/conversation_export.go index f707f18..c4d7321 100644 --- a/chat/conversation_export.go +++ b/chat/conversation_export.go @@ -146,7 +146,7 @@ type ConversationExportOptions struct { // ConversationExportView is a built ConversationExport and the export asked for. type ConversationExportView struct { - Element *ui.Element + Element ui.Element // Exported reports Content is ready for the caller to write. Exported bool Content string @@ -172,13 +172,13 @@ func ConversationExport(c *ui.Context, s *ConversationExportState, messages []Se v := ConversationExportView{Element: col, Format: s.Format} content := conversationExportContent(s, messages, opts) col.Children(func() { - input.FormField(c, core.Msg(c, "conversation_export.format"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, core.Msg(c, "conversation_export.format"), input.FormFieldOptions{}, func() ui.Element { return input.RadioGroup(c, &s.Format, []input.RadioItem[ExportFormat]{ {Value: ExportMarkdown, Label: "Markdown"}, {Value: ExportJSON, Label: "JSON"}, {Value: ExportText, Label: core.Msg(c, "conversation_export.text")}, }, input.RadioGroupOptions{Horizontal: true, Label: core.Msg(c, "conversation_export.format"), Disabled: opts.Disabled}).Element }) - input.FormField(c, core.Msg(c, "conversation_export.range"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, core.Msg(c, "conversation_export.range"), input.FormFieldOptions{}, func() ui.Element { return ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() { input.RadioGroup(c, &s.Range, []input.RadioItem[ExportRange]{ {Value: ExportAll, Label: fmt.Sprintf(core.Msg(c, "conversation_export.all"), len(messages))}, diff --git a/chat/conversation_item.go b/chat/conversation_item.go index 76bff67..ece5434 100644 --- a/chat/conversation_item.go +++ b/chat/conversation_item.go @@ -29,7 +29,7 @@ type ConversationDrag struct{ ID string } // ConversationItemView is a built ConversationItem and its requests. type ConversationItemView struct { - Element *ui.Element + Element ui.Element // Opened, Deleted, Exported and PinToggled are requests for the caller. Opened, Deleted, Exported, PinToggled bool // Renamed reports a confirmed new title, already in conv.Title. @@ -54,7 +54,7 @@ func ConversationItem(c *ui.Context, conv *ChatConversation, opts ConversationIt if today.IsZero() { today = core.DateOf(c.Now()) } - row := ui.Row(c).Key(conv.ID).FillWidth().MinHeight(d.ControlHeight()).Gap(d.Unit()).AlignItems(ui.Center). + row := ui.Row(c.Key(conv.ID)).FillWidth().MinHeight(d.ControlHeight()).Gap(d.Unit()).AlignItems(ui.Center). Radius(theme.ControlRadius).Disabled(opts.Disabled) v := ConversationItemView{Element: row} st := ui.Local(row, conversationItemKey{}, func() conversationItemState { return conversationItemState{} }) @@ -145,7 +145,7 @@ func conversationItemEditor(c *ui.Context, k theme.Tokens, conv *ChatConversatio } // conversationItemMenu is a borderless ellipsis button opening a menu. -func conversationItemMenu(c *ui.Context, label string, build func(m *overlay.PopupMenu)) *ui.Element { +func conversationItemMenu(c *ui.Context, label string, build func(m *overlay.PopupMenu)) ui.Element { b := core.IconAction(c, icons.Must("ellipsis"), label).Role(ui.RoleMenuButton).Shrink(0) open := ui.Local(b, conversationItemKey{}, func() bool { return false }) if b.IsDisabled() { diff --git a/chat/conversation_list.go b/chat/conversation_list.go index e7799d3..f941fef 100644 --- a/chat/conversation_list.go +++ b/chat/conversation_list.go @@ -54,7 +54,7 @@ type conversationListRow struct { // ConversationListView is a built ConversationList and this frame's events. type ConversationListView struct { - Element *ui.Element + Element ui.Element changed bool submitted bool } @@ -95,7 +95,7 @@ func ConversationList(c *ui.Context, s *ConversationListState, items []ChatConve s.rows.Header = func(i int) bool { return rows[i].item < 0 } s.rows.Label = func(i int) string { return conversationListTitle(c, items, rows[i]) } s.rows.Selected = cursor - var list *ui.Element + var list ui.Element wrap := ui.Column(c).MinHeight(0).Disabled(opts.Disabled) wrap.Children(func() { restore := data.DataSelectTheme(c, 0) diff --git a/chat/conversation_search.go b/chat/conversation_search.go index 083c6fe..5c71437 100644 --- a/chat/conversation_search.go +++ b/chat/conversation_search.go @@ -34,7 +34,7 @@ type ConversationSearchOptions struct { // ConversationSearchView is a built ConversationSearch and this frame's events. type ConversationSearchView struct { - Element *ui.Element + Element ui.Element // QueryChanged asks the caller to search for the new query. QueryChanged bool // Chosen is the hit opened this frame, or empty. diff --git a/chat/cost_estimator.go b/chat/cost_estimator.go index eae18cd..320f8f6 100644 --- a/chat/cost_estimator.go +++ b/chat/cost_estimator.go @@ -32,7 +32,7 @@ func EstimateCost(inputTokens, outputTokens int, inputPrice, outputPrice float64 } // CostEstimator shows token counts and cost at caller prices. -func CostEstimator(c *ui.Context, opts CostEstimatorOptions) *ui.Element { +func CostEstimator(c *ui.Context, opts CostEstimatorOptions) ui.Element { if opts.InputTokens < 0 || opts.OutputTokens < 0 { panic("mujica: CostEstimator token counts must not be negative") } diff --git a/chat/date_separator.go b/chat/date_separator.go index 9f44d1f..83f7926 100644 --- a/chat/date_separator.go +++ b/chat/date_separator.go @@ -18,7 +18,7 @@ type DateSeparatorOptions struct { } // DateSeparator marks where a day's messages begin, localized. -func DateSeparator(c *ui.Context, day core.Date, opts DateSeparatorOptions) *ui.Element { +func DateSeparator(c *ui.Context, day core.Date, opts DateSeparatorOptions) ui.Element { if !day.Valid() { panic("mujica: DateSeparator needs a valid date") } diff --git a/chat/drag_drop_overlay.go b/chat/drag_drop_overlay.go index 52266de..46f3824 100644 --- a/chat/drag_drop_overlay.go +++ b/chat/drag_drop_overlay.go @@ -24,7 +24,7 @@ type DragDropOverlayOptions struct { // DropResult carries the overlay and dropped files. type DropResult struct { - Element *ui.Element + Element ui.Element accepted []string rejected []string } diff --git a/chat/error_message.go b/chat/error_message.go index 59eaf97..18d769b 100644 --- a/chat/error_message.go +++ b/chat/error_message.go @@ -24,7 +24,7 @@ type ErrorMessageOptions struct { // ErrorMessageResult is the error's element and a retry the user asked for. type ErrorMessageResult struct { - Element *ui.Element + Element ui.Element retried bool } diff --git a/chat/feedback_form.go b/chat/feedback_form.go index 87af5d1..b5a7840 100644 --- a/chat/feedback_form.go +++ b/chat/feedback_form.go @@ -38,7 +38,7 @@ type FeedbackFormOptions struct { // FeedbackFormView is a built FeedbackForm and this frame's outcome. type FeedbackFormView struct { - Element *ui.Element + Element ui.Element Submitted, Cancelled bool } diff --git a/chat/file_message.go b/chat/file_message.go index 2e64fa9..66f423d 100644 --- a/chat/file_message.go +++ b/chat/file_message.go @@ -26,7 +26,7 @@ type FileMessageOptions struct { // FileMessageResult is a FileMessage's element and this frame's actions. type FileMessageResult struct { - Element *ui.Element + Element ui.Element opened, downloaded bool } diff --git a/chat/image_grid.go b/chat/image_grid.go index f610fb6..c128aad 100644 --- a/chat/image_grid.go +++ b/chat/image_grid.go @@ -23,7 +23,7 @@ type GridImage struct { // ImageGridResult is an ImageGrid's element and a retry the user asked for. type ImageGridResult struct { - Element *ui.Element + Element ui.Element retry int } @@ -71,7 +71,7 @@ func ImageGrid(c *ui.Context, images []GridImage) ImageGridResult { for i, im := range images { loaded := im.Source != nil && !im.Loading && im.Err == nil // Only a loaded picture is a button; a failed one keeps its retry. - var b *ui.Element + var b ui.Element if loaded { b = ui.ButtonBase(c).Label(im.Alt) internal.Ring(b, k.Focus, theme.ControlRadius) diff --git a/chat/link_preview_card.go b/chat/link_preview_card.go index 92a5fad..e8e554b 100644 --- a/chat/link_preview_card.go +++ b/chat/link_preview_card.go @@ -18,7 +18,7 @@ type LinkPreview struct { } // LinkPreviewCard shows a link's site, title, description and thumbnail; Clicked opens it. -func LinkPreviewCard(c *ui.Context, p LinkPreview) *ui.Element { +func LinkPreviewCard(c *ui.Context, p LinkPreview) ui.Element { if p.URL == "" || p.Title == "" { panic("mujica: LinkPreviewCard needs URL and Title") } diff --git a/chat/markdown_view.go b/chat/markdown_view.go index a809c60..10294f9 100644 --- a/chat/markdown_view.go +++ b/chat/markdown_view.go @@ -13,7 +13,7 @@ import ( // MarkdownResult is a MarkdownView's element and the link the user opened this frame. type MarkdownResult struct { - Element *ui.Element + Element ui.Element link string } @@ -32,7 +32,7 @@ func MarkdownView(c *ui.Context, src string) MarkdownResult { r.Element.Children(func() { for i := range (*doc).Blocks { b := &(*doc).Blocks[i] - ui.Column(c).Key(i).MinWidth(0).Children(func() { + ui.Column(c.Key(i)).MinWidth(0).Children(func() { if url := markdownBlock(c, b); url != "" { r.link = url } @@ -96,7 +96,7 @@ func markdownBlock(c *ui.Context, b *internal.MdBlock) string { } // markdownSpans fills paragraph t with styled spans; links are focusable and return their URL when used. -func markdownSpans(c *ui.Context, t *ui.Element, spans []internal.MdSpan) string { +func markdownSpans(c *ui.Context, t ui.Element, spans []internal.MdSpan) string { k := core.Tokens(c) var link string t.MinWidth(0).Children(func() { diff --git a/chat/message_actions.go b/chat/message_actions.go index c36d1aa..d64eb55 100644 --- a/chat/message_actions.go +++ b/chat/message_actions.go @@ -39,7 +39,7 @@ type MessageActionsOptions struct { // MessageActionsResult is the toolbar's element and the action chosen this frame. type MessageActionsResult struct { - Element *ui.Element + Element ui.Element action MessageAction } diff --git a/chat/message_bubble.go b/chat/message_bubble.go index 6e24c4c..6be7996 100644 --- a/chat/message_bubble.go +++ b/chat/message_bubble.go @@ -43,7 +43,7 @@ type MessageBubbleOptions struct { } // MessageBubble frames one chat message by role: users right, others left, system centered. -func MessageBubble(c *ui.Context, role MessageRole, opts MessageBubbleOptions, content func()) *ui.Element { +func MessageBubble(c *ui.Context, role MessageRole, opts MessageBubbleOptions, content func()) ui.Element { if opts.MaxWidth < 0 { panic("mujica: MessageBubble MaxWidth must not be negative") } diff --git a/chat/message_editor.go b/chat/message_editor.go index 2c5c795..fafb2b7 100644 --- a/chat/message_editor.go +++ b/chat/message_editor.go @@ -18,7 +18,7 @@ type MessageEditorOptions struct { // MessageEditorResult is the editor's element and its outcome this frame. type MessageEditorResult struct { - Element *ui.Element + Element ui.Element saved, canceled bool } diff --git a/chat/message_header.go b/chat/message_header.go index dc9e3b1..a818f48 100644 --- a/chat/message_header.go +++ b/chat/message_header.go @@ -30,7 +30,7 @@ type MessageHeaderOptions struct { } // MessageHeader shows a message's model, time and status as icon and text. -func MessageHeader(c *ui.Context, opts MessageHeaderOptions) *ui.Element { +func MessageHeader(c *ui.Context, opts MessageHeaderOptions) ui.Element { k := core.Tokens(c) u := core.Density(c).Unit() small := core.FontSize(c, theme.CaptionSize) diff --git a/chat/message_list.go b/chat/message_list.go index 8ef16c2..1939b76 100644 --- a/chat/message_list.go +++ b/chat/message_list.go @@ -35,7 +35,7 @@ type MessageListOptions struct { // MessageListResult is a MessageList's element and its unread count. type MessageListResult struct { - Element *ui.Element + Element ui.Element unread int } diff --git a/chat/mode_selector.go b/chat/mode_selector.go index c80d910..952090a 100644 --- a/chat/mode_selector.go +++ b/chat/mode_selector.go @@ -44,7 +44,7 @@ func ModeSelector(c *ui.Context, mode *ChatMode, opts ModeSelectorOptions) core. k, u := core.Tokens(c), core.Density(c).Unit() old, pick := *mode, int(*mode) wrapper := ui.Row(c).Shrink(0).Disabled(opts.Disabled) - var track *ui.Element + var track ui.Element wrapper.Children(func() { parts := ui.SegmentedBase(c, &pick, len(chatModes)) track = parts.Track.Padding(2).Gap(2).Radius(theme.ControlRadius+2).Background(k.Background). diff --git a/chat/model_selector.go b/chat/model_selector.go index 88a0bc7..fcf458b 100644 --- a/chat/model_selector.go +++ b/chat/model_selector.go @@ -44,7 +44,7 @@ func ModelSelector(c *ui.Context, value *string, models []ModelOption, opts Mode } } k, u := core.Tokens(c), core.Density(c).Unit() - var tr *ui.Element + var tr ui.Element ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { tr = ui.ButtonBase(c).Role(ui.RolePopUpButton) }) st := ui.Local(tr, "model-selector", func() modelSelectorState { return modelSelectorState{hi: -1, lastHi: -1} }) disabled := tr.IsDisabled() @@ -120,7 +120,7 @@ func ModelSelector(c *ui.Context, value *string, models []ModelOption, opts Mode }) if st.open { ww, wh := c.Size() - ui.PopoverBase(c, tr, &st.open, func(panel *ui.Element) { + ui.PopoverBase(c, tr, &st.open, func(panel ui.Element) { core.OverlayStyle(c, panel, overlay.OverlayAnchored(c, panel, tr.Bounds())) a := tr.Bounds() room := max(a.Y, wh-a.Y-a.H) - 8 diff --git a/chat/parameter_panel.go b/chat/parameter_panel.go index 79fc87a..5a17355 100644 --- a/chat/parameter_panel.go +++ b/chat/parameter_panel.go @@ -62,7 +62,7 @@ func ParameterPanel(c *ui.Context, params []ModelParam) core.ChoiceResult { format := func(v float64) string { return strconv.FormatFloat(v, 'f', input.SliderDecimals(p.Min, p.Step, p.Default), 64) } - ui.Column(c).Key(p.Label).Gap(u).Children(func() { + ui.Column(c.Key(p.Label)).Gap(u).Children(func() { ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { ui.Text(c, p.Label).FontWeight(600).Grow(1).SingleLine() ui.Text(c, format(*p.Value)).FontFeatures("tnum").TextColor(k.AccentText).SingleLine() diff --git a/chat/paste_image_preview.go b/chat/paste_image_preview.go index 1c96763..fd09ade 100644 --- a/chat/paste_image_preview.go +++ b/chat/paste_image_preview.go @@ -35,7 +35,7 @@ func PasteImagePreview(c *ui.Context, images []PastedImage) RemoveResult { if im.ID == "" || im.Source == nil { panic("mujica: PastedImage needs ID and Source") } - ui.Box(c).Key(im.ID).Size(pasteImageSize, pasteImageSize).Role(ui.RoleListItem).Children(func() { + ui.Box(c.Key(im.ID)).Size(pasteImageSize, pasteImageSize).Role(ui.RoleListItem).Children(func() { ui.Image(c, im.Source).Fit(ui.Cover).Size(pasteImageSize, pasteImageSize).Radius(theme.ControlRadius). Border(theme.BorderWidth, k.Border).Label(im.Name) x := core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "paste_image_preview.remove"), im.Name)). diff --git a/chat/project_knowledge_panel.go b/chat/project_knowledge_panel.go index e87bf6b..2662d48 100644 --- a/chat/project_knowledge_panel.go +++ b/chat/project_knowledge_panel.go @@ -30,7 +30,7 @@ type ProjectKnowledgePanelOptions struct { // ProjectKnowledgePanelView is a built panel and its requests. type ProjectKnowledgePanelView struct { - Element *ui.Element + Element ui.Element // Add asks the caller to pick files to add. Add bool // Removed is the ID of a file to remove. @@ -73,7 +73,7 @@ func ProjectKnowledgePanel(c *ui.Context, files []KnowledgeFile, opts ProjectKno } ui.Column(c).Gap(2).FocusGroup(ui.Vertical).Children(func() { for _, f := range files { - ui.Row(c).Key(f.ID).MinHeight(d.ControlHeight()).Gap(d.Unit()*2).AlignItems(ui.Center).Padding(0, d.Unit()).Children(func() { + ui.Row(c.Key(f.ID)).MinHeight(d.ControlHeight()).Gap(d.Unit()*2).AlignItems(ui.Center).Padding(0, d.Unit()).Children(func() { ui.Icon(c, icons.Must("file-text")).Size(16, 16).TextColor(k.TextMuted) ui.Text(c, f.Name).SingleLine().Grow(1).MinWidth(0) ui.Text(c, fmt.Sprintf(core.Msg(c, "project_knowledge_panel.tokens"), f.Tokens)). diff --git a/chat/project_list.go b/chat/project_list.go index ca9233b..3212ce4 100644 --- a/chat/project_list.go +++ b/chat/project_list.go @@ -35,7 +35,7 @@ type ProjectListState struct { // ProjectListView is a built ProjectList and this frame's events. type ProjectListView struct { - Element *ui.Element + Element ui.Element // Changed reports a newly chosen project, in State.Selected. Changed bool // Opened is a conversation the user opened, empty for none. @@ -64,7 +64,7 @@ func ProjectList(c *ui.Context, s *ProjectListState, projects []ChatProject, con } seen[p.Folder] = true open := !s.closed[p.Folder] - ui.Column(c).Key("folder:" + p.Folder).Children(func() { projectListFolder(c, p.Folder, &open) }) + ui.Column(c.Key("folder:" + p.Folder)).Children(func() { projectListFolder(c, p.Folder, &open) }) s.closed[p.Folder] = !open if !open { continue @@ -94,7 +94,7 @@ func ProjectList(c *ui.Context, s *ProjectListState, projects []ChatProject, con } // projectListFolder is a folder heading on a CollapsibleBase trigger. -func projectListFolder(c *ui.Context, title string, open *bool) *ui.Element { +func projectListFolder(c *ui.Context, title string, open *bool) ui.Element { k := core.Tokens(c) d := core.Density(c) parts := ui.CollapsibleBase(c, open) @@ -121,8 +121,8 @@ func projectListProject(c *ui.Context, s *ProjectListState, p ChatProject, all [ d := core.Density(c) convs := conversations(p.ID) chosen := s.Selected == p.ID - var b *ui.Element - line := ui.Row(c).Key("project:" + p.ID).AlignItems(ui.Center) + var b ui.Element + line := ui.Row(c.Key("project:" + p.ID)).AlignItems(ui.Center) line.Children(func() { b = ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()) }) b.Padding(0, d.Unit()*2, 0, d.Unit()*2+indent).Padding(0, d.Unit()*2, 0, d.Unit()*2+indent). Gap(d.Unit() * 2).Justify(ui.Start).Radius(theme.ControlRadius).Role(ui.RoleTreeItem).Label(p.Name).Selected(chosen) @@ -185,7 +185,7 @@ func projectListProject(c *ui.Context, s *ProjectListState, p ChatProject, all [ func projectListConversation(c *ui.Context, conv ChatConversation, home ChatProject, all []ChatProject, indent float32, v *ProjectListView) { k := core.Tokens(c) d := core.Density(c) - ui.Row(c).Key("conv:" + conv.ID).AlignItems(ui.Center).Children(func() { + ui.Row(c.Key("conv:" + conv.ID)).AlignItems(ui.Center).Children(func() { b := ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2, 0, d.Unit()*2+indent). Justify(ui.Start).Radius(theme.ControlRadius).Label(conv.Title).Drag(ConversationDrag{ID: conv.ID}) internal.Ring(b, k.Focus, theme.ControlRadius) diff --git a/chat/prompt_composer.go b/chat/prompt_composer.go index c11fd97..6ab420a 100644 --- a/chat/prompt_composer.go +++ b/chat/prompt_composer.go @@ -53,6 +53,7 @@ func PromptComposer(c *ui.Context, value *string, opts PromptComposerOptions) in } var r input.InputResult r.Element = ui.Column(c).Gap(u * 2).MinWidth(0).Disabled(opts.Disabled) + disabled := r.Element.IsDisabled() hooks := ui.Local(r.Element, promptHooksKey{}, func() *promptHooks { return &promptHooks{} }) h := *hooks h.list = h.list[:0] @@ -62,7 +63,7 @@ func PromptComposer(c *ui.Context, value *string, opts PromptComposerOptions) in } ta := input.TextAreaOptions{Label: label, Placeholder: opts.Placeholder, MaxHeight: maxH, Keys: func(ev ui.InputEvent) bool { - if r.Element.IsDisabled() { + if disabled { return true } if opts.Composing && ev.Kind == ui.InputKeyDown { @@ -101,7 +102,7 @@ func PromptComposer(c *ui.Context, value *string, opts PromptComposerOptions) in // promptComposerHook registers a handler before editor input. func promptComposerHook(composer input.InputResult, f func(ui.InputEvent) bool) { - if composer.Element == nil { + if !composer.Element.Valid() { panic("mujica: menus need a built PromptComposer") } h := *ui.Local(composer.Element, promptHooksKey{}, func() *promptHooks { return &promptHooks{} }) diff --git a/chat/prompt_library.go b/chat/prompt_library.go index f442433..e3172ca 100644 --- a/chat/prompt_library.go +++ b/chat/prompt_library.go @@ -47,7 +47,7 @@ type promptLibraryShown struct { // PromptLibraryView is a built PromptLibrary and inserted text. type PromptLibraryView struct { - Element *ui.Element + Element ui.Element // Inserted is the filled template, or empty. Inserted string } @@ -157,7 +157,7 @@ func promptLibraryCategory(c *ui.Context, k theme.Tokens, s *PromptLibraryState, // selectableRow is a focusable row marked chosen with a diamond. func selectableRow(c *ui.Context, k theme.Tokens, key, label string, chosen bool) bool { d := core.Density(c) - b := ui.ButtonBase(c).Key(key).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).Justify(ui.Start). + b := ui.ButtonBase(c.Key(key)).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).Justify(ui.Start). Radius(theme.ControlRadius).Label(label).Selected(chosen) b.Background(ui.Transparent).TextColor(k.Text) internal.Ring(b, k.Focus, theme.ControlRadius) @@ -202,8 +202,8 @@ func promptLibraryPreview(c *ui.Context, k theme.Tokens, s *PromptLibraryState, Children(func() { ui.RichText(c, spans...).Selectable() }) for _, name := range vars { val := s.Values[name] - ui.Column(c).Key("var:" + name).Children(func() { - input.FormField(c, name, input.FormFieldOptions{Required: true}, func() *ui.Element { + ui.Column(c.Key("var:" + name)).Children(func() { + input.FormField(c, name, input.FormFieldOptions{Required: true}, func() ui.Element { r := input.TextInput(c, &val, input.TextInputOptions{Label: name, Disabled: disabled}) if r.Changed() { s.Values[name] = val diff --git a/chat/quote_reply.go b/chat/quote_reply.go index 36a705e..9a366fc 100644 --- a/chat/quote_reply.go +++ b/chat/quote_reply.go @@ -12,7 +12,7 @@ import ( ) // QuoteReply shows the author and a one-line excerpt of a quoted message; Clicked jumps to it. -func QuoteReply(c *ui.Context, author, excerpt string) *ui.Element { +func QuoteReply(c *ui.Context, author, excerpt string) ui.Element { if author == "" { panic("mujica: QuoteReply needs an author") } diff --git a/chat/regenerate_menu.go b/chat/regenerate_menu.go index 7119853..361a0ff 100644 --- a/chat/regenerate_menu.go +++ b/chat/regenerate_menu.go @@ -41,7 +41,7 @@ type RegenerateMenuOptions struct { // RegenerateMenuView is a built menu and the chosen retry. type RegenerateMenuView struct { - Element *ui.Element + Element ui.Element Chosen bool Choice RegenerateChoice } diff --git a/chat/scroll_to_bottom_button.go b/chat/scroll_to_bottom_button.go index e36d243..80428a9 100644 --- a/chat/scroll_to_bottom_button.go +++ b/chat/scroll_to_bottom_button.go @@ -13,7 +13,7 @@ import ( // ScrollToBottomButton shows while away is true, with the unread count; click it to return to the end. // While hidden it returns an empty element whose Clicked is false. -func ScrollToBottomButton(c *ui.Context, away bool, unread int) *ui.Element { +func ScrollToBottomButton(c *ui.Context, away bool, unread int) ui.Element { if unread < 0 { panic("mujica: ScrollToBottomButton unread must not be negative") } @@ -27,7 +27,7 @@ func ScrollToBottomButton(c *ui.Context, away bool, unread int) *ui.Element { label = fmt.Sprintf(core.Msg(c, "scroll_to_bottom_button.unread"), unread) } wrap := ui.Row(c).Absolute().Left(0).Right(0).Bottom(u * 3).Justify(ui.Center).PassThrough() - var b *ui.Element + var b ui.Element wrap.Children(func() { b = ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, u*3).Gap(u*2). Radius(theme.MinHitSize/2).Label(label).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder). diff --git a/chat/send_button.go b/chat/send_button.go index ab65886..835ca75 100644 --- a/chat/send_button.go +++ b/chat/send_button.go @@ -23,7 +23,7 @@ type SendButtonOptions struct { // SendResult carries the button and requested action. type SendResult struct { - Element *ui.Element + Element ui.Element send, stop bool } @@ -44,7 +44,7 @@ func SendButton(c *ui.Context, opts SendButtonOptions) SendResult { tip += " (" + opts.Shortcut + ")" } var r SendResult - var b *ui.Element + var b ui.Element r.Element = ui.Row(c).Shrink(0).Disabled(opts.Disabled && !opts.Streaming) r.Element.Children(func() { b = ui.ButtonBase(c).Size(h, h).Radius(h / 2).Label(label).Tooltip(tip).Transition(core.ColorFade) diff --git a/chat/slash_command_menu.go b/chat/slash_command_menu.go index 1099ec2..e5f9ee9 100644 --- a/chat/slash_command_menu.go +++ b/chat/slash_command_menu.go @@ -23,7 +23,7 @@ type SlashCommand struct { // SlashCommandResult carries the popup and chosen command. type SlashCommandResult struct { - Element *ui.Element + Element ui.Element chosen SlashCommand ok bool } @@ -34,7 +34,7 @@ func (r SlashCommandResult) Chosen() (SlashCommand, bool) { return r.chosen, r.o // SlashCommandMenu filters commands after a slash trigger. // Arrows navigate; Enter chooses; Tab completes; Escape dismisses. func SlashCommandMenu(c *ui.Context, composer input.InputResult, value *string, commands []SlashCommand) SlashCommandResult { - if composer.Element == nil || composer.Input == nil || value == nil { + if !composer.Element.Valid() || !composer.Input.Valid() || value == nil { panic("mujica: SlashCommandMenu needs composer and value") } data := ui.Local(composer.Element, "slash-data", func() slashMenuData { return slashMenuData{} }) @@ -116,7 +116,7 @@ const ( ) // slashMenuShow returns the selected row and activation method. -func slashMenuShow(c *ui.Context, composer input.InputResult, st *slashMenuState, query string, open bool, rows []slashMenuRow, o slashMenuOptions) (int, slashMenuHow, *ui.Element) { +func slashMenuShow(c *ui.Context, composer input.InputResult, st *slashMenuState, query string, open bool, rows []slashMenuRow, o slashMenuOptions) (int, slashMenuHow, ui.Element) { if !open { st.dismissed = nil } @@ -133,7 +133,7 @@ func slashMenuShow(c *ui.Context, composer input.InputResult, st *slashMenuState st.open = open if !open { st.pick = slashMenuNone - return pick, how, nil + return pick, how, ui.Element{} } st.hi = min(max(st.hi, 0), len(rows)-1) if st.pick != slashMenuNone && st.hi >= 0 { @@ -142,9 +142,6 @@ func slashMenuShow(c *ui.Context, composer input.InputResult, st *slashMenuState st.pick = slashMenuNone n := len(rows) promptComposerHook(composer, func(ev ui.InputEvent) bool { - if composer.Element.IsDisabled() { - return true - } if ev.Kind != ui.InputKeyDown || ev.Mods != 0 { return false } @@ -178,7 +175,7 @@ func slashMenuShow(c *ui.Context, composer input.InputResult, st *slashMenuState } // slashMenuPanel builds visible rows and returns clicked choices. -func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMenuRow, o slashMenuOptions) (int, *ui.Element) { +func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMenuRow, o slashMenuOptions) (int, ui.Element) { k := core.Tokens(c) u := core.Density(c).Unit() rowH := core.Density(c).ControlHeight() @@ -199,7 +196,7 @@ func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMe w := min(max(a.W*0.6, 320), max(a.W, 200), max(0, ww-8)) x, y := core.PlaceOverlay(c, a, w, h, u) clicked, pressed := -1, false - var result *ui.Element + var result ui.Element ui.Overlay(c, func() { panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).Height(h).Role(ui.RoleList).Label(o.label) core.OverlayStyle(c, panel, overlay.OverlayAnchored(c, panel, a)) @@ -231,7 +228,7 @@ func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMe ui.Text(c, r.group).Height(headH).Padding(u*2, u*3, 0, u*3).FontSize(core.FontSize(c, theme.CaptionSize)). FontWeight(600).TextColor(k.TextMuted).Role(ui.RoleText) } - row := ui.Row(c).Key(i).MinHeight(rowH).Padding(0, u*3, 0, u*2).Gap(u * 2).AlignItems(ui.Center). + row := ui.Row(c.Key(i)).MinHeight(rowH).Padding(0, u*3, 0, u*2).Gap(u * 2).AlignItems(ui.Center). Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(r.label).Cursor(ui.CursorPointer).Selected(i == st.hi).TextColor(k.Text) if row.Clicked() { clicked = i diff --git a/chat/slash_command_menu_test.go b/chat/slash_command_menu_test.go index 21f3e3b..2080ace 100644 --- a/chat/slash_command_menu_test.go +++ b/chat/slash_command_menu_test.go @@ -130,18 +130,18 @@ func TestSlashCommandMenuNarrowAndLargeData(t *testing.T) { cmds[i] = SlashCommand{Name: fmt.Sprintf("cmd%d", i)} } text := "/" - var panel *ui.Element + shown, b := false, ui.Rect{} tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) p := PromptComposer(c, &text, PromptComposerOptions{Label: "Prompt"}) - panel = SlashCommandMenu(c, p, &text, cmds).Element + panel := SlashCommandMenu(c, p, &text, cmds).Element + shown, b = panel.Valid(), panel.Bounds() }, 180, 120) _ = tt.Click("Prompt") tt.Frame() - if panel == nil { + if !shown { t.Fatal("menu missing") } - b := panel.Bounds() if b.X < 0 || b.Y < 0 || b.X+b.W > 180 || b.Y+b.H > 120 { t.Errorf("popup outside window: %v", b) } diff --git a/chat/source_card.go b/chat/source_card.go index 3a09d94..2e8d40b 100644 --- a/chat/source_card.go +++ b/chat/source_card.go @@ -13,7 +13,7 @@ import ( // SourceCard displays source metadata; Clicked requests opening. // Selection and an edge mark identify active or hovered sources. -func SourceCard(c *ui.Context, n int, src Source, st *CitationState) *ui.Element { +func SourceCard(c *ui.Context, n int, src Source, st *CitationState) ui.Element { if n < 1 || st == nil || src.Title == "" || src.URL == "" { panic("mujica: SourceCard needs n >= 1, a state and a Source with Title and URL") } @@ -23,8 +23,7 @@ func SourceCard(c *ui.Context, n int, src Source, st *CitationState) *ui.Element Label(fmt.Sprintf(core.Msg(c, "source_card.open"), src.Title)).Tooltip(src.URL).Transition(core.ColorFade) internal.Ring(b, k.Focus, theme.CardRadius) st.track(c, b, n) - frame := c.Now() - b.Draw(func(_ *ui.Painter, _ ui.Rect) { st.expire(c, frame) }) + b.Draw(func(_ *ui.Painter, _ ui.Rect) { st.expire(c) }) switch { case st.lit(n): b.Background(k.Selection).Border(theme.BorderWidth, k.Border).BorderWidth(theme.BorderWidth, theme.BorderWidth, theme.BorderWidth, 3). diff --git a/chat/sources_panel.go b/chat/sources_panel.go index 0ff8d1b..1b5710b 100644 --- a/chat/sources_panel.go +++ b/chat/sources_panel.go @@ -12,7 +12,7 @@ import ( // OpenResult carries the list and opened 1-based source number. type OpenResult struct { - Element *ui.Element + Element ui.Element opened int } diff --git a/chat/stop_generating_button.go b/chat/stop_generating_button.go index 1bd267a..a57761a 100644 --- a/chat/stop_generating_button.go +++ b/chat/stop_generating_button.go @@ -30,7 +30,7 @@ func StopGeneratingButton(c *ui.Context, opts StopGeneratingButtonOptions) core. if runtime.GOOS == "darwin" { mods = ui.Cmd } - var b *ui.Element + var b ui.Element wrap := ui.Row(c).Shrink(0).Disabled(opts.Disabled) wrap.Children(func() { b = ui.ButtonBase(c).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3).Gap(d.Unit()*2). diff --git a/chat/streaming_text.go b/chat/streaming_text.go index 465ac64..54fd5d5 100644 --- a/chat/streaming_text.go +++ b/chat/streaming_text.go @@ -28,7 +28,7 @@ type streamingState struct { } // StreamingText shows text that grows as it streams; reduced motion shows it at once. -func StreamingText(c *ui.Context, text string, opts StreamingTextOptions) *ui.Element { +func StreamingText(c *ui.Context, text string, opts StreamingTextOptions) ui.Element { if opts.Rate < 0 { panic("mujica: StreamingText Rate must not be negative") } diff --git a/chat/suggestion_chips.go b/chat/suggestion_chips.go index 7c2178f..34be0ee 100644 --- a/chat/suggestion_chips.go +++ b/chat/suggestion_chips.go @@ -22,7 +22,7 @@ type SuggestionChipsOptions struct { // SuggestionChipsView is built chips and the suggestion used this frame. type SuggestionChipsView struct { - Element *ui.Element + Element ui.Element // Chosen is the suggestion clicked, empty for none. Chosen string // Sent asks to send Chosen now instead of filling. @@ -47,7 +47,7 @@ func SuggestionChips(c *ui.Context, input *string, suggestions []string, opts Su if s == "" { panic("mujica: SuggestionChips with an empty suggestion") } - b := ui.ButtonBase(c).Key(i).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3). + b := ui.ButtonBase(c.Key(i)).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3). Gap(d.Unit()*2).Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Label(s) internal.Ring(b, k.Focus, theme.ControlRadius) switch { diff --git a/chat/system_prompt_editor.go b/chat/system_prompt_editor.go index f1ed672..3fec61f 100644 --- a/chat/system_prompt_editor.go +++ b/chat/system_prompt_editor.go @@ -30,7 +30,7 @@ type SystemPromptEditorOptions struct { // SystemPromptEditorView is a built editor and its requests. type SystemPromptEditorView struct { - Element *ui.Element + Element ui.Element // Save asks to save *value; Restored reports the default returned. Save, Restored bool } diff --git a/chat/thinking_indicator.go b/chat/thinking_indicator.go index 1388148..726fc4f 100644 --- a/chat/thinking_indicator.go +++ b/chat/thinking_indicator.go @@ -17,7 +17,7 @@ type ThinkingIndicatorOptions struct { } // ThinkingIndicator shows rising diamonds and a phase, still when reduced. -func ThinkingIndicator(c *ui.Context, opts ThinkingIndicatorOptions) *ui.Element { +func ThinkingIndicator(c *ui.Context, opts ThinkingIndicatorOptions) ui.Element { k := core.Tokens(c) d := core.Density(c) phase := opts.Phase diff --git a/chat/thinking_indicator_test.go b/chat/thinking_indicator_test.go index 4885ac5..e8f44fe 100644 --- a/chat/thinking_indicator_test.go +++ b/chat/thinking_indicator_test.go @@ -27,8 +27,7 @@ func TestThinkingIndicatorReducedMotionIsStill(t *testing.T) { tt.SetPreferences(ui.Preferences{ReduceMotion: reduce, TextScale: 1}) tt.Frame() if at > 0 { - time.Sleep(at) - tt.Frame() + tt.Advance(at) } return encode(t, tt.Image()) } diff --git a/chat/token_counter.go b/chat/token_counter.go index 3cf6b95..83dceaa 100644 --- a/chat/token_counter.go +++ b/chat/token_counter.go @@ -15,7 +15,7 @@ import ( const tokenWarnShare = 0.9 // TokenCounter displays estimates, limits and threshold warnings. -func TokenCounter(c *ui.Context, used, limit int) *ui.Element { +func TokenCounter(c *ui.Context, used, limit int) ui.Element { if used < 0 || limit <= 0 { panic("mujica: TokenCounter needs used >= 0 and limit > 0") } diff --git a/chat/tool_toggle_menu.go b/chat/tool_toggle_menu.go index 9da9c8b..9b820ac 100644 --- a/chat/tool_toggle_menu.go +++ b/chat/tool_toggle_menu.go @@ -27,7 +27,7 @@ type ToolItem struct { // ToolToggleResult carries the trigger and requested action. type ToolToggleResult struct { - Element *ui.Element + Element ui.Element changed, authorize string } @@ -93,7 +93,7 @@ func toolToggleRow(c *ui.Context, t ToolItem, r *ToolToggleResult) { u := core.Density(c).Unit() small := core.FontSize(c, theme.CaptionSize) locked := t.NeedsAuth && !t.Authorized - ui.Row(c).Key(t.ID).Gap(u * 3).AlignItems(ui.Center).Children(func() { + ui.Row(c.Key(t.ID)).Gap(u * 3).AlignItems(ui.Center).Children(func() { ui.Column(c).Grow(1).MinWidth(0).Children(func() { ui.Row(c).Gap(u).AlignItems(ui.Center).MinHeight(core.Density(c).ControlHeight()).Children(func() { if locked { diff --git a/chat/typing_indicator.go b/chat/typing_indicator.go index 489203d..e929bf8 100644 --- a/chat/typing_indicator.go +++ b/chat/typing_indicator.go @@ -15,7 +15,7 @@ import ( const typingPeriod = 1200 // TypingIndicator shows that name is typing: three fading dots, or static text under reduced motion. -func TypingIndicator(c *ui.Context, name string) *ui.Element { +func TypingIndicator(c *ui.Context, name string) ui.Element { if name == "" { panic("mujica: TypingIndicator needs a name") } diff --git a/chat/voice_waveform.go b/chat/voice_waveform.go index a90e628..dab6afd 100644 --- a/chat/voice_waveform.go +++ b/chat/voice_waveform.go @@ -19,7 +19,7 @@ type VoiceWaveformOptions struct { } // VoiceWaveform draws recent input levels and a silence hint. -func VoiceWaveform(c *ui.Context, levels []float32, opts VoiceWaveformOptions) *ui.Element { +func VoiceWaveform(c *ui.Context, levels []float32, opts VoiceWaveformOptions) ui.Element { if opts.Silence < 0 || opts.Silence >= 1 || opts.SilentFor < 0 || opts.Width < 0 { panic("mujica: VoiceWaveform needs 0 <= Silence < 1 and non-negative SilentFor and Width") } diff --git a/code/breakpoint_list.go b/code/breakpoint_list.go index 551e24b..79e5c81 100644 --- a/code/breakpoint_list.go +++ b/code/breakpoint_list.go @@ -31,7 +31,7 @@ type BreakpointListOptions struct { // BreakpointListResult fields hold breakpoint IDs. type BreakpointListResult struct { - Element *ui.Element + Element ui.Element Toggled, Edited, Opened string } @@ -67,7 +67,7 @@ func BreakpointList(c *ui.Context, bps *[]Breakpoint, opts BreakpointListOptions layout.ScrollArea(c, nil, layout.ScrollAreaOptions{}, func() { for i := range *bps { b := &(*bps)[i] - ui.Column(c).Key(b.ID).Padding(u, u*2).Gap(u).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() { + ui.Column(c.Key(b.ID)).Padding(u, u*2).Gap(u).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() { ui.Row(c).MinHeight(d.ControlHeight()).Gap(u * 2).AlignItems(ui.Center).Children(func() { on := b.Enabled input.Checkbox(c, &on, "", input.CheckboxOptions{Disabled: opts.Disabled}).Label(fmt.Sprintf(core.Msg(c, "breakpoint_list.enable"), path.Base(b.File), b.Line)) @@ -86,7 +86,7 @@ func BreakpointList(c *ui.Context, bps *[]Breakpoint, opts BreakpointListOptions switch { case edit.id == b.ID: in := input.TextInput(c, &edit.draft, input.TextInputOptions{Placeholder: core.Msg(c, "breakpoint_list.placeholder"), Label: core.Msg(c, "breakpoint_list.condition")}) - in.Input.AutoFocus() + core.AutoFocus(c, in.Input) if in.Input.Shortcut(0, ui.KeyEscape) { edit.id = "" } else if in.Submitted() && !disabled { @@ -104,7 +104,7 @@ func BreakpointList(c *ui.Context, bps *[]Breakpoint, opts BreakpointListOptions } // breakpointLocation is a button opening the location. -func breakpointLocation(c *ui.Context, b Breakpoint) *ui.Element { +func breakpointLocation(c *ui.Context, b Breakpoint) ui.Element { k := core.Tokens(c) d := core.Density(c) e := ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(theme.MinHitSize).Justify(ui.Start).Gap(d.Unit()*2).Padding(0, d.Unit()). diff --git a/code/code_shared.go b/code/code_shared.go index 9c39ff2..eeab439 100644 --- a/code/code_shared.go +++ b/code/code_shared.go @@ -20,12 +20,12 @@ func codeFont(c *ui.Context) ui.Font { } // CodeMono is a single-line monospace text. -func CodeMono(c *ui.Context, s string) *ui.Element { +func CodeMono(c *ui.Context, s string) ui.Element { return ui.Text(c, s).Font(theme.MonoFont).FontSize(core.FontSize(c, CodeSize)).SingleLine() } // CodeCaption is muted caption text. -func CodeCaption(c *ui.Context, s string) *ui.Element { +func CodeCaption(c *ui.Context, s string) ui.Element { return ui.Text(c, s).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(core.Tokens(c).TextMuted).SingleLine() } @@ -116,13 +116,13 @@ func CodeMatchSpans(c *ui.Context, text, query string) []ui.Span { } // CodePanel is a framed surface holding a code tool. -func CodePanel(c *ui.Context) *ui.Element { +func CodePanel(c *ui.Context) ui.Element { k := core.Tokens(c) return ui.Column(c).MinHeight(0).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius).Clip() } // CodeHeader is a panel's top strip of tools. -func CodeHeader(c *ui.Context, fn func()) *ui.Element { +func CodeHeader(c *ui.Context, fn func()) ui.Element { k := core.Tokens(c) u := core.Density(c).Unit() return ui.Row(c).Shrink(0).MinHeight(theme.MinHitSize+2*u).Padding(u, u*2).Gap(u*2).AlignItems(ui.Center). @@ -140,7 +140,7 @@ func CodeSelectMark(c *ui.Context, on bool) { } // CodeRows builds a growing virtual list with marks. -func CodeRows(c *ui.Context, s *ui.ListState, n int, row func(i int)) *ui.Element { +func CodeRows(c *ui.Context, s *ui.ListState, n int, row func(i int)) ui.Element { d := core.Density(c) restore := data.DataSelectTheme(c, 0) defer restore() diff --git a/code/code_viewer.go b/code/code_viewer.go index 549e7fe..9bfc813 100644 --- a/code/code_viewer.go +++ b/code/code_viewer.go @@ -140,7 +140,7 @@ func CodeViewer(c *ui.Context, lines []string, s *CodeViewerState, opts CodeView } // codeViewerKeys copies the selection and folds with Left and Right. -func codeViewerKeys(c *ui.Context, list *ui.Element, lines []string, s *CodeViewerState) { +func codeViewerKeys(c *ui.Context, list ui.Element, lines []string, s *CodeViewerState) { if list.IsDisabled() { return } diff --git a/code/command_history.go b/code/command_history.go index 47d3813..7d3046b 100644 --- a/code/command_history.go +++ b/code/command_history.go @@ -38,7 +38,7 @@ type CommandHistoryOptions struct { // CommandHistoryResult carries the entry to replay. type CommandHistoryResult struct { - Element *ui.Element + Element ui.Element // Replayed is the entry to run again, or nil. Replayed *HistoryEntry } diff --git a/code/completion_menu.go b/code/completion_menu.go index 0864d72..551d807 100644 --- a/code/completion_menu.go +++ b/code/completion_menu.go @@ -34,7 +34,7 @@ type CompletionMenuOptions struct { // CompletionMenuResult carries the accepted candidate. type CompletionMenuResult struct { - Element *ui.Element + Element ui.Element Accepted *Completion } @@ -42,8 +42,8 @@ type CompletionMenuResult struct { const completionRows = 8 // CompletionMenu shows candidates under the editor anchor. -func CompletionMenu(c *ui.Context, anchor *ui.Element, open *bool, items []Completion, s *CompletionMenuState, opts CompletionMenuOptions) CompletionMenuResult { - if anchor == nil || open == nil || s == nil { +func CompletionMenu(c *ui.Context, anchor ui.Element, open *bool, items []Completion, s *CompletionMenuState, opts CompletionMenuOptions) CompletionMenuResult { + if !anchor.Valid() || open == nil || s == nil { panic("mujica: CompletionMenu needs an anchor, open and a state") } for _, it := range items { @@ -70,8 +70,9 @@ func CompletionMenu(c *ui.Context, anchor *ui.Element, open *bool, items []Compl s.rows.ScrollIntoView(s.Selected) } // Take the menu keys before the editor does. + disabled := anchor.IsDisabled() anchor.HandleInput(func(ev ui.InputEvent) bool { - if ev.Kind != ui.InputKeyDown || ev.Mods != 0 || !*open || anchor.IsDisabled() { + if ev.Kind != ui.InputKeyDown || ev.Mods != 0 || !*open || disabled { return false } switch ev.Key { @@ -100,7 +101,7 @@ func CompletionMenu(c *ui.Context, anchor *ui.Element, open *bool, items []Compl d := core.Density(c) b := anchor.Bounds() core.OverlayLayer(c, open) - res.Element = ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { + res.Element = ui.PopoverBase(c, anchor, open, func(panel ui.Element) { core.OverlayStyle(c, panel, overlay.OverlayAnchored(c, panel, b)) panel.Margin(4, 0, 0, 0).Padding(d.Unit()).Width(overlay.PopoverWide(c, 360)).MaxHeight(overlay.PopoverRoom(c, b)). Label(core.Msg(c, "completion_menu.label")).Role(ui.RoleList) diff --git a/code/debug_toolbar.go b/code/debug_toolbar.go index fed4793..edec14d 100644 --- a/code/debug_toolbar.go +++ b/code/debug_toolbar.go @@ -42,7 +42,7 @@ type DebugToolbarOptions struct { // DebugToolbarResult carries the requested action. type DebugToolbarResult struct { - Element *ui.Element + Element ui.Element Action DebugAction } diff --git a/code/find_widget.go b/code/find_widget.go index 419c34d..dcecaea 100644 --- a/code/find_widget.go +++ b/code/find_widget.go @@ -50,7 +50,7 @@ type FindWidgetOptions struct { // FindWidgetResult is a FindWidget and what happened in this frame. type FindWidgetResult struct { - Element *ui.Element + Element ui.Element // Moved reports navigation; Replaced counts replacements. Moved bool Replaced int diff --git a/code/flamegraph.go b/code/flamegraph.go index 4309a6f..b05f678 100644 --- a/code/flamegraph.go +++ b/code/flamegraph.go @@ -109,7 +109,7 @@ func Flamegraph(c *ui.Context, root *FlameNode, s *FlamegraphState, opts Flamegr } // flameInput zooms, moves focus and tracks hover. -func flameInput(c *ui.Context, g *ui.Element, root *FlameNode, s *FlamegraphState, hover *[]int) bool { +func flameInput(c *ui.Context, g ui.Element, root *FlameNode, s *FlamegraphState, hover *[]int) bool { b := g.Bounds() x, y, over := g.PointerPosition() *hover = nil diff --git a/code/hex_viewer.go b/code/hex_viewer.go index e55a5ee..484f16c 100644 --- a/code/hex_viewer.go +++ b/code/hex_viewer.go @@ -128,7 +128,7 @@ func hexColumn(c *ui.Context, s *HexViewerState, data []byte, start, end int, cw } // hexKeys moves, extends and copies the selection. -func hexKeys(c *ui.Context, frame *ui.Element, data []byte, s *HexViewerState, move func(int, bool)) { +func hexKeys(c *ui.Context, frame ui.Element, data []byte, s *HexViewerState, move func(int, bool)) { copyMods := ui.Cmd if runtime.GOOS != "darwin" { copyMods = ui.Ctrl diff --git a/code/output_panel.go b/code/output_panel.go index 2337665..9396b4d 100644 --- a/code/output_panel.go +++ b/code/output_panel.go @@ -34,7 +34,7 @@ type OutputPanelOptions struct { // OutputPanelResult is an OutputPanel and what happened in this frame. type OutputPanelResult struct { - Element *ui.Element + Element ui.Element // Cleared names a channel to clear. Changed bool Cleared string diff --git a/code/problems_panel.go b/code/problems_panel.go index 47e7cd1..68b9632 100644 --- a/code/problems_panel.go +++ b/code/problems_panel.go @@ -58,7 +58,7 @@ type ProblemsPanelOptions struct { // ProblemsPanelResult carries the problem opened. type ProblemsPanelResult struct { - Element *ui.Element + Element ui.Element Opened *Problem } diff --git a/code/process_list.go b/code/process_list.go index 3a7c9bc..f8c1d03 100644 --- a/code/process_list.go +++ b/code/process_list.go @@ -48,7 +48,7 @@ type ProcessListOptions struct { // ProcessListResult carries a confirmed end request. type ProcessListResult struct { - Element *ui.Element + Element ui.Element // End is the confirmed process to end, or nil. End *Process } diff --git a/code/search_panel.go b/code/search_panel.go index 9832955..ceabe0d 100644 --- a/code/search_panel.go +++ b/code/search_panel.go @@ -57,7 +57,7 @@ type SearchOpen struct { // SearchPanelResult is a SearchPanel and what happened in this frame. type SearchPanelResult struct { - Element *ui.Element + Element ui.Element // Changed asks the caller to search again. Changed bool Opened *SearchOpen diff --git a/code/symbol_outline.go b/code/symbol_outline.go index 558bdd4..a8732da 100644 --- a/code/symbol_outline.go +++ b/code/symbol_outline.go @@ -63,7 +63,7 @@ type SymbolOutlineOptions struct { // SymbolOutlineResult carries the symbol to jump to. type SymbolOutlineResult struct { - Element *ui.Element + Element ui.Element Jumped *Symbol } diff --git a/code/terminal.go b/code/terminal.go index d017e1b..bd5d92b 100644 --- a/code/terminal.go +++ b/code/terminal.go @@ -49,8 +49,14 @@ func terminalMetrics(f ui.Font) (cw, ch, base float32) { return cw, ch, (ch-m.Ascent-m.Descent)/2 + m.Ascent } +// terminalPointer is the selection drag and focus target kept across frames. +type terminalPointer struct { + handle ui.Handle + down bool +} + // Terminal draws a TerminalState and sends input to the PTY. -func Terminal(c *ui.Context, s *TerminalState, opts TerminalOptions) *ui.Element { +func Terminal(c *ui.Context, s *TerminalState, opts TerminalOptions) ui.Element { if s == nil { panic("mujica: Terminal needs a TerminalState") } @@ -94,13 +100,17 @@ func Terminal(c *ui.Context, s *TerminalState, opts TerminalOptions) *ui.Element return TerminalPos{Line: len(s.back) - s.offset + row, Col: col} } anchor := ui.Local(e, "terminal.anchor", func() TerminalPos { return TerminalPos{} }) + ptr := ui.Local(e, "terminal.pointer", func() terminalPointer { return terminalPointer{} }) + e.Bind(&ptr.handle) + disabled := e.IsDisabled() + svc := c.Services() e.HandleInput(func(ev ui.InputEvent) bool { - if e.IsDisabled() { + if disabled { return false } switch ev.Kind { case ui.InputKeyDown: - return terminalKey(c, s, ev, send) + return terminalKey(svc, s, ev, send) case ui.InputText: send(ev.Text) return true @@ -117,15 +127,18 @@ func Terminal(c *ui.Context, s *TerminalState, opts TerminalOptions) *ui.Element if ev.Button != 0 { return false } - e.Focus() + ptr.down = true + ptr.handle.Focus() *anchor = cell(ev.X, ev.Y) s.selOn = false return true case ui.InputPointerMove: - if e.Pressed() { + if ptr.down { s.Select(*anchor, cell(ev.X, ev.Y)) } return true + case ui.InputPointerUp: + ptr.down = false } return false }) @@ -144,7 +157,7 @@ func Terminal(c *ui.Context, s *TerminalState, opts TerminalOptions) *ui.Element } // terminalKey encodes a key, handling copy, paste and scrollback. -func terminalKey(c *ui.Context, s *TerminalState, ev ui.InputEvent, send func(string)) bool { +func terminalKey(svc ui.Services, s *TerminalState, ev ui.InputEvent, send func(string)) bool { clip := ui.Super if runtime.GOOS != "darwin" { clip = ui.Ctrl | ui.Shift @@ -152,12 +165,12 @@ func terminalKey(c *ui.Context, s *TerminalState, ev ui.InputEvent, send func(st switch { case ev.Mods == clip && ev.Key == ui.KeyC: if t := s.Selection(); t != "" { - c.WriteClipboard(t) + svc.WriteClipboard(t) slog.Debug("mujica: terminal copy", "runes", len([]rune(t))) } return true case ev.Mods == clip && ev.Key == ui.KeyV: - if t := c.ReadClipboard(); t != "" { + if t := svc.ReadClipboard(); t != "" { send(t) } return true diff --git a/code/terminal_tabs.go b/code/terminal_tabs.go index 6d3051d..f1a81dc 100644 --- a/code/terminal_tabs.go +++ b/code/terminal_tabs.go @@ -30,7 +30,7 @@ type TerminalTabsOptions struct { // TerminalTabsResult is TerminalTabs and what happened in this frame. type TerminalTabsResult struct { - Element *ui.Element + Element ui.Element // Renamed and Closed hold a tab ID. Changed bool Renamed string @@ -126,7 +126,7 @@ func TerminalTabs(c *ui.Context, tabs *[]TerminalTab, active *string, opts Termi } // terminalTabsTab builds one tab with title and close. -func terminalTabsTab(c *ui.Context, tab *ui.Element, t TerminalTab, on bool, st *terminalTabsState, rename func(string), askClose func(TerminalTab), disabled bool) { +func terminalTabsTab(c *ui.Context, tab ui.Element, t TerminalTab, on bool, st *terminalTabsState, rename func(string), askClose func(TerminalTab), disabled bool) { k := core.Tokens(c) d := core.Density(c) u := d.Unit() @@ -162,7 +162,7 @@ func terminalTabsTab(c *ui.Context, tab *ui.Element, t TerminalTab, on bool, st if st.editing == t.ID { in := input.TextInput(c, &st.draft, input.TextInputOptions{Label: core.Msg(c, "terminal_tabs.rename")}) in.Element.Width(u * 36) - in.Input.AutoFocus() + core.AutoFocus(c, in.Input) switch { case in.Input.Shortcut(0, ui.KeyEscape): st.editing = "" diff --git a/code/variables_panel.go b/code/variables_panel.go index 294c0d7..13e1993 100644 --- a/code/variables_panel.go +++ b/code/variables_panel.go @@ -43,7 +43,7 @@ type VariableEdit struct{ Path, Value string } // VariablesPanelResult carries a committed edit. type VariablesPanelResult struct { - Element *ui.Element + Element ui.Element Edited *VariableEdit } @@ -80,7 +80,7 @@ func VariablesPanel(c *ui.Context, vars []Variable, s *VariablesPanelState, opts if s.editing == p { in := input.TextInput(c, &s.draft, input.TextInputOptions{Label: core.Msg(c, "variables_panel.value")}) in.Element.Grow(1).MinWidth(0) - in.Input.AutoFocus() + core.AutoFocus(c, in.Input) switch { case in.Input.Shortcut(0, ui.KeyEscape): s.editing = "" diff --git a/core/autofocus.go b/core/autofocus.go new file mode 100644 index 0000000..8a33365 --- /dev/null +++ b/core/autofocus.go @@ -0,0 +1,19 @@ +package core + +import "github.com/egoist/mygo/ui" + +type autoFocusKey struct{} + +// AutoFocus focuses e the frame after it appears, once MyGo has recorded an overlay's opener from the focus it had. +func AutoFocus(c *ui.Context, e ui.Element) ui.Element { + step := ui.Local(e, autoFocusKey{}, func() int { return 0 }) + switch *step { + case 0: + *step = 1 + c.Invalidate() + case 1: + *step = 2 + e.Focus() + } + return e +} diff --git a/core/base_disabled_test.go b/core/base_disabled_test.go index 128b64d..7b86dcc 100644 --- a/core/base_disabled_test.go +++ b/core/base_disabled_test.go @@ -8,11 +8,11 @@ import ( // TestBasesBlockedByDisabledWrapper flips a focused MyGo base's wrapper to Disabled in the frame Space arrives. func TestBasesBlockedByDisabledWrapper(t *testing.T) { - for name, base := range map[string]func(c *ui.Context, on *bool) *ui.Element{ - "CheckboxBase": func(c *ui.Context, on *bool) *ui.Element { return ui.CheckboxBase(c, on) }, - "RadioBase": func(c *ui.Context, on *bool) *ui.Element { return ui.RadioBase(c, on, true) }, - "SwitchBase": func(c *ui.Context, on *bool) *ui.Element { return ui.SwitchBase(c, on) }, - "ToggleBase": func(c *ui.Context, on *bool) *ui.Element { return ui.ToggleBase(c, on) }, + for name, base := range map[string]func(c *ui.Context, on *bool) ui.Element{ + "CheckboxBase": func(c *ui.Context, on *bool) ui.Element { return ui.CheckboxBase(c, on) }, + "RadioBase": func(c *ui.Context, on *bool) ui.Element { return ui.RadioBase(c, on, true) }, + "SwitchBase": func(c *ui.Context, on *bool) ui.Element { return ui.SwitchBase(c, on) }, + "ToggleBase": func(c *ui.Context, on *bool) ui.Element { return ui.ToggleBase(c, on) }, } { for _, disable := range []bool{false, true} { on, off := false, false @@ -35,8 +35,8 @@ func TestBasesBlockedByDisabledWrapper(t *testing.T) { } } -// TestComboboxBaseIgnoresDisabledWrapper shows why Combobox hands its base a copy: a focused one still takes typing. -func TestComboboxBaseIgnoresDisabledWrapper(t *testing.T) { +// TestComboboxBaseBlockedByDisabledWrapper guards Combobox, which binds its base directly: a focused one takes no typing. +func TestComboboxBaseBlockedByDisabledWrapper(t *testing.T) { q, off := "", false tt := ui.NewTester(func(c *ui.Context) { ui.Row(c).Disabled(off).Children(func() { @@ -48,9 +48,10 @@ func TestComboboxBaseIgnoresDisabledWrapper(t *testing.T) { t.Fatal("not focused") } off = true + tt.Frame() tt.Type("a") tt.Frame() - if q != "a" { - t.Fatalf("typing left %q; the wrapper now blocks it and Combobox can drop its copy", q) + if q != "" { + t.Fatalf("typing under a disabled wrapper set %q; Combobox must edit a copy again", q) } } diff --git a/core/choice_result.go b/core/choice_result.go index 1b0e3da..99fceca 100644 --- a/core/choice_result.go +++ b/core/choice_result.go @@ -6,7 +6,7 @@ import ( // ChoiceResult is a choice control's element and whether its value changed this frame. type ChoiceResult struct { - Element *ui.Element + Element ui.Element changed bool } @@ -14,4 +14,4 @@ type ChoiceResult struct { func (r ChoiceResult) Changed() bool { return r.changed } // NewChoiceResult reports e and whether its value changed this frame. -func NewChoiceResult(e *ui.Element, changed bool) ChoiceResult { return ChoiceResult{e, changed} } +func NewChoiceResult(e ui.Element, changed bool) ChoiceResult { return ChoiceResult{e, changed} } diff --git a/core/datetime.go b/core/datetime.go index 2afd891..162a9e9 100644 --- a/core/datetime.go +++ b/core/datetime.go @@ -108,7 +108,7 @@ func DatetimeDayLabel(c *ui.Context, d Date) string { } // DatetimeField styles row as a single-line field: solid fill and fine border. -func DatetimeField(c *ui.Context, row *ui.Element, invalid, readOnly bool) *ui.Element { +func DatetimeField(c *ui.Context, row ui.Element, invalid, readOnly bool) ui.Element { k := Tokens(c) d := Density(c) bg, border := k.Surface, k.ControlBorder @@ -127,7 +127,7 @@ func DatetimeField(c *ui.Context, row *ui.Element, invalid, readOnly bool) *ui.E } // DatetimeTrigger is the popup button inside a field; Cmd+C copies its text. -func DatetimeTrigger(c *ui.Context, text, placeholder, icon string) *ui.Element { +func DatetimeTrigger(c *ui.Context, text, placeholder, icon string) ui.Element { k := Tokens(c) b := ui.ButtonBase(c).Grow(1).Shrink(1).Justify(ui.Start).Gap(Density(c).Unit() * 2). MinHeight(theme.MinHitSize).Radius(theme.ControlRadius).Role(ui.RolePopUpButton) @@ -147,13 +147,13 @@ func DatetimeTrigger(c *ui.Context, text, placeholder, icon string) *ui.Element } // DatetimePopup shows fn below anchor while *open; a disabled or read-only anchor closes it. -func DatetimePopup(c *ui.Context, anchor *ui.Element, open *bool, readOnly bool, fn func()) *ui.Element { +func DatetimePopup(c *ui.Context, anchor ui.Element, open *bool, readOnly bool, fn func()) ui.Element { d := Density(c) if *open && (readOnly || anchor.IsDisabled()) { slog.Debug("mujica: datetime popup closed by state", "readOnly", readOnly) *open = false } - return ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { + return ui.PopoverBase(c, anchor, open, func(panel ui.Element) { OverlayStyle(c, panel, OverlayEnter(c, panel)) panel.MaxWidth(d.ControlHeight()*7+d.Unit()*6+2*theme.BorderWidth).Margin(d.Unit(), 0, 0, 0).Padding(d.Unit() * 3).Gap(d.Unit() * 3) fn() diff --git a/core/icon_action.go b/core/icon_action.go index 1ef38cf..2838193 100644 --- a/core/icon_action.go +++ b/core/icon_action.go @@ -8,7 +8,7 @@ import ( ) // IconAction is a square, borderless button showing icon, named label. -func IconAction(c *ui.Context, icon *ui.SVG, label string) *ui.Element { +func IconAction(c *ui.Context, icon *ui.SVG, label string) ui.Element { k := Tokens(c) b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Label(label).TextColor(k.TextMuted) internal.Ring(b, k.Focus, theme.ControlRadius) diff --git a/core/overlay_layers.go b/core/overlay_layers.go index ae96232..b7430e2 100644 --- a/core/overlay_layers.go +++ b/core/overlay_layers.go @@ -2,16 +2,16 @@ package core import ( "log/slog" - "time" "github.com/egoist/mygo/ui" ) // overlayLayers is the window's open MujicaUI layers, oldest first. type overlayLayers struct { - frame, prev time.Time - keys []any - seen []time.Time + root ui.Element // compared only: a new root means a new build pass + pass int + keys []any + seen []int } type overlayLayersKey struct{} @@ -19,11 +19,12 @@ type overlayLayersKey struct{} // OverlayLayer marks the layer key open in this frame and reports whether it is on top. func OverlayLayer(c *ui.Context, key any) bool { s := ui.Local(c.Root(), overlayLayersKey{}, func() overlayLayers { return overlayLayers{} }) - if now := c.Now(); now != s.frame { - s.prev, s.frame = s.frame, now + if root := c.Root(); root != s.root { + s.root = root + s.pass++ n := 0 for i, k := range s.keys { - if !s.seen[i].Before(s.prev) { // drop layers the last frame did not build + if s.seen[i] >= s.pass-1 { // drop layers the last pass did not build s.keys[n], s.seen[n] = k, s.seen[i] n++ } @@ -38,10 +39,10 @@ func OverlayLayer(c *ui.Context, key any) bool { } } if at < 0 { - s.keys, s.seen = append(s.keys, key), append(s.seen, s.frame) + s.keys, s.seen = append(s.keys, key), append(s.seen, s.pass) at = len(s.keys) - 1 } - s.seen[at] = s.frame + s.seen[at] = s.pass return at == len(s.keys)-1 } @@ -76,7 +77,7 @@ type overlaySiteState struct { } // OverlaySite builds the invisible focus fallback at a layer's call site. -func OverlaySite(c *ui.Context) (*ui.Element, *overlaySiteState) { +func OverlaySite(c *ui.Context) (ui.Element, *overlaySiteState) { site := ui.Box(c).Size(0, 0).FocusRing(false) st := ui.Local(site, "mujica.overlay.site", func() overlaySiteState { return overlaySiteState{} }) st.holding = st.holding && site.FocusWithin() @@ -89,12 +90,12 @@ func OverlaySite(c *ui.Context) (*ui.Element, *overlaySiteState) { } // Shown records whether the focus is in the open layer's panel. -func (st *overlaySiteState) Shown(panel *ui.Element) { - st.focusIn, st.wait = panel != nil && panel.FocusWithin(), 0 +func (st *overlaySiteState) Shown(panel ui.Element) { + st.focusIn, st.wait = panel.Valid() && panel.FocusWithin(), 0 } // Closed moves a lost focus to the site once MyGo has had its chance to restore it. -func (st *overlaySiteState) Closed(c *ui.Context, site *ui.Element) { +func (st *overlaySiteState) Closed(c *ui.Context, site ui.Element) { if st.focusIn { st.focusIn, st.wait = false, 2 // MyGo prunes the panel at the end of the next frame } diff --git a/core/overlay_motion.go b/core/overlay_motion.go index 078d41c..4d9ce18 100644 --- a/core/overlay_motion.go +++ b/core/overlay_motion.go @@ -9,7 +9,7 @@ import ( ) // OverlayEnter returns how far, 0 to 1, the popup born with e has entered. -func OverlayEnter(c *ui.Context, e *ui.Element) float32 { +func OverlayEnter(c *ui.Context, e ui.Element) float32 { born := ui.Local(e, "mujica.overlay.born", func() time.Time { return c.Now() }) d := Motion(c, theme.PopupDuration) if d == 0 { @@ -24,7 +24,7 @@ func OverlayEnter(c *ui.Context, e *ui.Element) float32 { } // OverlayStyle gives a popup panel the surface, edge and shadow of the theme. -func OverlayStyle(c *ui.Context, panel *ui.Element, p float32) { +func OverlayStyle(c *ui.Context, panel ui.Element, p float32) { k := Tokens(c) panel.Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.PopupRadius). TextColor(k.Text).Opacity(p).Shadow(0, 6, 18, 0, ui.RGBA(0, 0, 0, 0.16*p)) diff --git a/core/severity.go b/core/severity.go index 477c098..6cfd858 100644 --- a/core/severity.go +++ b/core/severity.go @@ -51,7 +51,7 @@ func SeverityLook(k theme.Tokens, s Severity) (ui.Color, *ui.SVG) { // AlertResult is what an Alert built and reported this frame. type AlertResult struct { // Element is nil while the alert is closed. - Element *ui.Element + Element ui.Element // Action reports a click on the action button. Action bool // Closed reports the user dismissing the alert. diff --git a/core/spinner.go b/core/spinner.go index 3235dc9..6647470 100644 --- a/core/spinner.go +++ b/core/spinner.go @@ -20,7 +20,7 @@ type SpinnerOptions struct { } // Spinner turns four diamonds only while painted; hidden or under reduced motion it stops. -func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element { +func Spinner(c *ui.Context, opts SpinnerOptions) ui.Element { if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) { panic(fmt.Sprintf("mujica: Spinner Size %v invalid", opts.Size)) } @@ -41,7 +41,7 @@ func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element { row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center) row.Children(func() { // Keyed by motion so a flip repaints; MyGo's spinner carries the progress role. - ui.Box(c).Key(still).Shrink(0).Children(func() { spinnerMark(c, label, size, color, still) }) + ui.Box(c.Key(still)).Shrink(0).Children(func() { spinnerMark(c, label, size, color, still) }) if opts.ShowLabel { ui.Text(c, label).TextColor(k.TextMuted).SingleLine() } diff --git a/data/accordion.go b/data/accordion.go index 09fd026..521fee7 100644 --- a/data/accordion.go +++ b/data/accordion.go @@ -46,7 +46,7 @@ func Accordion(c *ui.Context, open *[]string, items []AccordionItem, opts Accord focusTo := ui.Local(v.Element, "focus", func() int { return -1 }) v.Element.Children(func() { for i, it := range items { - ui.Column(c).Key(it.ID).Children(func() { + ui.Column(c.Key(it.ID)).Children(func() { if i > 0 { ui.Box(c).Height(1).FillWidth().Background(k.Border) } @@ -75,7 +75,7 @@ func Accordion(c *ui.Context, open *[]string, items []AccordionItem, opts Accord ui.Text(c, it.Title).FontWeight(600).Grow(1).MinWidth(0) ui.Icon(c, icons.Must("chevron-down")).Size(16, 16).Rotate(180 * prog).TextColor(k.TextMuted).Shrink(0) }) - if panel := collapsiblePanel(c, isOpen, prog, it.Content); panel != nil { + if panel := collapsiblePanel(c, isOpen, prog, it.Content); panel.Valid() { panel.Padding(0, u*4, u*4).Gap(u * 2) } }) @@ -85,7 +85,7 @@ func Accordion(c *ui.Context, open *[]string, items []AccordionItem, opts Accord } // accordionKey returns the header the arrows, Home or End move to from header i, or -1. -func accordionKey(tr *ui.Element, items []AccordionItem, i int) int { +func accordionKey(tr ui.Element, items []AccordionItem, i int) int { step, from := 0, i switch { case tr.Shortcut(0, ui.KeyDown): diff --git a/data/card.go b/data/card.go index 20d26de..00fa6f4 100644 --- a/data/card.go +++ b/data/card.go @@ -22,7 +22,7 @@ type CardOptions struct { const cardInset float32 = 4 // Card groups content on a surface with an outline. It adds no click behavior. -func Card(c *ui.Context, opts CardOptions, body func()) *ui.Element { +func Card(c *ui.Context, opts CardOptions, body func()) ui.Element { k := core.Tokens(c) u := core.Density(c).Unit() pad := u * 4 diff --git a/data/carousel.go b/data/carousel.go index 34ff235..52ef999 100644 --- a/data/carousel.go +++ b/data/carousel.go @@ -97,7 +97,7 @@ func Carousel(c *ui.Context, index *int, n int, opts CarouselOptions, slide func ui.Column(c).Grow(1).MinWidth(0).FillHeight().Clip().Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius). Role(ui.RoleGroup).Label(fmt.Sprintf(core.Msg(c, "carousel.position"), *index+1, n)).Children(func() { if n > 0 { - ui.Column(c).Key(*index).Grow(1).Children(func() { slide(*index) }) + ui.Column(c.Key(*index)).Grow(1).Children(func() { slide(*index) }) } }) next := input.Button(c, "", input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("chevron-right"), Label: core.Msg(c, "carousel.next"), Disabled: n < 2 || !wrap && *index == n-1}) @@ -133,7 +133,7 @@ func Carousel(c *ui.Context, index *int, n int, opts CarouselOptions, slide func } // carouselDot is a position button: a filled diamond for the current slide. -func carouselDot(c *ui.Context, i, n int, on bool) *ui.Element { +func carouselDot(c *ui.Context, i, n int, on bool) ui.Element { k := core.Tokens(c) b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius). Label(fmt.Sprintf(core.Msg(c, "carousel.position"), i+1, n)).Role(ui.RoleTab).Selected(on) @@ -154,7 +154,7 @@ func carouselDot(c *ui.Context, i, n int, on bool) *ui.Element { } // carouselPause is the pause button: two bars, or a play triangle while paused. -func carouselPause(c *ui.Context, paused bool) *ui.Element { +func carouselPause(c *ui.Context, paused bool) ui.Element { k := core.Tokens(c) label := core.Msg(c, "carousel.pause") if paused { diff --git a/data/carousel_test.go b/data/carousel_test.go index c363c35..3ec05c1 100644 --- a/data/carousel_test.go +++ b/data/carousel_test.go @@ -82,8 +82,7 @@ func TestCarouselKeyboard(t *testing.T) { func TestCarouselNoAutoplayByDefault(t *testing.T) { h := &carouselHarness{opts: CarouselOptions{Interval: 10 * time.Millisecond}} tt := ui.NewTester(h.view, 420, 280) - time.Sleep(30 * time.Millisecond) - tt.Frame() + tt.Advance(30 * time.Millisecond) if h.index != 0 || tt.HasText("Pause autoplay") { t.Fatalf("advanced without Autoplay: %d", h.index) } @@ -92,8 +91,7 @@ func TestCarouselNoAutoplayByDefault(t *testing.T) { func TestCarouselAutoplayPause(t *testing.T) { h := &carouselHarness{opts: CarouselOptions{Autoplay: true, Interval: 20 * time.Millisecond}} tt := ui.NewTester(h.view, 420, 280) - time.Sleep(30 * time.Millisecond) - tt.Frame() + tt.Advance(30 * time.Millisecond) if h.index != 1 { t.Fatalf("autoplay did not advance: %d", h.index) } @@ -102,8 +100,7 @@ func TestCarouselAutoplayPause(t *testing.T) { tt.SetFocused(false) tt.SetFocused(true) at := h.index - time.Sleep(50 * time.Millisecond) - tt.Frame() + tt.Advance(50 * time.Millisecond) if h.index != at || !tt.HasText("") { t.Fatalf("paused carousel advanced: %d -> %d", at, h.index) } @@ -113,8 +110,7 @@ func TestCarouselAutoplayPause(t *testing.T) { r, _ := tt.Find("Window " + fmt.Sprint(at+1)) _ = tt.Click("Resume autoplay") tt.Move(r.X+2, r.Y+2) // hover pauses too - time.Sleep(50 * time.Millisecond) - tt.Frame() + tt.Advance(50 * time.Millisecond) if h.index != at { t.Fatalf("hovered carousel advanced: %d -> %d", at, h.index) } diff --git a/data/collapsible.go b/data/collapsible.go index c8e65ed..214fab1 100644 --- a/data/collapsible.go +++ b/data/collapsible.go @@ -55,7 +55,7 @@ func Collapsible(c *ui.Context, open *bool, opts CollapsibleOptions, content fun ui.Text(c, opts.Title).FontWeight(600).SingleLine() } }) - if panel := collapsiblePanel(c, *open, prog, content); panel != nil { + if panel := collapsiblePanel(c, *open, prog, content); panel.Valid() { panel.Padding(u*2, u*2, u*2, u*8).Gap(u * 2) } }) @@ -73,13 +73,13 @@ func collapsibleTarget(open bool) float32 { } // collapsiblePanel builds fn while open or animating, clipped to its progress. -func collapsiblePanel(c *ui.Context, open bool, prog float32, fn func()) *ui.Element { +func collapsiblePanel(c *ui.Context, open bool, prog float32, fn func()) ui.Element { if !open && prog == 0 { - return nil + return ui.Element{} } // Closing content takes no input or focus while it shrinks away. clip := ui.Column(c).Shrink(0).Disabled(!open) - var inner *ui.Element + var inner ui.Element clip.Children(func() { inner = ui.Column(c).Shrink(0) inner.Children(fn) diff --git a/data/data_table.go b/data/data_table.go index b5e5b0d..15d39e5 100644 --- a/data/data_table.go +++ b/data/data_table.go @@ -89,7 +89,7 @@ func (s *DataTableState[K]) VisibleRows() (first, last int) { return s.rows.Visi // DataTableView is a built DataTable and what happened to it in this frame. type DataTableView struct { - Element *ui.Element + Element ui.Element changed, submitted, sorted, retry bool } diff --git a/data/data_table_columns.go b/data/data_table_columns.go index 4d5fa73..d6b01f3 100644 --- a/data/data_table_columns.go +++ b/data/data_table_columns.go @@ -43,12 +43,12 @@ func tableColumnMenu(c *ui.Context, cols []tableColumn, sort *ui.SortOrder, layo } } } - ui.PopoverBase(c, b, open, func(panel *ui.Element) { + ui.PopoverBase(c, b, open, func(panel ui.Element) { panel.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Radius(theme.PopupRadius). Padding(u * 2).Gap(u).MinWidth(240).MarginY(u) for at, id := range order { col := cols[slices.IndexFunc(cols, func(col tableColumn) bool { return col.ID == id })] - ui.Row(c).Key(id).Gap(u).AlignItems(ui.Center).Children(func() { + ui.Row(c.Key(id)).Gap(u).AlignItems(ui.Center).Children(func() { if hidden != nil { tableVisibility(c, col, hidden, visible) } else { diff --git a/data/description_list.go b/data/description_list.go index 3b167f5..f328c7a 100644 --- a/data/description_list.go +++ b/data/description_list.go @@ -33,7 +33,7 @@ type DescriptionListOptions struct { // DescriptionListView is a built DescriptionList and what happened to it in this frame. type DescriptionListView struct { - Element *ui.Element + Element ui.Element copied int } diff --git a/data/image_viewer.go b/data/image_viewer.go index 88204fb..8ee3703 100644 --- a/data/image_viewer.go +++ b/data/image_viewer.go @@ -47,7 +47,7 @@ func ImageViewer(c *ui.Context, open *bool, index *int, images []ViewerImage) co k := core.Tokens(c) u := core.Density(c).Unit() w, h := c.Size() - v.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + v.Element = ui.DialogBase(c, open, func(back, panel ui.Element) { back.Background(ui.RGBA(0, 0, 0, 0.55)) panel.Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.PopupRadius). Size(w-u*12, h-u*12).Padding(u * 3).Gap(u * 3).Label(core.Msg(c, "image_viewer.title")) @@ -92,7 +92,7 @@ func ImageViewer(c *ui.Context, open *bool, index *int, images []ViewerImage) co if tool("chevron-left", "image_viewer.prev", *index == 0) { goTo(*index - 1) } - stage := ui.Box(c).Grow(1).FillHeight().MinWidth(0).Focusable().AutoFocus().Clip(). + stage := core.AutoFocus(c, ui.Box(c).Grow(1).FillHeight().MinWidth(0).Focusable()).Clip(). Radius(theme.ControlRadius).Background(k.Background).Role(ui.RoleImage).Label(img.Alt).Cursor(ui.CursorGrab) internal.Ring(stage, k.Focus, theme.ControlRadius) if dx, dy, ok := stage.Dragged(); ok { @@ -143,7 +143,7 @@ func ImageViewer(c *ui.Context, open *bool, index *int, images []ViewerImage) co } // viewerKeys, anywhere in the panel, zooms with + - 0, switches with Left and Right, and pans with Shift+arrows. -func viewerKeys(panel *ui.Element, st *viewerState, zoom func(float32), goTo func(int), at int) { +func viewerKeys(panel ui.Element, st *viewerState, zoom func(float32), goTo func(int), at int) { switch { case panel.Shortcut(0, ui.KeyEqual), panel.Shortcut(ui.Shift, ui.KeyEqual): zoom(viewerZoomStep) diff --git a/data/list.go b/data/list.go index 4b95935..247a549 100644 --- a/data/list.go +++ b/data/list.go @@ -67,7 +67,7 @@ func (s *ListState[K]) ScrollTo(i int) { // ListView is a built List and what happened to it in this frame. type ListView struct { - Element *ui.Element + Element ui.Element changed, submitted, retry bool } @@ -100,7 +100,7 @@ func List[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[ v.retry = DataPlaceholder(c, opts.Status, opts.Error, opts.Empty, core.Msg(c, "list.empty")) return } - var e *ui.Element + var e ui.Element if opts.Grid { e = listGrid(c, s, n, opts, item) } else { @@ -136,7 +136,7 @@ func listLocate[K comparable](s *ListState[K], n int, key func(int) K) { slog.Debug("mujica: list selection moved", "row", s.cursor) } -func listRows[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[K], item func(i int)) *ui.Element { +func listRows[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[K], item func(i int)) ui.Element { d := core.Density(c) s.Rows.Key = func(i int) any { return opts.Key(i) } s.Rows.Label = opts.Label @@ -159,7 +159,7 @@ func listRows[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOpti }).Padding(d.Unit()) } -func listGrid[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[K], item func(i int)) *ui.Element { +func listGrid[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[K], item func(i int)) ui.Element { d := core.Density(c) w, h := opts.ItemWidth, opts.ItemHeight if w <= 0 { diff --git a/data/statistic.go b/data/statistic.go index c80cd92..2a88bf0 100644 --- a/data/statistic.go +++ b/data/statistic.go @@ -29,7 +29,7 @@ type StatisticOptions struct { } // Statistic shows a headline number with its title, unit and change. -func Statistic(c *ui.Context, value float64, opts StatisticOptions) *ui.Element { +func Statistic(c *ui.Context, value float64, opts StatisticOptions) ui.Element { if opts.Precision < 0 { panic("mujica: Statistic with negative Precision") } diff --git a/data/timeline.go b/data/timeline.go index c6e107f..9fbbb35 100644 --- a/data/timeline.go +++ b/data/timeline.go @@ -29,7 +29,7 @@ type TimelineItem struct { } // Timeline lists events top to bottom with markers joined by a line; long text wraps. -func Timeline(c *ui.Context, items []TimelineItem) *ui.Element { +func Timeline(c *ui.Context, items []TimelineItem) ui.Element { k := core.Tokens(c) u := core.Density(c).Unit() return ui.Column(c).Role(ui.RoleList).Children(func() { diff --git a/data/tree.go b/data/tree.go index 1f24b32..ecb346f 100644 --- a/data/tree.go +++ b/data/tree.go @@ -162,7 +162,7 @@ func (t *treeCore[K]) itemStatus(c *ui.Context, item K, status func(K) DataStatu // TreeView is a built Tree and what happened to it in this frame. type TreeView struct { - Element *ui.Element + Element ui.Element changed, submitted, retry bool } diff --git a/datetime/attendee_list.go b/datetime/attendee_list.go index 2a92066..9d1478c 100644 --- a/datetime/attendee_list.go +++ b/datetime/attendee_list.go @@ -41,7 +41,7 @@ type AttendeeListOptions struct { // AttendeeListResult is the list and the attendee the user removed. type AttendeeListResult struct { - Element *ui.Element + Element ui.Element removed int } @@ -73,7 +73,7 @@ func AttendeeList(c *ui.Context, attendees []Attendee, opts AttendeeListOptions) ui.Text(c, core.Msg(c, "attendee_list.empty")).TextColor(k.TextMuted) } for i, a := range attendees { - ui.Row(c).Key(i).MinHeight(d.ControlHeight()).Gap(d.Unit() * 2).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() { + ui.Row(c.Key(i)).MinHeight(d.ControlHeight()).Gap(d.Unit() * 2).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() { display.Avatar(c, a.Name, display.AvatarOptions{Size: 28}) ui.Column(c).Grow(1).Shrink(1).Children(func() { name := a.Name diff --git a/datetime/calendar.go b/datetime/calendar.go index 7580d9d..432af2c 100644 --- a/datetime/calendar.go +++ b/datetime/calendar.go @@ -223,7 +223,7 @@ func calendarDay(c *ui.Context, day, cur core.Date, cfg calendarConfig, size flo if focus { cell.Focus() } else if cfg.autoFocus { - cell.AutoFocus() + core.AutoFocus(c, cell) } } band := cfg.inRange != nil && cfg.inRange(day) diff --git a/datetime/calendar_events.go b/datetime/calendar_events.go index cf2db6b..80814bc 100644 --- a/datetime/calendar_events.go +++ b/datetime/calendar_events.go @@ -57,7 +57,7 @@ type CalendarChange struct { // CalendarViewResult is a view and what the user asked. type CalendarViewResult struct { - Element *ui.Element + Element ui.Element opened string change *CalendarChange day core.Date @@ -112,7 +112,7 @@ type eventsCache[T any] struct { } // eventsDerived rebuilds build(hits) only when events or span change. -func eventsDerived[T any](e *ui.Element, key string, events []CalendarEvent, version int, from, to time.Time, build func(hits []int) T) T { +func eventsDerived[T any](e ui.Element, key string, events []CalendarEvent, version int, from, to time.Time, build func(hits []int) T) T { cache := ui.Local(e, key, func() *eventsCache[T] { return &eventsCache[T]{} }) var first *CalendarEvent if len(events) > 0 { diff --git a/datetime/calendar_month_view.go b/datetime/calendar_month_view.go index 55e90b5..5fc5183 100644 --- a/datetime/calendar_month_view.go +++ b/datetime/calendar_month_view.go @@ -204,7 +204,7 @@ func monthCell(c *ui.Context, day core.Date, month YearMonth, today core.Date, l *more = day } open := *more == day - ui.PopoverBase(c, b, &open, func(panel *ui.Element) { + ui.PopoverBase(c, b, &open, func(panel ui.Element) { core.OverlayStyle(c, panel, core.OverlayEnter(c, panel)) _, wh := c.Size() panel.Padding(d.Unit() * 2).Gap(d.Unit()).MinWidth(d.ControlHeight() * 6).MaxHeight(wh / 2) diff --git a/datetime/calendar_time_grid.go b/datetime/calendar_time_grid.go index 387bfce..a066cae 100644 --- a/datetime/calendar_time_grid.go +++ b/datetime/calendar_time_grid.go @@ -60,7 +60,7 @@ func timeGridSlot(name string, slot int) int { } // timeGrid draws day columns and turns gestures into changes. -func timeGrid(c *ui.Context, root *ui.Element, days []core.Date, events []CalendarEvent, cfg timeGridConfig, r *CalendarViewResult) { +func timeGrid(c *ui.Context, root ui.Element, days []core.Date, events []CalendarEvent, cfg timeGridConfig, r *CalendarViewResult) { k := core.Tokens(c) d := core.Density(c) hourH := d.ControlHeight() * 2 @@ -266,7 +266,7 @@ func timeChip(c *ui.Context, i int, pl timePlaced, ev CalendarEvent, cfg timeGri if mine && g.kind == CalendarResize { growY = float32(snap(g.dy)) * hourH / 60 } - box := ui.Box(c).Key(ev.ID).Absolute().Top(from + shiftY).Height(max(to-from+growY, theme.MinHitSize)). + box := ui.Box(c.Key(ev.ID)).Absolute().Top(from + shiftY).Height(max(to-from+growY, theme.MinHitSize)). LeftPercent(100 * float32(pl.col) / float32(pl.cols)).WidthPercent(100 / float32(pl.cols)).Padding(1) box.Children(func() { chip := EventChip(c, ev, EventChipOptions{Location: cfg.loc, Hour12: cfg.hour12, Selected: mine, Inline: to-from < hourH*0.75}).Fill() @@ -291,7 +291,7 @@ func timeChip(c *ui.Context, i int, pl timePlaced, ev CalendarEvent, cfg timeGri } } for _, part := range []struct { - e *ui.Element + e ui.Element kind CalendarChangeKind }{{chip, CalendarMove}, {handle, CalendarResize}} { dx, dy, ok := part.e.Dragged() diff --git a/datetime/countdown.go b/datetime/countdown.go index 11fd30a..49aae41 100644 --- a/datetime/countdown.go +++ b/datetime/countdown.go @@ -31,7 +31,7 @@ type CountdownOptions struct { // CountdownResult is the countdown and its finish. type CountdownResult struct { - Element *ui.Element + Element ui.Element finished bool } diff --git a/datetime/current_time_indicator.go b/datetime/current_time_indicator.go index 4afb5b2..60ecbb4 100644 --- a/datetime/current_time_indicator.go +++ b/datetime/current_time_indicator.go @@ -22,7 +22,7 @@ type CurrentTimeIndicatorOptions struct { } // CurrentTimeIndicator rules the current time across its parent. -func CurrentTimeIndicator(c *ui.Context, opts CurrentTimeIndicatorOptions) *ui.Element { +func CurrentTimeIndicator(c *ui.Context, opts CurrentTimeIndicatorOptions) ui.Element { loc := eventLocation("CurrentTimeIndicator", opts.Location) k := core.Tokens(c) now := opts.Now diff --git a/datetime/date_picker.go b/datetime/date_picker.go index d4eff41..e9ac67a 100644 --- a/datetime/date_picker.go +++ b/datetime/date_picker.go @@ -106,7 +106,7 @@ func DatePicker(c *ui.Context, value *core.Date, opts DatePickerOptions) DateTim if placeholder == "" { placeholder = core.Msg(c, "date_picker.placeholder") } - var field, inputEl *ui.Element + var field, inputEl ui.Element col.Children(func() { field = core.DatetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly) field.Children(func() { diff --git a/datetime/date_range_picker.go b/datetime/date_range_picker.go index c8e336f..dada883 100644 --- a/datetime/date_range_picker.go +++ b/datetime/date_range_picker.go @@ -85,7 +85,7 @@ func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOption if !value.IsZero() { text = value.Start.Format(layout) + " – " + value.End.Format(layout) } - var field *ui.Element + var field ui.Element col.Children(func() { field = core.DatetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly) field.Children(func() { diff --git a/datetime/datetime_grid.go b/datetime/datetime_grid.go index 68d84f2..61dd8b5 100644 --- a/datetime/datetime_grid.go +++ b/datetime/datetime_grid.go @@ -105,7 +105,7 @@ func datetimeGridCell(c *ui.Context, o, start, cursor int, cfg datetimeGridConfi if focus { cell.Focus() } else if cfg.autoFocus { - cell.AutoFocus() + core.AutoFocus(c, cell) } } fg := k.Text @@ -172,7 +172,7 @@ type datetimeGridField struct { } // datetimeGridPicker draws f and its popup; reports a pick or clear. -func datetimeGridPicker(c *ui.Context, f datetimeGridField, cfg datetimeGridConfig) (col *ui.Element, picked int, ok, cleared bool) { +func datetimeGridPicker(c *ui.Context, f datetimeGridField, cfg datetimeGridConfig) (col ui.Element, picked int, ok, cleared bool) { d := core.Density(c) col = ui.Column(c).Width(d.ControlHeight() * 6).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(f.disabled) open := ui.Local(col, "datetime-grid-open", func() bool { return false }) diff --git a/datetime/datetime_result.go b/datetime/datetime_result.go index bcb3606..ee1aecc 100644 --- a/datetime/datetime_result.go +++ b/datetime/datetime_result.go @@ -6,9 +6,9 @@ import ( // DateTimeResult is a date or time component and what happened to it this frame. type DateTimeResult struct { - Element *ui.Element + Element ui.Element // Input is the text field inside, for labels and focus; nil without one. - Input *ui.Element + Input ui.Element changed bool canceled bool } @@ -20,6 +20,6 @@ func (r DateTimeResult) Changed() bool { return r.changed } func (r DateTimeResult) Canceled() bool { return r.canceled } // NewDateTimeResult reports a date or time control built from parts in another component. -func NewDateTimeResult(e, input *ui.Element, changed, canceled bool) DateTimeResult { +func NewDateTimeResult(e, input ui.Element, changed, canceled bool) DateTimeResult { return DateTimeResult{e, input, changed, canceled} } diff --git a/datetime/datetime_review2_test.go b/datetime/datetime_review2_test.go index 265a3aa..f950502 100644 --- a/datetime/datetime_review2_test.go +++ b/datetime/datetime_review2_test.go @@ -167,21 +167,23 @@ func TestTimePickerPopupPickClearsSegmentError(t *testing.T) { func TestFormFocusesDatePickerInput(t *testing.T) { var st input.FormState day := core.Date{} - var field *ui.Element + focused := false tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) + var field ui.Element input.Form(c, &st, input.FormOptions{}, func(f *input.FormScope) { - input.FormItem(c, f, "debut", &day, input.FormItemOptions[core.Date]{Label: "Debut date", Required: true}, func() *ui.Element { + input.FormItem(c, f, "debut", &day, input.FormItemOptions[core.Date]{Label: "Debut date", Required: true}, func() ui.Element { r := DatePicker(c, &day, DatePickerOptions{Today: calendarToday}) field = r.Input return r.Input }) }) + focused = field.Valid() && field.Focused() }, 600, 500) if err := tt.Click("Submit"); err != nil { t.Fatal(err) } - if field == nil || !field.Focused() || !tt.Focused("Debut date") { + if !focused || !tt.Focused("Debut date") { t.Fatal("a failed submit should focus the DatePicker's text box") } tt.Type("2026-10-12") diff --git a/datetime/event_chip.go b/datetime/event_chip.go index cd39185..bebe56e 100644 --- a/datetime/event_chip.go +++ b/datetime/event_chip.go @@ -22,7 +22,7 @@ type EventChipOptions struct { } // EventChip is a clickable, truncating event block. -func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Element { +func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) ui.Element { loc := eventLocation("EventChip", opts.Location) k := core.Tokens(c) d := core.Density(c) diff --git a/datetime/event_editor.go b/datetime/event_editor.go index 5e2fb08..fa6f008 100644 --- a/datetime/event_editor.go +++ b/datetime/event_editor.go @@ -24,7 +24,7 @@ type EventEditorOptions struct { // EventEditorResult is the editor and its save or cancel. type EventEditorResult struct { - Element *ui.Element + Element ui.Element saved bool canceled bool } @@ -61,7 +61,7 @@ func EventEditor(c *ui.Context, ev *CalendarEvent, opts EventEditorOptions) Even dr := &s.draft problems := eventEditorProblems(c, *dr, s.tried) root.Children(func() { - input.FormField(c, core.Msg(c, "event_editor.title"), input.FormFieldOptions{Required: true, Error: problems["title"]}, func() *ui.Element { + input.FormField(c, core.Msg(c, "event_editor.title"), input.FormFieldOptions{Required: true, Error: problems["title"]}, func() ui.Element { return input.TextInput(c, &dr.Title, input.TextInputOptions{Label: core.Msg(c, "event_editor.title"), Disabled: opts.Disabled, Error: problems["title"]}).Input }) input.Checkbox(c, &dr.AllDay, core.Msg(c, "event_editor.all_day"), input.CheckboxOptions{Disabled: opts.Disabled}) @@ -70,7 +70,7 @@ func EventEditor(c *ui.Context, ev *CalendarEvent, opts EventEditorOptions) Even key string t *time.Time }{{"event_editor.starts", &dr.Start}, {"event_editor.ends", &dr.End}} { - input.FormField(c, core.Msg(c, f.key), input.FormFieldOptions{Error: problems[f.key]}, func() *ui.Element { + input.FormField(c, core.Msg(c, f.key), input.FormFieldOptions{Error: problems[f.key]}, func() ui.Element { if dr.AllDay { day := core.DateOf(f.t.In(loc)) r := DatePicker(c, &day, DatePickerOptions{Label: core.Msg(c, f.key), WeekStart: opts.WeekStart, Disabled: opts.Disabled}) @@ -84,10 +84,10 @@ func EventEditor(c *ui.Context, ev *CalendarEvent, opts EventEditorOptions) Even }) } }) - input.FormField(c, core.Msg(c, "event_editor.where"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, core.Msg(c, "event_editor.where"), input.FormFieldOptions{}, func() ui.Element { return input.TextInput(c, &dr.Location, input.TextInputOptions{Label: core.Msg(c, "event_editor.where"), Disabled: opts.Disabled}).Input }) - input.FormField(c, core.Msg(c, "event_editor.guests"), input.FormFieldOptions{Description: core.Msg(c, "event_editor.guests_help")}, func() *ui.Element { + input.FormField(c, core.Msg(c, "event_editor.guests"), input.FormFieldOptions{Description: core.Msg(c, "event_editor.guests_help")}, func() ui.Element { names := make([]string, len(dr.Attendees)) for i, a := range dr.Attendees { names[i] = a.Name @@ -101,11 +101,11 @@ func EventEditor(c *ui.Context, ev *CalendarEvent, opts EventEditorOptions) Even if len(dr.Attendees) > 0 { AttendeeList(c, dr.Attendees, AttendeeListOptions{Disabled: opts.Disabled}) } - input.FormField(c, core.Msg(c, "event_editor.reminders"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, core.Msg(c, "event_editor.reminders"), input.FormFieldOptions{}, func() ui.Element { return ReminderPicker(c, &dr.Reminders, ReminderPickerOptions{Disabled: opts.Disabled}).Element }) var rec RecurrenceResult - input.FormField(c, core.Msg(c, "recurrence_editor.repeat"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, core.Msg(c, "recurrence_editor.repeat"), input.FormFieldOptions{}, func() ui.Element { rec = RecurrenceEditor(c, &dr.Repeat, RecurrenceEditorOptions{Start: core.DateOf(dr.Start.In(loc)), WeekStart: opts.WeekStart, Disabled: opts.Disabled}) return rec.Element }) diff --git a/datetime/event_popover.go b/datetime/event_popover.go index d5da8d8..ced5ea9 100644 --- a/datetime/event_popover.go +++ b/datetime/event_popover.go @@ -26,7 +26,7 @@ type EventPopoverOptions struct { // EventPopoverResult is the popover and the action the user chose. type EventPopoverResult struct { - Element *ui.Element + Element ui.Element edit bool del bool } @@ -38,7 +38,7 @@ func (r EventPopoverResult) Edit() bool { return r.edit } func (r EventPopoverResult) Delete() bool { return r.del } // EventPopover summarizes an event beside anchor while *open. -func EventPopover(c *ui.Context, anchor *ui.Element, open *bool, ev CalendarEvent, opts EventPopoverOptions) EventPopoverResult { +func EventPopover(c *ui.Context, anchor ui.Element, open *bool, ev CalendarEvent, opts EventPopoverOptions) EventPopoverResult { loc := eventLocation("EventPopover", opts.Location) k := core.Tokens(c) d := core.Density(c) @@ -47,7 +47,7 @@ func EventPopover(c *ui.Context, anchor *ui.Element, open *bool, ev CalendarEven slog.Debug("mujica: event_popover close", "disabled", true) *open = false } - r.Element = ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { + r.Element = ui.PopoverBase(c, anchor, open, func(panel ui.Element) { core.OverlayStyle(c, panel, core.OverlayEnter(c, panel)) ww, wh := c.Size() panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit() * 4).Gap(d.Unit() * 2).Width(min(d.ControlHeight()*9, ww-16)).MaxHeight(wh - 16). diff --git a/datetime/recurrence_editor.go b/datetime/recurrence_editor.go index 9e441fb..b001782 100644 --- a/datetime/recurrence_editor.go +++ b/datetime/recurrence_editor.go @@ -55,7 +55,7 @@ type RecurrenceEditorOptions struct { // RecurrenceResult is the editor, its change and its problem. type RecurrenceResult struct { - Element *ui.Element + Element ui.Element changed bool problem string } diff --git a/datetime/relative_time.go b/datetime/relative_time.go index fb34ba8..1105eb1 100644 --- a/datetime/relative_time.go +++ b/datetime/relative_time.go @@ -19,7 +19,7 @@ type RelativeTimeOptions struct { } // RelativeTime shows t as "3 minutes ago", refreshing while shown. -func RelativeTime(c *ui.Context, t time.Time, opts RelativeTimeOptions) *ui.Element { +func RelativeTime(c *ui.Context, t time.Time, opts RelativeTimeOptions) ui.Element { loc := eventLocation("RelativeTime", opts.Location) now := opts.Now if now.IsZero() { diff --git a/datetime/time_picker.go b/datetime/time_picker.go index 2c839ab..5c01a90 100644 --- a/datetime/time_picker.go +++ b/datetime/time_picker.go @@ -192,7 +192,7 @@ func timeParts(cfg timeConfig) []timePart { } // timeEditor draws segments and popup; reports edits and invalid entry. -func timeEditor(c *ui.Context, draft *TimeOfDay, cfg timeConfig) (field *ui.Element, edited bool, bad string) { +func timeEditor(c *ui.Context, draft *TimeOfDay, cfg timeConfig) (field ui.Element, edited bool, bad string) { k := core.Tokens(c) d := core.Density(c) field = core.DatetimeField(c, ui.Row(c), cfg.problem != "", cfg.readOnly).Gap(d.Unit() * 2) @@ -268,7 +268,7 @@ func timeLimits(part timePart, cfg timeConfig) (lo, hi int) { } // timeSegment draws one segment; done reports a complete valid entry, bad an invalid one. -func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig, bad *timeBad, edited *bool) (seg *ui.Element, done bool) { +func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig, bad *timeBad, edited *bool) (seg ui.Element, done bool) { k := core.Tokens(c) d := core.Density(c) name := core.Msg(c, timePartNames[part]) diff --git a/datetime/timezone_select.go b/datetime/timezone_select.go index a97378c..ea5c3a0 100644 --- a/datetime/timezone_select.go +++ b/datetime/timezone_select.go @@ -114,7 +114,7 @@ func TimezoneSelect(c *ui.Context, zone *string, opts TimezoneSelectOptions) cor core.DatetimePopup(c, field, open, false, func() { list := timezoneFilter(zones, common, *query, now) in := input.TextInput(c, query, input.TextInputOptions{Placeholder: core.Msg(c, "timezone_select.search"), Label: core.Msg(c, "timezone_select.search")}) - in.Input.AutoFocus() + core.AutoFocus(c, in.Input) if in.Changed() { *hi = 0 } diff --git a/devtools/alert_list.go b/devtools/alert_list.go index c6567a2..6db65b6 100644 --- a/devtools/alert_list.go +++ b/devtools/alert_list.go @@ -42,7 +42,7 @@ type AlertListOptions struct { // AlertListResult reports this frame's request. type AlertListResult struct { - Element *ui.Element + Element ui.Element // Ack and Silence report requested alert IDs; empty means idle. Ack, Silence string } @@ -97,7 +97,7 @@ func alertRow(c *ui.Context, a AlertItem, locked bool) (ack, silence bool) { sev := [...]core.Severity{core.SeverityInfo, core.SeverityWarning, core.SeverityError}[a.Level] word := core.Msg(c, [...]string{"alert_list.info", "alert_list.warning", "alert_list.critical"}[a.Level]) silenced := a.Silence.After(c.Now()) - row := ui.Row(c).Key(a.ID).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3). + row := ui.Row(c.Key(a.ID)).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3). Label(word + ", " + a.Title) if tone, _ := core.SeverityLook(k, sev); a.Level == AlertCritical && !a.Acked { row.DrawOver(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y, W: 3, H: r.H}, tone, 0) }) diff --git a/devtools/api_request_builder.go b/devtools/api_request_builder.go index 4566bdd..3c4c966 100644 --- a/devtools/api_request_builder.go +++ b/devtools/api_request_builder.go @@ -36,7 +36,7 @@ type ApiRequestBuilderOptions struct { // ApiRequestResult reports this frame's events. type ApiRequestResult struct { - Element *ui.Element + Element ui.Element // Send asks the caller to send the request. Send bool Changed bool diff --git a/devtools/devtools_parts.go b/devtools/devtools_parts.go index 795b665..4edc18f 100644 --- a/devtools/devtools_parts.go +++ b/devtools/devtools_parts.go @@ -27,6 +27,6 @@ func devtoolsSeverity(c *ui.Context, s core.Severity, word string) { } // devtoolsTitle is a serif panel title. -func devtoolsTitle(c *ui.Context, s string) *ui.Element { +func devtoolsTitle(c *ui.Context, s string) ui.Element { return ui.Text(c, s).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600) } diff --git a/devtools/helpers_test.go b/devtools/helpers_test.go index d1ac295..0d965fd 100644 --- a/devtools/helpers_test.go +++ b/devtools/helpers_test.go @@ -16,15 +16,14 @@ func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) { snaptest.Run(t, name, w, h, snapshotTheme, view, nil) } -// overlayUntil draws frames until cond holds, for at most 5 s of wall time. +// overlayUntil advances the Tester clock until cond holds, for at most 5 s. func overlayUntil(tt *ui.Tester, cond func() bool) bool { - end := time.Now().Add(5 * time.Second) + end := tt.Now().Add(5 * time.Second) for !cond() { - if time.Now().After(end) { + if tt.Now().After(end) { return false } - time.Sleep(2 * time.Millisecond) - tt.Frame() + tt.Advance(2 * time.Millisecond) } return true } diff --git a/devtools/incident_card.go b/devtools/incident_card.go index 0db3a6e..d405042 100644 --- a/devtools/incident_card.go +++ b/devtools/incident_card.go @@ -49,7 +49,7 @@ type Incident struct { } // IncidentCard shows an incident's title, status, impact and update timeline. -func IncidentCard(c *ui.Context, inc Incident) *ui.Element { +func IncidentCard(c *ui.Context, inc Incident) ui.Element { if inc.Title == "" { panic("mujica: IncidentCard needs a Title") } diff --git a/devtools/json_editor.go b/devtools/json_editor.go index 1d44843..7c6e343 100644 --- a/devtools/json_editor.go +++ b/devtools/json_editor.go @@ -30,7 +30,7 @@ type JsonEditorOptions struct { // JsonEditorResult reports this frame's events and the parse state. type JsonEditorResult struct { - Element *ui.Element + Element ui.Element Changed bool // Valid reports *text parses as one JSON value. Valid bool diff --git a/devtools/json_viewer.go b/devtools/json_viewer.go index 431040f..00e1f24 100644 --- a/devtools/json_viewer.go +++ b/devtools/json_viewer.go @@ -43,7 +43,7 @@ type JsonViewerOptions struct { // JsonViewerResult reports this frame's events. type JsonViewerResult struct { - Element *ui.Element + Element ui.Element // Copied is the path written to the clipboard, else "". Copied string } diff --git a/devtools/key_value_input.go b/devtools/key_value_input.go index 44c24ed..f866e57 100644 --- a/devtools/key_value_input.go +++ b/devtools/key_value_input.go @@ -58,7 +58,7 @@ func KeyValueInput(c *ui.Context, rows *[]KeyValue, opts KeyValueInputOptions) c r.Element.Children(func() { agent.AgentRows(c, len(*rows), func(i int) { kv := &(*rows)[i] - ui.Row(c).Key(i).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Row(c.Key(i)).Gap(u * 2).AlignItems(ui.Center).Children(func() { name := fmt.Sprintf(core.Msg(c, "key_value_input.row"), i+1) on := kv.Enabled box := input.Checkbox(c, &on, "", input.CheckboxOptions{Disabled: locked}).Label(name) diff --git a/devtools/refresh_interval_selector.go b/devtools/refresh_interval_selector.go index 1d6e216..03cead9 100644 --- a/devtools/refresh_interval_selector.go +++ b/devtools/refresh_interval_selector.go @@ -23,7 +23,7 @@ type RefreshIntervalSelectorOptions struct { // RefreshResult reports this frame's events. type RefreshResult struct { - Element *ui.Element + Element ui.Element // Refresh requests caller loading, manually or after the interval. Refresh bool // Changed reports a new interval. diff --git a/devtools/refresh_interval_selector_test.go b/devtools/refresh_interval_selector_test.go index 0b1b7fb..93f1a87 100644 --- a/devtools/refresh_interval_selector_test.go +++ b/devtools/refresh_interval_selector_test.go @@ -31,8 +31,7 @@ func TestRefreshIntervalSelectorManualAndAuto(t *testing.T) { if refreshes != 1 { t.Fatalf("manual refreshes %d", refreshes) } - time.Sleep(1100 * time.Millisecond) - tt.Frame() + tt.Advance(1100 * time.Millisecond) if refreshes != 1 { t.Fatal("refreshed while off") } @@ -41,11 +40,11 @@ func TestRefreshIntervalSelectorManualAndAuto(t *testing.T) { if interval != time.Second || changes != 1 { t.Fatalf("interval %v changes %d", interval, changes) } - start := time.Now() + start := tt.Now() if !overlayUntil(tt, func() bool { return refreshes == 2 }) { t.Fatal("no automatic refresh") } - if time.Since(start) < 900*time.Millisecond { + if tt.Now().Sub(start) < 900*time.Millisecond { t.Fatal("refreshed before the interval") } } @@ -56,15 +55,13 @@ func TestRefreshIntervalSelectorRefreshingAndDisabled(t *testing.T) { refreshes, changes := 0, 0 tt := refreshTester(&interval, &opts, &refreshes, &changes) _ = tt.Click("Refresh") - time.Sleep(1100 * time.Millisecond) - tt.Frame() + tt.Advance(1100 * time.Millisecond) if refreshes != 0 || !tt.HasText("Refresh") { t.Fatalf("refreshing: %d", refreshes) } opts = RefreshIntervalSelectorOptions{Intervals: []time.Duration{time.Second}, Disabled: true} tt.Frame() - time.Sleep(1100 * time.Millisecond) - tt.Frame() + tt.Advance(1100 * time.Millisecond) _ = tt.Click("Refresh") if refreshes != 0 { t.Fatalf("disabled refreshed %d", refreshes) diff --git a/devtools/resource_gauge.go b/devtools/resource_gauge.go index 965506a..1106ac4 100644 --- a/devtools/resource_gauge.go +++ b/devtools/resource_gauge.go @@ -66,7 +66,7 @@ func ResourceGauge(c *ui.Context, readings []ResourceReading, opts ResourceGauge panic(fmt.Sprintf("mujica: ResourceGauge reading %d needs a Name and 0 <= Used <= Total", i)) } frac := r.Used / r.Total - ui.Column(c).Key(r.Name).Width(200).Gap(u).AlignItems(ui.Center).Children(func() { + ui.Column(c.Key(r.Name)).Width(200).Gap(u).AlignItems(ui.Center).Children(func() { ui.Text(c, r.Name).FontWeight(600) gauge := chart.Gauge(c, frac*100, chart.GaugeOptions{ Frame: chart.ChartFrame{Label: r.Name, Height: h, Status: opts.Status, Error: opts.Error}, Min: 0, Max: 100, Unit: "%", diff --git a/devtools/service_status.go b/devtools/service_status.go index 843cfdb..2720aad 100644 --- a/devtools/service_status.go +++ b/devtools/service_status.go @@ -35,7 +35,7 @@ type ServiceStatusOptions struct { } // ServiceStatus lists service health and an overall summary. -func ServiceStatus(c *ui.Context, services []ServiceItem, opts ServiceStatusOptions) *ui.Element { +func ServiceStatus(c *ui.Context, services []ServiceItem, opts ServiceStatusOptions) ui.Element { if opts.MaxHeight < 0 { panic("mujica: ServiceStatus MaxHeight is negative") } @@ -75,7 +75,7 @@ func ServiceStatus(c *ui.Context, services []ServiceItem, opts ServiceStatusOpti agent.AgentRows(c, len(services), func(i int) { s := services[i] word := serviceWord(c, s.Health) - ui.Row(c).Key(i).Gap(u*3).AlignItems(ui.Center).MinHeight(core.Density(c).ControlHeight()).Padding(u, u*3). + ui.Row(c.Key(i)).Gap(u*3).AlignItems(ui.Center).MinHeight(core.Density(c).ControlHeight()).Padding(u, u*3). Label(s.Name + ", " + word).Children(func() { ui.Column(c).Grow(1).Shrink(1).Children(func() { ui.Text(c, s.Name).SingleLine() diff --git a/devtools/system_monitor.go b/devtools/system_monitor.go index 5659739..2b913e6 100644 --- a/devtools/system_monitor.go +++ b/devtools/system_monitor.go @@ -56,7 +56,7 @@ func SystemMonitor(c *ui.Context, times []time.Time, metrics []MonitorMetric, op return } for _, m := range metrics { - panel := ui.Column(c).Key(m.Name).Gap(u) + panel := ui.Column(c.Key(m.Name)).Gap(u) series := ui.Local(panel, "mujica.monitor.series", func() []chart.ChartSeries { return []chart.ChartSeries{{Name: m.Name, Values: m.Samples}} }) (*series)[0].Name, (*series)[0].Values = m.Name, m.Samples panel.Children(func() { diff --git a/devtools/trace_waterfall.go b/devtools/trace_waterfall.go index fb4b0f3..47fe58a 100644 --- a/devtools/trace_waterfall.go +++ b/devtools/trace_waterfall.go @@ -142,7 +142,7 @@ func traceSpanRow(c *ui.Context, s WaterfallSpan, depth int, total time.Duration k := core.Tokens(c) d := core.Density(c) u := d.Unit() - row := ui.ButtonBase(c).Key(s.ID).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()).Padding(0, u*2). + row := ui.ButtonBase(c.Key(s.ID)).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()).Padding(0, u*2). Label(fmt.Sprintf("%s, %s, %s", s.Name, s.Service, agent.AgentDuration(s.Duration))) internal.Ring(row, k.Focus, theme.ControlRadius) if selected { diff --git a/display/avatar.go b/display/avatar.go index 3ab4eaa..0ec0ec5 100644 --- a/display/avatar.go +++ b/display/avatar.go @@ -18,7 +18,7 @@ type AvatarOptions struct { } // Avatar shows a person as picture, initials or icon. -func Avatar(c *ui.Context, name string, opts AvatarOptions) *ui.Element { +func Avatar(c *ui.Context, name string, opts AvatarOptions) ui.Element { size := opts.Size if size < 0 { panic("mujica: Avatar Size must not be negative") diff --git a/display/avatar_group.go b/display/avatar_group.go index 8be9c23..cc9cc27 100644 --- a/display/avatar_group.go +++ b/display/avatar_group.go @@ -29,7 +29,7 @@ type avatarGroupKey struct{} // AvatarGroupView is a shown AvatarGroup and its full list's state. type AvatarGroupView struct { - Element *ui.Element + Element ui.Element // Open reports whether the full list shows. Open bool // Toggled reports the full list opened or closed this frame. @@ -60,7 +60,7 @@ func AvatarGroup(c *ui.Context, items []AvatarItem, opts AvatarGroupOptions) Ava was := *open row.Children(func() { for i, it := range items[:shown] { - cell := ui.Box(c).Key(i).Padding(ring).Radius(size/2 + ring).Background(k.Background).Shrink(0) + cell := ui.Box(c.Key(i)).Padding(ring).Radius(size/2 + ring).Background(k.Background).Shrink(0) if i > 0 { cell.Margin(0, 0, 0, -size/4) } @@ -72,7 +72,7 @@ func AvatarGroup(c *ui.Context, items []AvatarItem, opts AvatarGroupOptions) Ava return } more := avatarGroupMore(c, k, open, size, ring, rest, len(items)) - ui.PopoverBase(c, more, open, func(panel *ui.Element) { + ui.PopoverBase(c, more, open, func(panel ui.Element) { avatarGroupList(c, k, panel, items) }) }) @@ -84,10 +84,10 @@ func AvatarGroup(c *ui.Context, items []AvatarItem, opts AvatarGroupOptions) Ava } // avatarGroupMore is the "+N" disclosure toggling *open. -func avatarGroupMore(c *ui.Context, k theme.Tokens, open *bool, size, ring float32, rest, total int) *ui.Element { +func avatarGroupMore(c *ui.Context, k theme.Tokens, open *bool, size, ring float32, rest, total int) ui.Element { hit := max(size+2*ring, theme.MinHitSize) - var b *ui.Element - ui.Box(c).Key("more").Margin(0, 0, 0, -size/4).Shrink(0).Children(func() { + var b ui.Element + ui.Box(c.Key("more")).Margin(0, 0, 0, -size/4).Shrink(0).Children(func() { b = ui.CollapsibleBase(c, open).Trigger.Size(hit, hit).Center().Radius(hit / 2). Label(fmt.Sprintf(core.Msg(c, "avatar_group.more"), total)) internal.Ring(b, k.Focus, hit/2) @@ -111,7 +111,7 @@ func avatarGroupMore(c *ui.Context, k theme.Tokens, open *bool, size, ring float return b } -func avatarGroupList(c *ui.Context, k theme.Tokens, panel *ui.Element, items []AvatarItem) { +func avatarGroupList(c *ui.Context, k theme.Tokens, panel ui.Element, items []AvatarItem) { u := core.Density(c).Unit() panel.Margin(u, 0, 0, 0).Padding(u*2).Radius(theme.PopupRadius).Background(k.Surface). Border(theme.BorderWidth, k.Border).MinWidth(200) @@ -121,7 +121,7 @@ func avatarGroupList(c *ui.Context, k theme.Tokens, panel *ui.Element, items []A ui.Scroll(c).MaxHeight(240).Children(func() { ui.Column(c).Gap(u * 2).Role(ui.RoleList).Children(func() { for i, it := range items { - ui.Row(c).Key(i).Gap(u * 2).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() { + ui.Row(c.Key(i)).Gap(u * 2).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() { Avatar(c, it.Name, AvatarOptions{Image: it.Image, Size: 24}) ui.Text(c, it.Name).SingleLine() }) diff --git a/display/badge.go b/display/badge.go index 850c324..8afcef2 100644 --- a/display/badge.go +++ b/display/badge.go @@ -48,7 +48,7 @@ type BadgeOptions struct { } // Badge shows text or a count, inline or cornered. -func Badge(c *ui.Context, opts BadgeOptions, content func()) *ui.Element { +func Badge(c *ui.Context, opts BadgeOptions, content func()) ui.Element { if opts.Count < 0 || opts.Max < 0 { panic("mujica: Badge Count and Max must not be negative") } @@ -92,7 +92,7 @@ func badgeText(opts BadgeOptions) (shown, full string) { return full, full } -func badgePill(c *ui.Context, opts BadgeOptions, shown, full string) *ui.Element { +func badgePill(c *ui.Context, opts BadgeOptions, shown, full string) ui.Element { k := core.Tokens(c) bg, fg, border := badgeColors(k, opts.Tone) label := full diff --git a/display/example_display_test.go b/display/example_display_test.go index 6f0846f..61f95c5 100644 --- a/display/example_display_test.go +++ b/display/example_display_test.go @@ -101,7 +101,7 @@ func ExampleTag() { ui.Row(c).Gap(8).Children(func() { for i, t := range tags { var v display.TagView - ui.Box(c).Key(t).Children(func() { + ui.Box(c.Key(t)).Children(func() { v = display.Tag(c, t, display.TagOptions{Icon: "star", Clickable: true, Closable: true}) }) if v.Closed { diff --git a/display/heading.go b/display/heading.go index 85bdb40..867bd89 100644 --- a/display/heading.go +++ b/display/heading.go @@ -19,7 +19,7 @@ type HeadingOptions struct { } // Heading shows a serif title scaled with desktop text. -func Heading(c *ui.Context, title string, opts HeadingOptions) *ui.Element { +func Heading(c *ui.Context, title string, opts HeadingOptions) ui.Element { k := core.Tokens(c) size := headingSize(opts.Level) u := core.Density(c).Unit() diff --git a/display/icon.go b/display/icon.go index 8121aa5..fd16bd7 100644 --- a/display/icon.go +++ b/display/icon.go @@ -36,7 +36,7 @@ type IconOptions struct { type iconMissingKey struct{} // Icon shows a named icon; missing ones show a fallback. -func Icon(c *ui.Context, name string, opts IconOptions) *ui.Element { +func Icon(c *ui.Context, name string, opts IconOptions) ui.Element { if name == "" { panic("mujica: Icon needs a name") } @@ -62,7 +62,7 @@ func Icon(c *ui.Context, name string, opts IconOptions) *ui.Element { return e } -func iconMissing(c *ui.Context, name string, size float32, k theme.Tokens) *ui.Element { +func iconMissing(c *ui.Context, name string, size float32, k theme.Tokens) ui.Element { text := fmt.Sprintf(core.Msg(c, "icon.missing"), name) e := ui.Box(c).Size(size, size).Shrink(0).Role(ui.RoleImage).Label(text).Tooltip(text) logged := ui.Local(e, iconMissingKey{}, func() string { return "" }) diff --git a/display/image.go b/display/image.go index 0572e1d..67e9b3f 100644 --- a/display/image.go +++ b/display/image.go @@ -26,7 +26,7 @@ type ImageOptions struct { // ImageView is a shown Image and this frame's events. type ImageView struct { - Element *ui.Element + Element ui.Element // Retry reports the error state's retry button was pressed. Retry bool } @@ -46,7 +46,7 @@ func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView { } loaded := opts.Err == nil && !opts.Loading && src != nil var v ImageView - v.Element = imageSized(c, src, opts, func(box, pic *ui.Element) { + v.Element = imageSized(c, src, opts, func(box, pic ui.Element) { if loaded { if opts.Alt != "" { pic.Label(opts.Alt) @@ -60,7 +60,7 @@ func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView { } // imageLayers builds the state layers over the hidden picture. -func imageLayers(c *ui.Context, box *ui.Element, opts ImageOptions) (retry bool) { +func imageLayers(c *ui.Context, box ui.Element, opts ImageOptions) (retry bool) { k := core.Tokens(c) u := core.Density(c).Unit() room := box.Bounds() @@ -99,7 +99,7 @@ func imageLayers(c *ui.Context, box *ui.Element, opts ImageOptions) (retry bool) } // imageSized builds the outer box and its picture, sized alike in every state. -func imageSized(c *ui.Context, src ui.ImageSource, opts ImageOptions, layers func(box, pic *ui.Element)) *ui.Element { +func imageSized(c *ui.Context, src ui.ImageSource, opts ImageOptions, layers func(box, pic ui.Element)) ui.Element { if src == nil { src = imageBlank } @@ -123,7 +123,7 @@ func imageSized(c *ui.Context, src ui.ImageSource, opts ImageOptions, layers fun } // imageRetry is the 28 DIP icon-only retry button. -func imageRetry(c *ui.Context, k theme.Tokens, problem string) *ui.Element { +func imageRetry(c *ui.Context, k theme.Tokens, problem string) ui.Element { b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Radius(theme.ControlRadius). Label(core.Msg(c, "image.retry")).Description(problem).Tooltip(problem) bg := k.Surface diff --git a/display/kbd.go b/display/kbd.go index 87f8e3c..7ab024b 100644 --- a/display/kbd.go +++ b/display/kbd.go @@ -11,7 +11,7 @@ import ( ) // Kbd shows keys as caps; "Mod" is Command or Ctrl. -func Kbd(c *ui.Context, keys ...string) *ui.Element { +func Kbd(c *ui.Context, keys ...string) ui.Element { if len(keys) == 0 { panic("mujica: Kbd needs at least one key") } @@ -31,7 +31,7 @@ func Kbd(c *ui.Context, keys ...string) *ui.Element { ui.Text(c, "+").FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) } face, _ := KbdKey(key, mac) - ui.Box(c).Key(i).Row().Center().MinWidth(core.FontSize(c, 20)).MinHeight(core.FontSize(c, 20)).Padding(0, 5). + ui.Box(c.Key(i)).Row().Center().MinWidth(core.FontSize(c, 20)).MinHeight(core.FontSize(c, 20)).Padding(0, 5). Radius(theme.ControlRadius).Background(k.Surface).BorderColor(k.ControlBorder). BorderWidth(1, 1, 2, 1).Children(func() { ui.Text(c, face).Font(theme.MonoFont).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Text).SingleLine() diff --git a/display/link.go b/display/link.go index 7874756..605f45d 100644 --- a/display/link.go +++ b/display/link.go @@ -21,7 +21,7 @@ type LinkOptions struct { } // Link is underlined text; malformed URLs show an error instead. -func Link(c *ui.Context, label string, opts LinkOptions) *ui.Element { +func Link(c *ui.Context, label string, opts LinkOptions) ui.Element { if label == "" { panic("mujica: Link needs a label") } @@ -40,7 +40,7 @@ func Link(c *ui.Context, label string, opts LinkOptions) *ui.Element { } } } - var l *ui.Element + var l ui.Element ui.Column(c).Gap(u).AlignItems(ui.Start).Children(func() { ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() { l = ui.Link(c, label, target).Underline().DecorationThickness(1) diff --git a/display/tag.go b/display/tag.go index db493c7..94b5c23 100644 --- a/display/tag.go +++ b/display/tag.go @@ -24,7 +24,7 @@ type TagOptions struct { // TagView is a shown Tag and this frame's events. type TagView struct { - Element *ui.Element + Element ui.Element // Clicked reports the label was activated, not the remove button. Clicked bool // Closed reports the remove button, Delete or Backspace. @@ -43,7 +43,7 @@ func Tag(c *ui.Context, label string, opts TagOptions) TagView { Background(k.Surface).Border(theme.BorderWidth, k.Border).Shrink(0).Disabled(opts.Disabled) v.Element = box box.Children(func() { - var main *ui.Element + var main ui.Element if opts.Clickable { main = ui.ButtonBase(c).Label(label) internal.Ring(main, k.Focus, theme.ControlRadius) @@ -89,7 +89,7 @@ func Tag(c *ui.Context, label string, opts TagOptions) TagView { return v } -func tagClose(c *ui.Context, k theme.Tokens, label string) *ui.Element { +func tagClose(c *ui.Context, k theme.Tokens, label string) ui.Element { b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize). Radius(theme.ControlRadius).Label(fmt.Sprintf(core.Msg(c, "tag.remove"), label)) fg := k.TextMuted diff --git a/display/text.go b/display/text.go index 8320798..cb6dc25 100644 --- a/display/text.go +++ b/display/text.go @@ -19,7 +19,7 @@ type TextOptions struct { } // Text shows body text wrapping at its width. -func Text(c *ui.Context, s string, opts TextOptions) *ui.Element { +func Text(c *ui.Context, s string, opts TextOptions) ui.Element { if opts.MaxLines < 0 { panic("mujica: Text MaxLines must not be negative") } @@ -49,7 +49,7 @@ type EditableTextOptions struct { // EditableTextView is a shown EditableText and this frame's outcome. type EditableTextView struct { - Element *ui.Element + Element ui.Element // Canceled reports Escape discarded an edit. Canceled bool changed bool @@ -118,7 +118,7 @@ func editableTextField(c *ui.Context, k theme.Tokens, value *string, opts Editab return v } } - var in *ui.Element + var in ui.Element box.Children(func() { in = ui.TextInputBase(c, &st.draft).Grow(1).MinWidth(theme.MinHitSize*3).Padding(pad, pad*2). Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.Accent).TextColor(k.Text). diff --git a/docs/compat.md b/docs/compat.md index a193467..ec9df7b 100644 --- a/docs/compat.md +++ b/docs/compat.md @@ -4,8 +4,8 @@ | 项 | 值 | | --- | --- | -| Go | 1.27.1 及以上(MyGo v0.2.10 的 go.mod 要求) | -| MyGo | v0.2.10,commit 459b512,固定在 go.mod 与 go.sum | +| Go | 1.27.1 及以上(MyGo v0.5.5 的 go.mod 要求) | +| MyGo | v0.5.5,commit 4d8673c,固定在 go.mod 与 go.sum | | 运行时依赖 | macOS:系统框架。Windows:无需 WebView2。Linux:GTK 3(MyGo 通过 GtkGLArea 绘制) | ## 已实测环境 diff --git a/docs/components/002-title-bar.md b/docs/components/002-title-bar.md index ca10ee7..94f736e 100644 --- a/docs/components/002-title-bar.md +++ b/docs/components/002-title-bar.md @@ -12,7 +12,7 @@ layout.TitleBar(c, "Court Records", layout.TitleBarOptions{ }) ``` -签名:`func TitleBar(c *ui.Context, title string, opts TitleBarOptions) *ui.Element` +签名:`func TitleBar(c *ui.Context, title string, opts TitleBarOptions) ui.Element` ## 参数 diff --git a/docs/components/004-container.md b/docs/components/004-container.md index a235f16..c286510 100644 --- a/docs/components/004-container.md +++ b/docs/components/004-container.md @@ -12,7 +12,7 @@ layout.Container(c, layout.ContainerOptions{MaxWidth: 320, Center: true}, func() }) ``` -签名:`func Container(c *ui.Context, opts ContainerOptions, children func()) *ui.Element` +签名:`func Container(c *ui.Context, opts ContainerOptions, children func()) ui.Element` ## 参数 diff --git a/docs/components/005-stack.md b/docs/components/005-stack.md index 55c3313..0ab762d 100644 --- a/docs/components/005-stack.md +++ b/docs/components/005-stack.md @@ -18,7 +18,7 @@ layout.Stack(c, layout.StackOptions{Horizontal: true, Gap: layout.SpaceM}, func( 签名: -- `func Stack(c *ui.Context, opts StackOptions, children func()) *ui.Element` +- `func Stack(c *ui.Context, opts StackOptions, children func()) ui.Element` ## 参数 diff --git a/docs/components/006-grid.md b/docs/components/006-grid.md index 8c48bf1..89ca75a 100644 --- a/docs/components/006-grid.md +++ b/docs/components/006-grid.md @@ -16,8 +16,8 @@ layout.Grid(c, layout.GridOptions{Columns: []ui.Track{ui.Fixed(96), ui.Fr(1)}}, 签名: -- `func Grid(c *ui.Context, opts GridOptions, children func()) *ui.Element` -- `func GridCell(c *ui.Context, opts GridCellOptions, build func()) *ui.Element` +- `func Grid(c *ui.Context, opts GridOptions, children func()) ui.Element` +- `func GridCell(c *ui.Context, opts GridCellOptions, build func()) ui.Element` ## 参数 diff --git a/docs/components/007-scroll-area.md b/docs/components/007-scroll-area.md index 8cf78c0..79abad6 100644 --- a/docs/components/007-scroll-area.md +++ b/docs/components/007-scroll-area.md @@ -15,7 +15,7 @@ layout.ScrollArea(c, &offset, layout.ScrollAreaOptions{Label: "Log"}, func() { }).Height(200) ``` -签名:`func ScrollArea(c *ui.Context, state *ui.ScrollState, opts ScrollAreaOptions, children func()) *ui.Element` +签名:`func ScrollArea(c *ui.Context, state *ui.ScrollState, opts ScrollAreaOptions, children func()) ui.Element` ## 参数 diff --git a/docs/components/009-aspect-ratio.md b/docs/components/009-aspect-ratio.md index db4be13..4b31d15 100644 --- a/docs/components/009-aspect-ratio.md +++ b/docs/components/009-aspect-ratio.md @@ -14,7 +14,7 @@ ui.Box(c).Size(320, 240).Children(func() { }) ``` -签名:`func AspectRatio(c *ui.Context, opts AspectRatioOptions, child func()) *ui.Element` +签名:`func AspectRatio(c *ui.Context, opts AspectRatioOptions, child func()) ui.Element` ## 参数 diff --git a/docs/components/010-divider.md b/docs/components/010-divider.md index 23bff3c..0a1a978 100644 --- a/docs/components/010-divider.md +++ b/docs/components/010-divider.md @@ -13,7 +13,7 @@ ui.Column(c).Padding(16).Gap(12).Children(func() { }) ``` -签名:`func Divider(c *ui.Context, opts DividerOptions) *ui.Element` +签名:`func Divider(c *ui.Context, opts DividerOptions) ui.Element` ## 参数 diff --git a/docs/components/011-text.md b/docs/components/011-text.md index 1cf5667..d205039 100644 --- a/docs/components/011-text.md +++ b/docs/components/011-text.md @@ -21,7 +21,7 @@ view := func(c *ui.Context) { ## 参数 -`func Text(c *ui.Context, s string, opts TextOptions) *ui.Element` +`func Text(c *ui.Context, s string, opts TextOptions) ui.Element` | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/012-heading.md b/docs/components/012-heading.md index 0735bd3..25068ff 100644 --- a/docs/components/012-heading.md +++ b/docs/components/012-heading.md @@ -16,7 +16,7 @@ view := func(c *ui.Context) { ## 参数 -`func Heading(c *ui.Context, title string, opts HeadingOptions) *ui.Element` +`func Heading(c *ui.Context, title string, opts HeadingOptions) ui.Element` | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/013-icon.md b/docs/components/013-icon.md index a1efd0b..2ea159e 100644 --- a/docs/components/013-icon.md +++ b/docs/components/013-icon.md @@ -18,7 +18,7 @@ view := func(c *ui.Context) { ## 参数 -`func Icon(c *ui.Context, name string, opts IconOptions) *ui.Element` +`func Icon(c *ui.Context, name string, opts IconOptions) ui.Element` `name` 必填,空字符串 panic。可用名称见 `icons.Names()`。 diff --git a/docs/components/015-avatar.md b/docs/components/015-avatar.md index 0a91a5b..368cb66 100644 --- a/docs/components/015-avatar.md +++ b/docs/components/015-avatar.md @@ -18,7 +18,7 @@ view := func(c *ui.Context) { ## 参数 -`func Avatar(c *ui.Context, name string, opts AvatarOptions) *ui.Element` +`func Avatar(c *ui.Context, name string, opts AvatarOptions) ui.Element` | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/017-badge.md b/docs/components/017-badge.md index 64655ed..9964bb2 100644 --- a/docs/components/017-badge.md +++ b/docs/components/017-badge.md @@ -19,7 +19,7 @@ view := func(c *ui.Context) { ## 参数 -`func Badge(c *ui.Context, opts BadgeOptions, content func()) *ui.Element` +`func Badge(c *ui.Context, opts BadgeOptions, content func()) ui.Element` `Position` 为 `BadgeInline` 时 `content` 必须为 `nil`;为角落位置时 `content` 必须不为 `nil`。否则 panic。 diff --git a/docs/components/019-link.md b/docs/components/019-link.md index 33ca872..ced4025 100644 --- a/docs/components/019-link.md +++ b/docs/components/019-link.md @@ -19,7 +19,7 @@ view := func(c *ui.Context) { ## 参数 -`func Link(c *ui.Context, label string, opts LinkOptions) *ui.Element` +`func Link(c *ui.Context, label string, opts LinkOptions) ui.Element` `label` 必填,空字符串 panic。 diff --git a/docs/components/020-kbd.md b/docs/components/020-kbd.md index 08a53eb..f0a9443 100644 --- a/docs/components/020-kbd.md +++ b/docs/components/020-kbd.md @@ -18,7 +18,7 @@ view := func(c *ui.Context) { ## 参数 -`func Kbd(c *ui.Context, keys ...string) *ui.Element` +`func Kbd(c *ui.Context, keys ...string) ui.Element` 至少要传一个键,键名不能为空,否则 panic。键名不区分大小写: diff --git a/docs/components/021-button.md b/docs/components/021-button.md index b7e3490..0e24076 100644 --- a/docs/components/021-button.md +++ b/docs/components/021-button.md @@ -12,7 +12,7 @@ if input.Button(c, "Save", input.ButtonOptions{Icon: icons.Must("check")}).Click } ``` -签名:`func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element` +签名:`func Button(c *ui.Context, label string, opts ButtonOptions) ui.Element` ## 参数 diff --git a/docs/components/022-icon-button.md b/docs/components/022-icon-button.md index 6829cfe..9e4b660 100644 --- a/docs/components/022-icon-button.md +++ b/docs/components/022-icon-button.md @@ -12,7 +12,7 @@ if input.IconButton(c, icons.Must("settings"), "Settings", input.IconButtonOptio } ``` -签名:`func IconButton(c *ui.Context, icon *ui.SVG, label string, opts IconButtonOptions) *ui.Element` +签名:`func IconButton(c *ui.Context, icon *ui.SVG, label string, opts IconButtonOptions) ui.Element` ## 参数 diff --git a/docs/components/029-checkbox.md b/docs/components/029-checkbox.md index 190ec91..0196266 100644 --- a/docs/components/029-checkbox.md +++ b/docs/components/029-checkbox.md @@ -16,7 +16,7 @@ input.CheckboxGroup(c, "Notifications", []input.CheckboxItem{ 签名: ```go -func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *ui.Element +func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) ui.Element func CheckboxGroup(c *ui.Context, label string, items []CheckboxItem, opts CheckboxGroupOptions) ChoiceResult ``` diff --git a/docs/components/031-switch.md b/docs/components/031-switch.md index b0d094e..942bab5 100644 --- a/docs/components/031-switch.md +++ b/docs/components/031-switch.md @@ -19,7 +19,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func Switch(c *ui.Context, on *bool, label string, opts SwitchOptions) *ui.Element`。`label` 为空时请在返回值上调用 `Label` 命名。 +`func Switch(c *ui.Context, on *bool, label string, opts SwitchOptions) ui.Element`。`label` 为空时请在返回值上调用 `Label` 命名。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/032-toggle.md b/docs/components/032-toggle.md index 4c730c9..eb52984 100644 --- a/docs/components/032-toggle.md +++ b/docs/components/032-toggle.md @@ -17,7 +17,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func Toggle(c *ui.Context, on *bool, label string, opts ToggleOptions) *ui.Element`。`label` 与 `opts.Label` 至少提供一个,否则 panic。 +`func Toggle(c *ui.Context, on *bool, label string, opts ToggleOptions) ui.Element`。`label` 与 `opts.Label` 至少提供一个,否则 panic。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/041-form.md b/docs/components/041-form.md index 4518710..e7bdc5f 100644 --- a/docs/components/041-form.md +++ b/docs/components/041-form.md @@ -14,7 +14,7 @@ name := "" view := func(c *ui.Context) { core.Use(c, core.Settings{}) res := input.Form(c, &state, input.FormOptions{}, func(f *input.FormScope) { - input.FormItem(c, f, "name", &name, input.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element { + input.FormItem(c, f, "name", &name, input.FormItemOptions[string]{Label: "Name", Required: true}, func() ui.Element { return input.InputGroup(c, &name, input.InputGroupOptions{Invalid: state.Error("name") != ""}).Input }) }) diff --git a/docs/components/042-form-field.md b/docs/components/042-form-field.md index f347f32..1870822 100644 --- a/docs/components/042-form-field.md +++ b/docs/components/042-form-field.md @@ -12,7 +12,7 @@ email := "" view := func(c *ui.Context) { core.Use(c, core.Settings{}) - input.FormField(c, "Email", input.FormFieldOptions{Required: true, Description: "For receipts."}, func() *ui.Element { + input.FormField(c, "Email", input.FormFieldOptions{Required: true, Description: "For receipts."}, func() ui.Element { return input.InputGroup(c, &email, input.InputGroupOptions{}).Input }) } @@ -28,7 +28,7 @@ ui.Render(view, 400, 300, 1) | `FormFieldOptions.Description` | `string` | "" | 控件下方的说明。 | | `FormFieldOptions.Error` | `string` | "" | 控件下方的错误,并把控件标记为无效。 | | `FormFieldOptions.Horizontal` | `bool` | false | 标签在左、控件在右。 | -| `control` | `func() *ui.Element` | 必填 | 构建控件并返回要关联的可聚焦元素(如 `InputGroupParts.Input`);返回 nil 时 panic。 | +| `control` | `func() ui.Element` | 必填 | 构建控件并返回要关联的可聚焦元素(如 `InputGroupParts.Input`);返回零值 `ui.Element{}` 时 panic。 | ## 状态 diff --git a/docs/components/060-segmented-control.md b/docs/components/060-segmented-control.md index 737b11a..0547b67 100644 --- a/docs/components/060-segmented-control.md +++ b/docs/components/060-segmented-control.md @@ -15,7 +15,7 @@ if navigation.SegmentedControl(c, &view, []navigation.Segment{ } ``` -签名:`func SegmentedControl(c *ui.Context, selected *int, segments []Segment, opts SegmentedControlOptions) *ui.Element` +签名:`func SegmentedControl(c *ui.Context, selected *int, segments []Segment, opts SegmentedControlOptions) ui.Element` ## 参数 diff --git a/docs/components/065-navigation-menu.md b/docs/components/065-navigation-menu.md index 9ee8978..dcd49d0 100644 --- a/docs/components/065-navigation-menu.md +++ b/docs/components/065-navigation-menu.md @@ -16,7 +16,7 @@ navigation.NavigationMenu(c, router, []navigation.NavigationLink{ router.View(c, func(r *ui.Route) { ui.Text(c, r.Path()) }) ``` -签名:`func NavigationMenu(c *ui.Context, router *ui.Router, links []NavigationLink, opts NavigationMenuOptions) *ui.Element` +签名:`func NavigationMenu(c *ui.Context, router *ui.Router, links []NavigationLink, opts NavigationMenuOptions) ui.Element` ## 参数 diff --git a/docs/components/067-toolbar.md b/docs/components/067-toolbar.md index a130f15..42b0158 100644 --- a/docs/components/067-toolbar.md +++ b/docs/components/067-toolbar.md @@ -21,9 +21,9 @@ navigation.Toolbar(c, navigation.ToolbarOptions{Label: "Format"}, func() { 签名: -- `func Toolbar(c *ui.Context, opts ToolbarOptions, fn func()) *ui.Element` -- `func ToolbarButton(c *ui.Context, label string, opts ToolbarButtonOptions) *ui.Element` -- `func ToolbarGroup(c *ui.Context, fn func()) *ui.Element` +- `func Toolbar(c *ui.Context, opts ToolbarOptions, fn func()) ui.Element` +- `func ToolbarButton(c *ui.Context, label string, opts ToolbarButtonOptions) ui.Element` +- `func ToolbarGroup(c *ui.Context, fn func()) ui.Element` - `func ToolbarSeparator(c *ui.Context)` ## 参数 diff --git a/docs/components/069-card.md b/docs/components/069-card.md index d8dad86..c9d6df8 100644 --- a/docs/components/069-card.md +++ b/docs/components/069-card.md @@ -20,7 +20,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func Card(c *ui.Context, opts CardOptions, body func()) *ui.Element`,`body` 构建内容插槽。 +`func Card(c *ui.Context, opts CardOptions, body func()) ui.Element`,`body` 构建内容插槽。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/076-timeline.md b/docs/components/076-timeline.md index 29a3c9b..a510677 100644 --- a/docs/components/076-timeline.md +++ b/docs/components/076-timeline.md @@ -20,7 +20,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func Timeline(c *ui.Context, items []TimelineItem) *ui.Element`。`TimelineItem` 字段:`Time`、`Title`、`Content`、`Status`。 +`func Timeline(c *ui.Context, items []TimelineItem) ui.Element`。`TimelineItem` 字段:`Time`、`Title`、`Content`、`Status`。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/078-statistic.md b/docs/components/078-statistic.md index f07ff9b..a57e01d 100644 --- a/docs/components/078-statistic.md +++ b/docs/components/078-statistic.md @@ -20,7 +20,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func Statistic(c *ui.Context, value float64, opts StatisticOptions) *ui.Element`。 +`func Statistic(c *ui.Context, value float64, opts StatisticOptions) ui.Element`。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/081-dialog.md b/docs/components/081-dialog.md index 7f2b01d..0829bf8 100644 --- a/docs/components/081-dialog.md +++ b/docs/components/081-dialog.md @@ -33,7 +33,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.Element`,关闭时返回 nil。 +签名:`func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) ui.Element`,关闭时返回 nil。 ## 参数 diff --git a/docs/components/083-drawer.md b/docs/components/083-drawer.md index 49cc7c0..0ed1ac7 100644 --- a/docs/components/083-drawer.md +++ b/docs/components/083-drawer.md @@ -20,7 +20,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.Element`。 +签名:`func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) ui.Element`。 ## 参数 diff --git a/docs/components/084-popover.md b/docs/components/084-popover.md index 05bc63b..fe946aa 100644 --- a/docs/components/084-popover.md +++ b/docs/components/084-popover.md @@ -21,7 +21,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions, content func()) *ui.Element`。`anchor` 必须是本帧已构建的元素,nil 时 panic。 +签名:`func Popover(c *ui.Context, anchor ui.Element, open *bool, opts PopoverOptions, content func()) ui.Element`。`anchor` 必须是本帧已构建的元素,nil 时 panic。 ## 参数 diff --git a/docs/components/085-tooltip.md b/docs/components/085-tooltip.md index 9edda47..66e446f 100644 --- a/docs/components/085-tooltip.md +++ b/docs/components/085-tooltip.md @@ -15,7 +15,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func Tooltip(c *ui.Context, anchor *ui.Element, text string, opts TooltipOptions) *ui.Element`,返回 `anchor`。`anchor` 为 nil 时 panic;`text` 为空时什么也不做。 +签名:`func Tooltip(c *ui.Context, anchor ui.Element, text string, opts TooltipOptions) ui.Element`,返回 `anchor`。`anchor` 为 nil 时 panic;`text` 为空时什么也不做。 ## 参数 diff --git a/docs/components/086-hover-card.md b/docs/components/086-hover-card.md index 421aaf5..4b0743b 100644 --- a/docs/components/086-hover-card.md +++ b/docs/components/086-hover-card.md @@ -17,7 +17,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content func()) *ui.Element`,返回卡片,未显示时为 nil。 +签名:`func HoverCard(c *ui.Context, anchor ui.Element, opts HoverCardOptions, content func()) ui.Element`,返回卡片,未显示时为 nil。 ## 参数 diff --git a/docs/components/087-dropdown-menu.md b/docs/components/087-dropdown-menu.md index 53959cc..64492ed 100644 --- a/docs/components/087-dropdown-menu.md +++ b/docs/components/087-dropdown-menu.md @@ -27,7 +27,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func DropdownMenu(c *ui.Context, label string, opts DropdownMenuOptions, build func(m *PopupMenu)) *ui.Element`,返回触发按钮。`build` 为 nil、或 `label` 与 `opts.Label` 都为空时 panic。 +签名:`func DropdownMenu(c *ui.Context, label string, opts DropdownMenuOptions, build func(m *PopupMenu)) ui.Element`,返回触发按钮。`build` 为 nil、或 `label` 与 `opts.Label` 都为空时 panic。 ## 参数 diff --git a/docs/components/088-context-menu.md b/docs/components/088-context-menu.md index d6e5893..b2b8a86 100644 --- a/docs/components/088-context-menu.md +++ b/docs/components/088-context-menu.md @@ -22,7 +22,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func ContextMenu(c *ui.Context, target *ui.Element, opts ContextMenuOptions, build func(m *PopupMenu)) *ui.Element`,返回 `target`。`target` 或 `build` 为 nil 时 panic。 +签名:`func ContextMenu(c *ui.Context, target ui.Element, opts ContextMenuOptions, build func(m *PopupMenu)) ui.Element`,返回 `target`。`target` 或 `build` 为 nil 时 panic。 ## 参数 diff --git a/docs/components/089-popconfirm.md b/docs/components/089-popconfirm.md index b2f7317..c9d17ea 100644 --- a/docs/components/089-popconfirm.md +++ b/docs/components/089-popconfirm.md @@ -21,7 +21,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOptions) PopconfirmResult`。`anchor` 为 nil 或 `Title` 为空时 panic。 +签名:`func Popconfirm(c *ui.Context, anchor ui.Element, open *bool, opts PopconfirmOptions) PopconfirmResult`。`anchor` 为 nil 或 `Title` 为空时 panic。 ## 参数 diff --git a/docs/components/090-overlay.md b/docs/components/090-overlay.md index 35cd17b..d1822fb 100644 --- a/docs/components/090-overlay.md +++ b/docs/components/090-overlay.md @@ -17,7 +17,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui.Element`。 +签名:`func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) ui.Element`。 ## 参数 diff --git a/docs/components/093-progress.md b/docs/components/093-progress.md index ed7ef0a..513c0d5 100644 --- a/docs/components/093-progress.md +++ b/docs/components/093-progress.md @@ -17,8 +17,8 @@ feedback.Meter(c, 93, feedback.MeterOptions{ 签名: -- `func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element` -- `func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element` +- `func Progress(c *ui.Context, value float64, opts ProgressOptions) ui.Element` +- `func Meter(c *ui.Context, value float64, opts MeterOptions) ui.Element` ## 参数 diff --git a/docs/components/094-spinner.md b/docs/components/094-spinner.md index 6c60dca..1683239 100644 --- a/docs/components/094-spinner.md +++ b/docs/components/094-spinner.md @@ -10,7 +10,7 @@ core.Spinner(c, core.SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: true}) ``` -签名:`func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element` +签名:`func Spinner(c *ui.Context, opts SpinnerOptions) ui.Element` ## 参数 diff --git a/docs/components/095-skeleton.md b/docs/components/095-skeleton.md index b21610a..f54919f 100644 --- a/docs/components/095-skeleton.md +++ b/docs/components/095-skeleton.md @@ -15,7 +15,7 @@ ui.Row(c).Padding(16).Gap(12).Children(func() { }) ``` -签名:`func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element` +签名:`func Skeleton(c *ui.Context, opts SkeletonOptions) ui.Element` ## 参数 diff --git a/docs/components/098-status-indicator.md b/docs/components/098-status-indicator.md index 71c3e8f..56e4bd0 100644 --- a/docs/components/098-status-indicator.md +++ b/docs/components/098-status-indicator.md @@ -11,7 +11,7 @@ feedback.StatusIndicator(c, feedback.StatusSuccess, feedback.StatusIndicatorOpti feedback.StatusIndicator(c, feedback.StatusOffline, feedback.StatusIndicatorOptions{}) ``` -签名:`func StatusIndicator(c *ui.Context, status Status, opts StatusIndicatorOptions) *ui.Element` +签名:`func StatusIndicator(c *ui.Context, status Status, opts StatusIndicatorOptions) ui.Element` ## 参数 diff --git a/docs/components/122-chart-tooltip.md b/docs/components/122-chart-tooltip.md index 8e0af0f..55b05bb 100644 --- a/docs/components/122-chart-tooltip.md +++ b/docs/components/122-chart-tooltip.md @@ -25,7 +25,7 @@ ui.Render(view, 400, 200, 1) ## 事件 -返回浮层 `*ui.Element`;读数事件由所属 Chart 提供。 +返回浮层 `ui.Element`;读数事件由所属 Chart 提供。 ## 键盘操作 diff --git a/docs/components/133-trend-indicator.md b/docs/components/133-trend-indicator.md index 6217546..966f082 100644 --- a/docs/components/133-trend-indicator.md +++ b/docs/components/133-trend-indicator.md @@ -24,7 +24,7 @@ ui.Render(view, 400, 100, 1) ## 事件 -返回 `*ui.Element`,展示调用方提供的变化值。 +返回 `ui.Element`,展示调用方提供的变化值。 ## 键盘操作 diff --git a/docs/components/203-message-bubble.md b/docs/components/203-message-bubble.md index de98d4d..411af9d 100644 --- a/docs/components/203-message-bubble.md +++ b/docs/components/203-message-bubble.md @@ -12,7 +12,7 @@ chat.MessageBubble(c, chat.MessageAssistant, chat.MessageBubbleOptions{Name: "He }) ``` -签名:`func MessageBubble(c *ui.Context, role MessageRole, opts MessageBubbleOptions, content func()) *ui.Element` +签名:`func MessageBubble(c *ui.Context, role MessageRole, opts MessageBubbleOptions, content func()) ui.Element` ## 参数 diff --git a/docs/components/204-message-header.md b/docs/components/204-message-header.md index f9d41e5..40a0b83 100644 --- a/docs/components/204-message-header.md +++ b/docs/components/204-message-header.md @@ -10,7 +10,7 @@ chat.MessageHeader(c, chat.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: chat.MessageSent}) ``` -签名:`func MessageHeader(c *ui.Context, opts MessageHeaderOptions) *ui.Element` +签名:`func MessageHeader(c *ui.Context, opts MessageHeaderOptions) ui.Element` ## 参数 diff --git a/docs/components/205-streaming-text.md b/docs/components/205-streaming-text.md index 3154a4e..9d06a80 100644 --- a/docs/components/205-streaming-text.md +++ b/docs/components/205-streaming-text.md @@ -10,7 +10,7 @@ chat.StreamingText(c, reply, chat.StreamingTextOptions{Streaming: true, Smooth: true}) ``` -签名:`func StreamingText(c *ui.Context, text string, opts StreamingTextOptions) *ui.Element` +签名:`func StreamingText(c *ui.Context, text string, opts StreamingTextOptions) ui.Element` ## 参数 diff --git a/docs/components/212-link-preview-card.md b/docs/components/212-link-preview-card.md index a80dead..32907b4 100644 --- a/docs/components/212-link-preview-card.md +++ b/docs/components/212-link-preview-card.md @@ -13,7 +13,7 @@ if chat.LinkPreviewCard(c, p).Clicked() { } ``` -签名:`func LinkPreviewCard(c *ui.Context, p LinkPreview) *ui.Element` +签名:`func LinkPreviewCard(c *ui.Context, p LinkPreview) ui.Element` ## 参数 diff --git a/docs/components/213-quote-reply.md b/docs/components/213-quote-reply.md index 783f89a..4f355e3 100644 --- a/docs/components/213-quote-reply.md +++ b/docs/components/213-quote-reply.md @@ -12,7 +12,7 @@ if chat.QuoteReply(c, "Marie", "Let them eat cake.").Clicked() { } ``` -签名:`func QuoteReply(c *ui.Context, author, excerpt string) *ui.Element` +签名:`func QuoteReply(c *ui.Context, author, excerpt string) ui.Element` ## 参数 diff --git a/docs/components/219-scroll-to-bottom-button.md b/docs/components/219-scroll-to-bottom-button.md index 818d28c..3e0ba5b 100644 --- a/docs/components/219-scroll-to-bottom-button.md +++ b/docs/components/219-scroll-to-bottom-button.md @@ -14,7 +14,7 @@ ui.Box(c).Fill().Children(func() { }) ``` -签名:`func ScrollToBottomButton(c *ui.Context, away bool, unread int) *ui.Element` +签名:`func ScrollToBottomButton(c *ui.Context, away bool, unread int) ui.Element` ## 参数 diff --git a/docs/components/220-typing-indicator.md b/docs/components/220-typing-indicator.md index 45eba39..b390fb1 100644 --- a/docs/components/220-typing-indicator.md +++ b/docs/components/220-typing-indicator.md @@ -10,7 +10,7 @@ chat.TypingIndicator(c, "Herald") ``` -签名:`func TypingIndicator(c *ui.Context, name string) *ui.Element` +签名:`func TypingIndicator(c *ui.Context, name string) ui.Element` ## 参数 diff --git a/docs/components/251-date-separator.md b/docs/components/251-date-separator.md index 257fb40..31c167f 100644 --- a/docs/components/251-date-separator.md +++ b/docs/components/251-date-separator.md @@ -16,7 +16,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func DateSeparator(c *ui.Context, day Date, opts DateSeparatorOptions) *ui.Element`。`day` 必须是有效日期。 +`func DateSeparator(c *ui.Context, day Date, opts DateSeparatorOptions) ui.Element`。`day` 必须是有效日期。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/252-thinking-indicator.md b/docs/components/252-thinking-indicator.md index fcf9165..9847102 100644 --- a/docs/components/252-thinking-indicator.md +++ b/docs/components/252-thinking-indicator.md @@ -16,7 +16,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func ThinkingIndicator(c *ui.Context, opts ThinkingIndicatorOptions) *ui.Element`。 +`func ThinkingIndicator(c *ui.Context, opts ThinkingIndicatorOptions) ui.Element`。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/255-cost-estimator.md b/docs/components/255-cost-estimator.md index 20ca6af..b41407a 100644 --- a/docs/components/255-cost-estimator.md +++ b/docs/components/255-cost-estimator.md @@ -16,7 +16,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func CostEstimator(c *ui.Context, opts CostEstimatorOptions) *ui.Element`。另有 `EstimateCost(inputTokens, outputTokens, inputPrice, outputPrice) CostEstimate`。 +`func CostEstimator(c *ui.Context, opts CostEstimatorOptions) ui.Element`。另有 `EstimateCost(inputTokens, outputTokens, inputPrice, outputPrice) CostEstimate`。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/267-agent-status.md b/docs/components/267-agent-status.md index 4bc87dd..a00e328 100644 --- a/docs/components/267-agent-status.md +++ b/docs/components/267-agent-status.md @@ -14,7 +14,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func AgentStatus(c *ui.Context, state AgentState, opts AgentStatusOptions) *ui.Element`。未知状态 panic。 +签名:`func AgentStatus(c *ui.Context, state AgentState, opts AgentStatusOptions) ui.Element`。未知状态 panic。 ## 参数 diff --git a/docs/components/268-agent-progress.md b/docs/components/268-agent-progress.md index 9cab6e1..a4c8b46 100644 --- a/docs/components/268-agent-progress.md +++ b/docs/components/268-agent-progress.md @@ -14,7 +14,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func AgentProgress(c *ui.Context, value float64, opts AgentProgressOptions) *ui.Element`。确定模式下 `value` 不在 0 到 1 时 panic。 +签名:`func AgentProgress(c *ui.Context, value float64, opts AgentProgressOptions) ui.Element`。确定模式下 `value` 不在 0 到 1 时 panic。 ## 参数 diff --git a/docs/components/276-sandbox-status.md b/docs/components/276-sandbox-status.md index 0f22c73..cbe4af6 100644 --- a/docs/components/276-sandbox-status.md +++ b/docs/components/276-sandbox-status.md @@ -16,7 +16,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *ui.Element`。状态未知、资源名为空、`Limit` 不大于 0 或 `Used` 不在 0 到 `Limit` 之间时 panic。 +签名:`func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) ui.Element`。状态未知、资源名为空、`Limit` 不大于 0 或 `Used` 不在 0 到 `Limit` 之间时 panic。 ## 参数 diff --git a/docs/components/287-dots-loader.md b/docs/components/287-dots-loader.md index 9822044..b34614b 100644 --- a/docs/components/287-dots-loader.md +++ b/docs/components/287-dots-loader.md @@ -14,7 +14,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func DotsLoader(c *ui.Context, opts LoaderOptions) *ui.Element` +签名:`func DotsLoader(c *ui.Context, opts LoaderOptions) ui.Element` ## 参数 diff --git a/docs/components/288-bars-loader.md b/docs/components/288-bars-loader.md index fd70f64..e7909c6 100644 --- a/docs/components/288-bars-loader.md +++ b/docs/components/288-bars-loader.md @@ -14,7 +14,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func BarsLoader(c *ui.Context, opts LoaderOptions) *ui.Element` +签名:`func BarsLoader(c *ui.Context, opts LoaderOptions) ui.Element` ## 参数 diff --git a/docs/components/289-pulse-loader.md b/docs/components/289-pulse-loader.md index a57fde0..6f0858c 100644 --- a/docs/components/289-pulse-loader.md +++ b/docs/components/289-pulse-loader.md @@ -14,7 +14,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func PulseLoader(c *ui.Context, opts LoaderOptions) *ui.Element` +签名:`func PulseLoader(c *ui.Context, opts LoaderOptions) ui.Element` ## 参数 diff --git a/docs/components/290-orbit-loader.md b/docs/components/290-orbit-loader.md index 72d901d..77cf370 100644 --- a/docs/components/290-orbit-loader.md +++ b/docs/components/290-orbit-loader.md @@ -14,7 +14,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func OrbitLoader(c *ui.Context, opts LoaderOptions) *ui.Element` +签名:`func OrbitLoader(c *ui.Context, opts LoaderOptions) ui.Element` ## 参数 diff --git a/docs/components/291-wave-loader.md b/docs/components/291-wave-loader.md index 3fc0dc6..e2c9587 100644 --- a/docs/components/291-wave-loader.md +++ b/docs/components/291-wave-loader.md @@ -14,7 +14,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func WaveLoader(c *ui.Context, opts LoaderOptions) *ui.Element` +签名:`func WaveLoader(c *ui.Context, opts LoaderOptions) ui.Element` ## 参数 diff --git a/docs/components/292-shimmer.md b/docs/components/292-shimmer.md index 5b05200..516dd5d 100644 --- a/docs/components/292-shimmer.md +++ b/docs/components/292-shimmer.md @@ -17,7 +17,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func Shimmer(c *ui.Context, loading bool, opts ShimmerOptions, content func()) *ui.Element` +签名:`func Shimmer(c *ui.Context, loading bool, opts ShimmerOptions, content func()) ui.Element` ## 参数 diff --git a/docs/components/293-count-up.md b/docs/components/293-count-up.md index d303848..5f08f9a 100644 --- a/docs/components/293-count-up.md +++ b/docs/components/293-count-up.md @@ -14,7 +14,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func CountUp(c *ui.Context, value float64, opts CountUpOptions) *ui.Element` +签名:`func CountUp(c *ui.Context, value float64, opts CountUpOptions) ui.Element` ## 参数 diff --git a/docs/components/295-shimmer-text.md b/docs/components/295-shimmer-text.md index 876af8d..230c41a 100644 --- a/docs/components/295-shimmer-text.md +++ b/docs/components/295-shimmer-text.md @@ -15,7 +15,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func ShimmerText(c *ui.Context, text string, opts ShimmerTextOptions) *ui.Element` +签名:`func ShimmerText(c *ui.Context, text string, opts ShimmerTextOptions) ui.Element` ## 参数 diff --git a/docs/components/296-stagger.md b/docs/components/296-stagger.md index 1db967a..87f7c2d 100644 --- a/docs/components/296-stagger.md +++ b/docs/components/296-stagger.md @@ -17,7 +17,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func Stagger(c *ui.Context, n int, opts StaggerOptions, item func(i int)) *ui.Element` +签名:`func Stagger(c *ui.Context, n int, opts StaggerOptions, item func(i int)) ui.Element` ## 参数 diff --git a/docs/components/297-reorder-transition.md b/docs/components/297-reorder-transition.md index 1ad3ab0..ffc2af4 100644 --- a/docs/components/297-reorder-transition.md +++ b/docs/components/297-reorder-transition.md @@ -17,7 +17,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func ReorderTransition[K comparable](c *ui.Context, keys []K, opts ReorderTransitionOptions, item func(key K)) *ui.Element` +签名:`func ReorderTransition[K comparable](c *ui.Context, keys []K, opts ReorderTransitionOptions, item func(key K)) ui.Element` ## 参数 diff --git a/docs/components/298-layout-transition.md b/docs/components/298-layout-transition.md index d693431..919b9ff 100644 --- a/docs/components/298-layout-transition.md +++ b/docs/components/298-layout-transition.md @@ -20,7 +20,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func LayoutTransition(c *ui.Context, opts LayoutTransitionOptions, content func()) *ui.Element` +签名:`func LayoutTransition(c *ui.Context, opts LayoutTransitionOptions, content func()) ui.Element` ## 参数 diff --git a/docs/components/299-presence.md b/docs/components/299-presence.md index d1b1602..c6324be 100644 --- a/docs/components/299-presence.md +++ b/docs/components/299-presence.md @@ -17,7 +17,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func Presence(c *ui.Context, show bool, opts PresenceOptions, content func()) *ui.Element` +签名:`func Presence(c *ui.Context, show bool, opts PresenceOptions, content func()) ui.Element` ## 参数 diff --git a/docs/components/300-blink-highlight.md b/docs/components/300-blink-highlight.md index 3538317..6b52b15 100644 --- a/docs/components/300-blink-highlight.md +++ b/docs/components/300-blink-highlight.md @@ -15,7 +15,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func BlinkHighlight(c *ui.Context, value float64, opts BlinkHighlightOptions) *ui.Element` +签名:`func BlinkHighlight(c *ui.Context, value float64, opts BlinkHighlightOptions) ui.Element` ## 参数 diff --git a/docs/components/301-diff-viewer.md b/docs/components/301-diff-viewer.md index d2e7c9b..2c7e535 100644 --- a/docs/components/301-diff-viewer.md +++ b/docs/components/301-diff-viewer.md @@ -17,7 +17,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func DiffViewer(c *ui.Context, s *DiffViewerState, from, to string, opts DiffViewerOptions) *ui.Element` +签名:`func DiffViewer(c *ui.Context, s *DiffViewerState, from, to string, opts DiffViewerOptions) ui.Element` ## 参数 diff --git a/docs/components/302-inline-diff.md b/docs/components/302-inline-diff.md index 6891fbf..d6ad55d 100644 --- a/docs/components/302-inline-diff.md +++ b/docs/components/302-inline-diff.md @@ -14,7 +14,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func InlineDiff(c *ui.Context, from, to string, opts InlineDiffOptions) *ui.Element` +签名:`func InlineDiff(c *ui.Context, from, to string, opts InlineDiffOptions) ui.Element` ## 参数 diff --git a/docs/components/313-blame-view.md b/docs/components/313-blame-view.md index 8d468f5..219bb45 100644 --- a/docs/components/313-blame-view.md +++ b/docs/components/313-blame-view.md @@ -19,7 +19,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func BlameView(c *ui.Context, s *BlameViewState, lines []BlameLine, opts BlameViewOptions) *ui.Element` +签名:`func BlameView(c *ui.Context, s *BlameViewState, lines []BlameLine, opts BlameViewOptions) ui.Element` ## 参数 diff --git a/docs/components/317-diff-stat.md b/docs/components/317-diff-stat.md index b1e6a4d..ecdcb74 100644 --- a/docs/components/317-diff-stat.md +++ b/docs/components/317-diff-stat.md @@ -14,7 +14,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func DiffStat(c *ui.Context, added, deleted int, opts DiffStatOptions) *ui.Element` +签名:`func DiffStat(c *ui.Context, added, deleted int, opts DiffStatOptions) ui.Element` ## 参数 diff --git a/docs/components/318-git-status-badge.md b/docs/components/318-git-status-badge.md index 7621bf7..e1f1a7e 100644 --- a/docs/components/318-git-status-badge.md +++ b/docs/components/318-git-status-badge.md @@ -14,7 +14,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func GitStatusBadge(c *ui.Context, status GitStatus, opts GitStatusBadgeOptions) *ui.Element` +签名:`func GitStatusBadge(c *ui.Context, status GitStatus, opts GitStatusBadgeOptions) ui.Element` ## 参数 diff --git a/docs/components/320-ansi-text.md b/docs/components/320-ansi-text.md index 2852f86..02e663c 100644 --- a/docs/components/320-ansi-text.md +++ b/docs/components/320-ansi-text.md @@ -14,7 +14,7 @@ view := func(c *ui.Context) { ui.Render(view, 400, 300, 1) ``` -签名:`func AnsiText(c *ui.Context, text string, opts AnsiTextOptions) *ui.Element` +签名:`func AnsiText(c *ui.Context, text string, opts AnsiTextOptions) ui.Element` ## 参数 diff --git a/docs/components/321-terminal.md b/docs/components/321-terminal.md index 00af6dc..2d3c2cf 100644 --- a/docs/components/321-terminal.md +++ b/docs/components/321-terminal.md @@ -21,7 +21,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func Terminal(c *ui.Context, s *TerminalState, opts TerminalOptions) *ui.Element`。`TerminalState` 零值是 80×24 空屏,由调用方持有;`Write(data)` 写入输出(须在界面线程,例如经 `Window.Update`),`Resize(cols, rows)`、`Lines()`、`LineText(i)`、`Select(a, b)`、`Selection()`、`ScrollBack(n)`、`Offset()`、`Cursor()`、`Size()` 供程序读取与控制。 +`func Terminal(c *ui.Context, s *TerminalState, opts TerminalOptions) ui.Element`。`TerminalState` 零值是 80×24 空屏,由调用方持有;`Write(data)` 写入输出(须在界面线程,例如经 `Window.Update`),`Resize(cols, rows)`、`Lines()`、`LineText(i)`、`Select(a, b)`、`Selection()`、`ScrollBack(n)`、`Offset()`、`Cursor()`、`Size()` 供程序读取与控制。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/334-completion-menu.md b/docs/components/334-completion-menu.md index 7b6aa0e..42c9215 100644 --- a/docs/components/334-completion-menu.md +++ b/docs/components/334-completion-menu.md @@ -23,7 +23,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func CompletionMenu(c *ui.Context, anchor *ui.Element, open *bool, items []Completion, s *CompletionMenuState, opts CompletionMenuOptions) CompletionMenuResult`。`anchor` 是编辑器元素(如 `InputResult.Input`);打开期间组件替换它的 `HandleInput` 以先拿到菜单按键。候选为空、禁用或编辑器禁用时自动关闭。列表虚拟化,最多显示 8 行后滚动;窗口下方空间不足时在上方弹出。 +`func CompletionMenu(c *ui.Context, anchor ui.Element, open *bool, items []Completion, s *CompletionMenuState, opts CompletionMenuOptions) CompletionMenuResult`。`anchor` 是编辑器元素(如 `InputResult.Input`);打开期间组件替换它的 `HandleInput` 以先拿到菜单按键。候选为空、禁用或编辑器禁用时自动关闭。列表虚拟化,最多显示 8 行后滚动;窗口下方空间不足时在上方弹出。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/345-video-player.md b/docs/components/345-video-player.md index 5bb3938..2207e94 100644 --- a/docs/components/345-video-player.md +++ b/docs/components/345-video-player.md @@ -22,7 +22,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func VideoPlayer(c *ui.Context, st VideoState, opts VideoPlayerOptions) *ui.Element` +`func VideoPlayer(c *ui.Context, st VideoState, opts VideoPlayerOptions) ui.Element` `VideoState`:`Frame`(当前画面,nil 显示“暂无视频”)、`Position`、`Duration`、`Buffered`、`Playing`、`Rate`、`Subtitle`、`SubtitlesOn`、`Fullscreen`、`PiP`。 diff --git a/docs/components/348-audio-player.md b/docs/components/348-audio-player.md index 856fdff..ea0c142 100644 --- a/docs/components/348-audio-player.md +++ b/docs/components/348-audio-player.md @@ -21,7 +21,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func AudioPlayer(c *ui.Context, st AudioState, opts AudioPlayerOptions) *ui.Element` +`func AudioPlayer(c *ui.Context, st AudioState, opts AudioPlayerOptions) ui.Element` `AudioState`:`Title`、`Position`、`Duration`、`Playing`。 diff --git a/docs/components/350-audio-spectrum.md b/docs/components/350-audio-spectrum.md index 2a88039..bf632ea 100644 --- a/docs/components/350-audio-spectrum.md +++ b/docs/components/350-audio-spectrum.md @@ -17,7 +17,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func AudioSpectrum(c *ui.Context, bands []float32, opts AudioSpectrumOptions) *ui.Element` +`func AudioSpectrum(c *ui.Context, bands []float32, opts AudioSpectrumOptions) ui.Element` 每个频带取 0 到 1,超出范围 panic。 diff --git a/docs/components/353-media-controls.md b/docs/components/353-media-controls.md index 70e0769..e27366e 100644 --- a/docs/components/353-media-controls.md +++ b/docs/components/353-media-controls.md @@ -21,7 +21,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func MediaControls(c *ui.Context, st MediaControlsState, opts MediaControlsOptions) *ui.Element` +`func MediaControls(c *ui.Context, st MediaControlsState, opts MediaControlsOptions) ui.Element` `MediaControlsState`:`Playing`、`Loop`(`LoopOff`、`LoopAll`、`LoopOne`)、`Shuffle`、`NoPrevious`、`NoNext`。 diff --git a/docs/components/356-screen-recorder-controls.md b/docs/components/356-screen-recorder-controls.md index f1040e1..cf02628 100644 --- a/docs/components/356-screen-recorder-controls.md +++ b/docs/components/356-screen-recorder-controls.md @@ -22,7 +22,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func ScreenRecorderControls(c *ui.Context, st RecorderState, opts ScreenRecorderControlsOptions) *ui.Element` +`func ScreenRecorderControls(c *ui.Context, st RecorderState, opts ScreenRecorderControlsOptions) ui.Element` `RecorderState`:`Status`(`RecorderIdle`、`RecorderRecording`、`RecorderPaused`)、`Elapsed`。 diff --git a/docs/components/358-mic-level-meter.md b/docs/components/358-mic-level-meter.md index 16f9d7f..8e6cdda 100644 --- a/docs/components/358-mic-level-meter.md +++ b/docs/components/358-mic-level-meter.md @@ -17,7 +17,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func MicLevelMeter(c *ui.Context, level float32, opts MicLevelMeterOptions) *ui.Element` +`func MicLevelMeter(c *ui.Context, level float32, opts MicLevelMeterOptions) ui.Element` `level` 为满刻度的 0 到 1,超出 panic。 diff --git a/docs/components/426-event-chip.md b/docs/components/426-event-chip.md index 032602d..f53e3c5 100644 --- a/docs/components/426-event-chip.md +++ b/docs/components/426-event-chip.md @@ -20,7 +20,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Element` +`func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) ui.Element` | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/428-event-popover.md b/docs/components/428-event-popover.md index cfa3e64..80f6087 100644 --- a/docs/components/428-event-popover.md +++ b/docs/components/428-event-popover.md @@ -26,7 +26,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func EventPopover(c *ui.Context, anchor *ui.Element, open *bool, ev CalendarEvent, opts EventPopoverOptions) EventPopoverResult` +`func EventPopover(c *ui.Context, anchor ui.Element, open *bool, ev CalendarEvent, opts EventPopoverOptions) EventPopoverResult` | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/429-current-time-indicator.md b/docs/components/429-current-time-indicator.md index 0271d4c..d80177a 100644 --- a/docs/components/429-current-time-indicator.md +++ b/docs/components/429-current-time-indicator.md @@ -18,7 +18,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func CurrentTimeIndicator(c *ui.Context, opts CurrentTimeIndicatorOptions) *ui.Element` +`func CurrentTimeIndicator(c *ui.Context, opts CurrentTimeIndicatorOptions) ui.Element` | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/438-relative-time.md b/docs/components/438-relative-time.md index ec65118..403a6ff 100644 --- a/docs/components/438-relative-time.md +++ b/docs/components/438-relative-time.md @@ -17,7 +17,7 @@ ui.Render(view, 400, 300, 1) ## 参数 -`func RelativeTime(c *ui.Context, t time.Time, opts RelativeTimeOptions) *ui.Element` +`func RelativeTime(c *ui.Context, t time.Time, opts RelativeTimeOptions) ui.Element` | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | diff --git a/docs/components/461-settings-layout.md b/docs/components/461-settings-layout.md index e13d3d7..94a8975 100644 --- a/docs/components/461-settings-layout.md +++ b/docs/components/461-settings-layout.md @@ -16,7 +16,7 @@ view := func(c *ui.Context) { core.Use(c, core.Settings{}) account.SettingsLayout(c, &page, pages, account.SettingsLayoutOptions{}, func(id string) { account.SettingsSection(c, "Notifications", func() { - account.SettingsRow(c, "Sound", account.SettingsRowOptions{ID: "sound"}, func() *ui.Element { + account.SettingsRow(c, "Sound", account.SettingsRowOptions{ID: "sound"}, func() ui.Element { return input.Switch(c, &sound, "", input.SwitchOptions{}) }) }) @@ -33,7 +33,7 @@ ui.Render(view, 400, 300, 1) | `pages` | `[]SettingsPage` | 必填 | ID、Label、Icon。 | | `Title` | `string` | 内置“Settings” | 导航标题。 | | `content` | `func(id string)` | 必填 | 构建分区内容。 | -| `SettingsRow control` | `func() *ui.Element` | 必填 | 返回控件;行标签与说明关联到它。 | +| `SettingsRow control` | `func() ui.Element` | 必填 | 返回控件;行标签与说明关联到它。 | | `SettingsRowOptions.ID` | `string` | "" | 供 SettingsSearch 跳转定位。 | ## 状态 diff --git a/docs/components/496-masonry.md b/docs/components/496-masonry.md index a8c65d8..7bb3e80 100644 --- a/docs/components/496-masonry.md +++ b/docs/components/496-masonry.md @@ -18,7 +18,7 @@ view := func(c *ui.Context) { } ``` -签名:`func Masonry(c *ui.Context, n int, opts MasonryOptions, item func(i int)) *ui.Element` +签名:`func Masonry(c *ui.Context, n int, opts MasonryOptions, item func(i int)) ui.Element` ## 参数 diff --git a/docs/components/499-qr-code.md b/docs/components/499-qr-code.md index 5b0d066..8ea7de5 100644 --- a/docs/components/499-qr-code.md +++ b/docs/components/499-qr-code.md @@ -13,7 +13,7 @@ view := func(c *ui.Context) { } ``` -签名:`func QRCode(c *ui.Context, text string, opts QRCodeOptions) *ui.Element` +签名:`func QRCode(c *ui.Context, text string, opts QRCodeOptions) ui.Element` ## 参数 diff --git a/docs/components/500-activity-feed.md b/docs/components/500-activity-feed.md index 3ac9302..5476077 100644 --- a/docs/components/500-activity-feed.md +++ b/docs/components/500-activity-feed.md @@ -16,7 +16,7 @@ view := func(c *ui.Context) { } ``` -签名:`func ActivityFeed(c *ui.Context, items []Activity, opts ActivityFeedOptions) *ui.Element` +签名:`func ActivityFeed(c *ui.Context, items []Activity, opts ActivityFeedOptions) ui.Element` ## 参数 diff --git a/examples/gallery/app.go b/examples/gallery/app.go index 1d2d9c2..c62d813 100644 --- a/examples/gallery/app.go +++ b/examples/gallery/app.go @@ -143,7 +143,7 @@ func (g *gallery) statusBar(c *ui.Context) { } func (g *gallery) content(c *ui.Context) { - ui.Box(c).Key(g.selected).FillWidth().Children(func() { + ui.Box(c.Key(g.selected)).FillWidth().Children(func() { switch g.selected { case overtureID: g.overturePage(c) diff --git a/examples/gallery/composite_files.go b/examples/gallery/composite_files.go index c619524..58198ac 100644 --- a/examples/gallery/composite_files.go +++ b/examples/gallery/composite_files.go @@ -319,7 +319,7 @@ func (g *gallery) renameDialog(c *ui.Context, log func(string)) { commit() } }}, func() { - input.FormField(c, g.t("New name", "新名称"), input.FormFieldOptions{Error: bad}, func() *ui.Element { + input.FormField(c, g.t("New name", "新名称"), input.FormFieldOptions{Error: bad}, func() ui.Element { r := input.TextInput(c, &s.newName, input.TextInputOptions{Error: bad}) if r.Submitted() { commit() diff --git a/examples/gallery/composite_form.go b/examples/gallery/composite_form.go index 3140b9e..c998380 100644 --- a/examples/gallery/composite_form.go +++ b/examples/gallery/composite_form.go @@ -135,7 +135,7 @@ func (g *gallery) formFields(c *ui.Context, f *input.FormScope, log func(string) s.checks <- formAsync{version, msg} c.Invalidate() }() - }}, func() *ui.Element { + }}, func() ui.Element { return input.TextInput(c, &s.name, input.TextInputOptions{}).Input }) input.FormItem(c, f, "password", &s.password, input.FormItemOptions[string]{Label: g.t("Password", "密码"), Required: true, @@ -144,14 +144,14 @@ func (g *gallery) formFields(c *ui.Context, f *input.FormScope, log func(string) return g.t("Use at least 8 characters.", "至少 8 个字符。") } return "" - }}, func() *ui.Element { + }}, func() ui.Element { r := input.PasswordInput(c, &s.password, input.PasswordInputOptions{}) if r.Submitted() { s.st.Submit() } return r.Input }) - input.FormItem(c, f, "debut", &s.debut, input.FormItemOptions[core.Date]{Label: g.t("Debut date", "首演日期"), Required: true}, func() *ui.Element { + input.FormItem(c, f, "debut", &s.debut, input.FormItemOptions[core.Date]{Label: g.t("Debut date", "首演日期"), Required: true}, func() ui.Element { r := datetime.DatePicker(c, &s.debut, datetime.DatePickerOptions{Clearable: true}) if r.Changed() { log("debut " + s.debut.Format("2006-01-02")) @@ -159,7 +159,7 @@ func (g *gallery) formFields(c *ui.Context, f *input.FormScope, log func(string) return r.Input }) input.FormItem(c, f, "roles", &s.tags, input.FormItemOptions[[]string]{Label: g.t("Roles", "担当"), Required: true, - Description: g.t("Up to 4; press Enter after each.", "最多 4 个;每个后按 Enter。")}, func() *ui.Element { + Description: g.t("Up to 4; press Enter after each.", "最多 4 个;每个后按 Enter。")}, func() ui.Element { r := input.TagsInput(c, &s.tags, input.TagsInputOptions{Max: 4, Invalid: s.st.Error("roles") != "", Placeholder: g.t("keyboard, bass…", "键盘、贝斯…")}) if r.Changed() { log(fmt.Sprintf("roles %q", s.tags)) @@ -172,7 +172,7 @@ func (g *gallery) formFields(c *ui.Context, f *input.FormScope, log func(string) return g.t("Accept the terms to continue.", "请先同意条款。") } return "" - }}, func() *ui.Element { + }}, func() ui.Element { e := input.Checkbox(c, &s.terms, g.t("I accept the covenant of the masquerade", "我接受假面舞会的盟约"), input.CheckboxOptions{}) if e.Changed() { log(fmt.Sprintf("terms %v", s.terms)) diff --git a/examples/gallery/composite_settings.go b/examples/gallery/composite_settings.go index 765df40..a13cd85 100644 --- a/examples/gallery/composite_settings.go +++ b/examples/gallery/composite_settings.go @@ -79,14 +79,14 @@ func (g *gallery) settingsGeneral(c *ui.Context, log func(string)) { settingsPanel(c, func() { bad := g.workspaceError(d.workspace) input.FormField(c, g.t("Workspace name", "工作区名称"), input.FormFieldOptions{Required: true, Error: bad, - Description: g.t("Shown in the title bar.", "显示在标题栏中。")}, func() *ui.Element { + Description: g.t("Shown in the title bar.", "显示在标题栏中。")}, func() ui.Element { r := input.TextInput(c, &d.workspace, input.TextInputOptions{Error: bad}) if r.Changed() { log(fmt.Sprintf("workspace %q", d.workspace)) } return r.Input }) - input.FormField(c, g.t("Language", "语言"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, g.t("Language", "语言"), input.FormFieldOptions{}, func() ui.Element { r := input.Select(c, &d.language, []input.SelectOption[string]{ {Value: "en", Label: "English"}, {Value: "zh", Label: "简体中文"}, {Value: "ja", Label: "日本語"}, }, input.SelectOptions{}) @@ -95,7 +95,7 @@ func (g *gallery) settingsGeneral(c *ui.Context, log func(string)) { } return r.Element }) - input.FormField(c, g.t("On startup", "启动时"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, g.t("On startup", "启动时"), input.FormFieldOptions{}, func() ui.Element { r := input.RadioGroup(c, &d.startPage, []input.RadioItem[string]{ {Value: "blank", Label: g.t("Open a blank page", "打开空白页")}, {Value: "last", Label: g.t("Reopen the last session", "恢复上次会话")}, @@ -106,7 +106,7 @@ func (g *gallery) settingsGeneral(c *ui.Context, log func(string)) { } return r.Element }) - input.FormField(c, g.t("Login item", "登录项"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, g.t("Login item", "登录项"), input.FormFieldOptions{}, func() ui.Element { e := input.Switch(c, &d.launch, g.t("Launch at login", "登录时启动"), input.SwitchOptions{}) if e.Changed() { log(fmt.Sprintf("launch at login %v", d.launch)) @@ -119,7 +119,7 @@ func (g *gallery) settingsGeneral(c *ui.Context, log func(string)) { func (g *gallery) settingsAppearance(c *ui.Context, log func(string)) { d := &g.cSettings.draft settingsPanel(c, func() { - input.FormField(c, g.t("Theme", "主题"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, g.t("Theme", "主题"), input.FormFieldOptions{}, func() ui.Element { r := input.RadioGroup(c, &d.theme, []input.RadioItem[string]{ {Value: "system", Label: g.t("System", "跟随系统")}, {Value: "light", Label: g.t("Light", "浅色")}, {Value: "dark", Label: g.t("Dark", "深色")}, }, input.RadioGroupOptions{Horizontal: true}) @@ -128,7 +128,7 @@ func (g *gallery) settingsAppearance(c *ui.Context, log func(string)) { } return r.Element }) - input.FormField(c, g.t("Text size", "文字大小"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, g.t("Text size", "文字大小"), input.FormFieldOptions{}, func() ui.Element { r := input.Slider(c, &d.fontSize, input.SliderOptions{Min: 12, Max: 20, Step: 1, ShowValue: true, Marks: []input.SliderMark{{Value: 12, Label: "12"}, {Value: 16, Label: "16"}, {Value: 20, Label: "20"}}, Format: func(v float64) string { return fmt.Sprintf("%.0f pt", v) }}) @@ -137,14 +137,14 @@ func (g *gallery) settingsAppearance(c *ui.Context, log func(string)) { } return r.Element }) - input.FormField(c, g.t("Sidebar width", "侧栏宽度"), input.FormFieldOptions{Description: g.t("In DIPs, from 180 to 360.", "单位 DIP,180 至 360。")}, func() *ui.Element { + input.FormField(c, g.t("Sidebar width", "侧栏宽度"), input.FormFieldOptions{Description: g.t("In DIPs, from 180 to 360.", "单位 DIP,180 至 360。")}, func() ui.Element { r := input.NumberInput(c, &d.sidebar, input.NumberInputOptions{Min: 180, Max: 360, Step: 10}) if r.Changed() { log(fmt.Sprintf("sidebar width %.0f", d.sidebar)) } return r.Input }) - input.FormField(c, g.t("Motion", "动效"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, g.t("Motion", "动效"), input.FormFieldOptions{}, func() ui.Element { e := input.Switch(c, &d.reduce, g.t("Reduce motion", "减少动效"), input.SwitchOptions{}) if e.Changed() { log(fmt.Sprintf("reduce motion %v", d.reduce)) @@ -157,28 +157,28 @@ func (g *gallery) settingsAppearance(c *ui.Context, log func(string)) { func (g *gallery) settingsPrivacy(c *ui.Context, log func(string)) { d := &g.cSettings.draft settingsPanel(c, func() { - input.FormField(c, g.t("Usage data", "使用数据"), input.FormFieldOptions{Description: g.t("Nothing leaves this demo.", "本示例不会发送任何数据。")}, func() *ui.Element { + input.FormField(c, g.t("Usage data", "使用数据"), input.FormFieldOptions{Description: g.t("Nothing leaves this demo.", "本示例不会发送任何数据。")}, func() ui.Element { e := input.Switch(c, &d.telemetry, g.t("Share anonymous usage data", "共享匿名使用数据"), input.SwitchOptions{}) if e.Changed() { log(fmt.Sprintf("usage data %v", d.telemetry)) } return e }) - input.FormField(c, g.t("Crash reports", "崩溃报告"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, g.t("Crash reports", "崩溃报告"), input.FormFieldOptions{}, func() ui.Element { e := input.Switch(c, &d.crashes, g.t("Send crash reports", "发送崩溃报告"), input.SwitchOptions{}) if e.Changed() { log(fmt.Sprintf("crash reports %v", d.crashes)) } return e }) - input.FormField(c, g.t("Keep history", "保留历史"), input.FormFieldOptions{Description: g.t("Days, from 0 to 365; 0 keeps none.", "天数,0 至 365;0 表示不保留。")}, func() *ui.Element { + input.FormField(c, g.t("Keep history", "保留历史"), input.FormFieldOptions{Description: g.t("Days, from 0 to 365; 0 keeps none.", "天数,0 至 365;0 表示不保留。")}, func() ui.Element { r := input.NumberInput(c, &d.history, input.NumberInputOptions{Min: 0, Max: 365, Step: 1}) if r.Changed() { log(fmt.Sprintf("history %.0f days", d.history)) } return r.Input }) - input.FormField(c, g.t("Lock after", "自动锁定"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, g.t("Lock after", "自动锁定"), input.FormFieldOptions{}, func() ui.Element { r := input.Select(c, &d.lock, []input.SelectOption[string]{ {Value: "never", Label: g.t("Never", "从不")}, {Value: "5", Label: g.t("5 minutes", "5 分钟")}, {Value: "15", Label: g.t("15 minutes", "15 分钟")}, {Value: "60", Label: g.t("1 hour", "1 小时")}, diff --git a/examples/gallery/composite_test.go b/examples/gallery/composite_test.go index 101805c..fba220f 100644 --- a/examples/gallery/composite_test.go +++ b/examples/gallery/composite_test.go @@ -26,8 +26,7 @@ func click(t *testing.T, tt *ui.Tester, label string) { // await renders frames until done holds or 5 seconds pass. func await(tt *ui.Tester, done func() bool) { for deadline := time.Now().Add(5 * time.Second); !done() && time.Now().Before(deadline); { - time.Sleep(20 * time.Millisecond) - tt.Frame() + tt.Advance(20 * time.Millisecond) } } diff --git a/examples/gallery/overture.go b/examples/gallery/overture.go index b416e32..ab7cc72 100644 --- a/examples/gallery/overture.go +++ b/examples/gallery/overture.go @@ -104,7 +104,7 @@ func (g *gallery) categoryGrid(c *ui.Context, width float32) { } layout.Grid(c, layout.GridOptions{Columns: tracks}, func() { for _, cat := range cats { - ui.Box(c).Key(cat).Children(func() { + ui.Box(c.Key(cat)).Children(func() { data.Card(c, data.CardOptions{Title: g.categoryName(cat), Footer: func() { if input.Button(c, g.t("Enter", "进入"), input.ButtonOptions{Variant: input.Outline}).Clicked() { g.selected = first[cat].ID diff --git a/examples/gallery/pages_account.go b/examples/gallery/pages_account.go index c170353..1e5666c 100644 --- a/examples/gallery/pages_account.go +++ b/examples/gallery/pages_account.go @@ -91,7 +91,7 @@ func accountSettingsPage(c *ui.Context, d *accountSettingsState, log func(string // accountSwitchRow is a settings row with a switch. func accountSwitchRow(c *ui.Context, id, label, desc string, v *bool, log func(string)) { - account.SettingsRow(c, label, account.SettingsRowOptions{ID: id, Description: desc}, func() *ui.Element { + account.SettingsRow(c, label, account.SettingsRowOptions{ID: id, Description: desc}, func() ui.Element { sw := input.Switch(c, v, "", input.SwitchOptions{}) if sw.Changed() { log(fmt.Sprintf("%s %v", label, *v)) diff --git a/examples/gallery/pages_account_more.go b/examples/gallery/pages_account_more.go index 5537f66..369b6c5 100644 --- a/examples/gallery/pages_account_more.go +++ b/examples/gallery/pages_account_more.go @@ -192,7 +192,7 @@ func accountCoachDemo(c *ui.Context, log func(string)) { d.open, d.step = true, 0 log("tour started") } - var compose, archive *ui.Element + var compose, archive ui.Element ui.Row(c).Gap(48).Padding(8).Children(func() { compose = input.Button(c, "Compose", input.ButtonOptions{Disabled: d.disabled}) archive = input.Button(c, "Archive", input.ButtonOptions{Variant: input.Secondary}) diff --git a/examples/gallery/pages_charts2_parts.go b/examples/gallery/pages_charts2_parts.go index c56d434..918f4b2 100644 --- a/examples/gallery/pages_charts2_parts.go +++ b/examples/gallery/pages_charts2_parts.go @@ -115,7 +115,7 @@ func c2PartsChart(c *ui.Context, frame chart.ChartFrame, show bool, s *c2PartsSt Tooltip: func(i int) []chart.ChartTooltipLine { return []chart.ChartTooltipLine{{Text: x.Label(pts[i][0])}, {Text: "Pews " + y.Label(pts[i][1]), Color: colors[0]}, {Text: "Candles " + y.Label(pts[i][1]/2), Color: colors[1], Shape: 1}} }, - Input: func(el *ui.Element, r ui.Rect) { + Input: func(el ui.Element, r ui.Rect) { if annotation.Drag(el, plotOf(r)) { log("ChartAnnotation: moved") } diff --git a/examples/gallery/pages_chatmsg_blocks.go b/examples/gallery/pages_chatmsg_blocks.go index dc04570..2f2b3b1 100644 --- a/examples/gallery/pages_chatmsg_blocks.go +++ b/examples/gallery/pages_chatmsg_blocks.go @@ -82,7 +82,7 @@ var chatmsgBlockPages = []page{ inputFlags(c, log, map[string]*bool{"LineNumbers": &s.numbers, "Wrap at first": &s.wrap}, "LineNumbers", "Wrap at first") }) inputSection(c, "Live", func() { - ui.Column(c).Key(s.lang + fmt.Sprint(s.wrap)).Children(func() { + ui.Column(c.Key(s.lang + fmt.Sprint(s.wrap))).Children(func() { if chat.CodeBlock(c, samples[s.lang], chat.CodeBlockOptions{Language: s.lang, LineNumbers: s.numbers, Wrap: s.wrap}).Copied() { log("code copied") } diff --git a/examples/gallery/pages_chatsession_demos.go b/examples/gallery/pages_chatsession_demos.go index 74ac81c..5c50b5f 100644 --- a/examples/gallery/pages_chatsession_demos.go +++ b/examples/gallery/pages_chatsession_demos.go @@ -98,7 +98,7 @@ func chatDemoConversationItem(c *ui.Context, log func(string)) { ui.Column(c).Width(420).Gap(4).Children(func() { for i := range s.convs { conv := &s.convs[i] - ui.Row(c).Key(conv.ID).Children(func() { + ui.Row(c.Key(conv.ID)).Children(func() { v := chat.ConversationItem(c, conv, chat.ConversationItemOptions{Selected: s.selected && i == 0, Disabled: s.disabled}) switch { case v.Opened: diff --git a/examples/gallery/pages_display.go b/examples/gallery/pages_display.go index 93b3d3f..68115b3 100644 --- a/examples/gallery/pages_display.go +++ b/examples/gallery/pages_display.go @@ -286,7 +286,7 @@ func tagDemo(c *ui.Context, log func(string)) { for i := 0; i < len(s.tags); i++ { t := s.tags[i] var v display.TagView - ui.Box(c).Key(t).Children(func() { + ui.Box(c.Key(t)).Children(func() { v = display.Tag(c, t, display.TagOptions{Icon: icon, Clickable: s.clickable, Closable: s.closable, Disabled: s.dis}) }) if v.Clicked { diff --git a/examples/gallery/pages_display_parts.go b/examples/gallery/pages_display_parts.go index 6068fd4..8a41220 100644 --- a/examples/gallery/pages_display_parts.go +++ b/examples/gallery/pages_display_parts.go @@ -22,7 +22,7 @@ func displayControls(c *ui.Context, fn func()) { } // displayLog logs a control's new value when it changed. -func displayLog[T any](log func(string), e *ui.Element, name string, v *T) { +func displayLog[T any](log func(string), e ui.Element, name string, v *T) { if e.Changed() { log(fmt.Sprintf("%s = %v", name, *v)) } diff --git a/examples/gallery/pages_essentials_more.go b/examples/gallery/pages_essentials_more.go index 7991de1..99f8974 100644 --- a/examples/gallery/pages_essentials_more.go +++ b/examples/gallery/pages_essentials_more.go @@ -44,7 +44,7 @@ func mentionInputDemo(c *ui.Context, log func(string)) { log("mention: submitted") } for _, m := range s.mentions { - ui.Text(c, fmt.Sprintf("@%s → %s", m.Name, m.ID)).Key(m.ID) + ui.Text(c.Key(m.ID), fmt.Sprintf("@%s → %s", m.Name, m.ID)) } }) }) @@ -139,7 +139,7 @@ func undoToastDemo(c *ui.Context, log func(string)) { ui.Slider(c, &s.seconds, 2, 10).Width(200) }) for i, n := range s.notes { - ui.Row(c).Key(n).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Row(c.Key(n)).Gap(12).AlignItems(ui.Center).Children(func() { ui.Text(c, n).Grow(1) if input.Button(c, "Delete", input.ButtonOptions{Variant: input.Ghost}).Clicked() { s.removed, s.open = n, true diff --git a/examples/gallery/pages_form.go b/examples/gallery/pages_form.go index 7a903cf..c17700f 100644 --- a/examples/gallery/pages_form.go +++ b/examples/gallery/pages_form.go @@ -131,7 +131,7 @@ var formPages = []page{ async <- formAsync{version, msg} c.Invalidate() }() - }}, func() *ui.Element { + }}, func() ui.Element { return input.InputGroup(c, &d.name, input.InputGroupOptions{Invalid: d.st.Error("name") != ""}).Input }) input.FormItem(c, f, "email", &d.email, input.FormItemOptions[string]{Label: "Email", Required: true, @@ -140,14 +140,14 @@ var formPages = []page{ return "Enter an email address." } return "" - }}, func() *ui.Element { + }}, func() ui.Element { g := input.InputGroup(c, &d.email, input.InputGroupOptions{Invalid: d.st.Error("email") != ""}) if g.Submitted() { d.st.Submit() } return g.Input }) - input.FormItem(c, f, "tags", &d.tags, input.FormItemOptions[[]string]{Label: "Roles", Required: true, Initial: []string{"keyboard"}}, func() *ui.Element { + input.FormItem(c, f, "tags", &d.tags, input.FormItemOptions[[]string]{Label: "Roles", Required: true, Initial: []string{"keyboard"}}, func() ui.Element { return input.TagsInput(c, &d.tags, input.TagsInputOptions{Max: 4, Invalid: d.st.Error("tags") != ""}).Input }) }) @@ -177,7 +177,7 @@ var formPages = []page{ e = "This name is already in the guest list." } input.FormField(c, "Guest name", input.FormFieldOptions{Required: d.required, Horizontal: d.horizontal, - Description: "As written on the invitation.", Error: e}, func() *ui.Element { + Description: "As written on the invitation.", Error: e}, func() ui.Element { g := input.InputGroup(c, &d.value, input.InputGroupOptions{Invalid: d.withError}) if g.Changed() { log(fmt.Sprintf("value %q", d.value)) diff --git a/examples/gallery/pages_layout.go b/examples/gallery/pages_layout.go index 22c2bbe..7a20dc1 100644 --- a/examples/gallery/pages_layout.go +++ b/examples/gallery/pages_layout.go @@ -68,7 +68,7 @@ func layoutSlider(c *ui.Context, v *float64, label string, lo, hi float64, log f func layoutControls(c *ui.Context, fn func()) { ui.Row(c).Wrap().Gap(12).Children(fn) } -func layoutFrame(c *ui.Context, h float32, fn func()) *ui.Element { +func layoutFrame(c *ui.Context, h float32, fn func()) ui.Element { return ui.Column(c).Height(h).Border(1, core.Tokens(c).Border).Radius(4).Clip().Children(fn) } diff --git a/examples/gallery/pages_mktchart_demo.go b/examples/gallery/pages_mktchart_demo.go index d961384..cce2804 100644 --- a/examples/gallery/pages_mktchart_demo.go +++ b/examples/gallery/pages_mktchart_demo.go @@ -87,7 +87,7 @@ func mktDemo(c *ui.Context, log func(string), id string) { } case "149": for _, chartID := range []string{"price", "other"} { - ui.Column(c).Key(chartID).Children(func() { + ui.Column(c.Key(chartID)).Children(func() { if s.sync.Link(c, chartID).Changed() { log("Sync: " + chartID) } diff --git a/examples/gallery/pages_motion_fx.go b/examples/gallery/pages_motion_fx.go index 38fa4a6..132db92 100644 --- a/examples/gallery/pages_motion_fx.go +++ b/examples/gallery/pages_motion_fx.go @@ -14,7 +14,7 @@ import ( ) // motionLoaderDemo shows a loader while loading. -func motionLoaderDemo(id string, loader func(c *ui.Context, o feedback.LoaderOptions) *ui.Element) func(c *ui.Context, log func(string)) { +func motionLoaderDemo(id string, loader func(c *ui.Context, o feedback.LoaderOptions) ui.Element) func(c *ui.Context, log func(string)) { return func(c *ui.Context, log func(string)) { type state struct { loading, label bool @@ -92,7 +92,7 @@ func motionTypewriterDemo(c *ui.Context, log func(string)) { log("Restarted") } }, func() { - ui.Column(c).Key(s.round).Children(func() { + ui.Column(c.Key(s.round)).Children(func() { if feedback.Typewriter(c, s.text, feedback.TypewriterOptions{Speed: s.speed, Skippable: true}).Changed() { log("Finished") } @@ -127,7 +127,7 @@ func motionStaggerDemo(c *ui.Context, log func(string)) { log(fmt.Sprintf("Replay %d", s.round)) } }, func() { - ui.Column(c).Key(s.round).Children(func() { + ui.Column(c.Key(s.round)).Children(func() { feedback.Stagger(c, len(guests), feedback.StaggerOptions{Interval: time.Duration(s.interval) * time.Millisecond, Horizontal: s.horizontal}, func(i int) { ui.Text(c, guests[i]+" arrives").Padding(6, 10).Background(core.Tokens(c).Surface).Radius(4).Margin(0, 0, 4, 0) }) diff --git a/examples/gallery/pages_overlay.go b/examples/gallery/pages_overlay.go index 222bd13..996b6f3 100644 --- a/examples/gallery/pages_overlay.go +++ b/examples/gallery/pages_overlay.go @@ -298,8 +298,8 @@ func hoverCardDemo(c *ui.Context, log func(string)) { log("follow") } }) - if (card != nil) != s.shown { - s.shown = card != nil + if card.Valid() != s.shown { + s.shown = card.Valid() log(fmt.Sprintf("card shown = %v", s.shown)) } } diff --git a/examples/gallery/web.go b/examples/gallery/web.go index b45eea2..ecde228 100644 --- a/examples/gallery/web.go +++ b/examples/gallery/web.go @@ -81,6 +81,7 @@ func (m *mirror) loop() { case <-time.After(wait): } m.mu.Lock() + m.sync() m.tt.Frame() changed := m.capture() m.mu.Unlock() @@ -92,6 +93,13 @@ func (m *mirror) loop() { } } +// sync moves the Tester's virtual clock up to the wall clock; the caller holds mu. +func (m *mirror) sync() { + if d := time.Since(m.tt.Now()); d > 0 { + m.tt.Advance(d) + } +} + func (m *mirror) kick() { select { case m.wake <- struct{}{}: diff --git a/examples/gallery/web_input.go b/examples/gallery/web_input.go index 90fde72..476f5f0 100644 --- a/examples/gallery/web_input.go +++ b/examples/gallery/web_input.go @@ -36,6 +36,7 @@ func (m *mirror) serveInput(w http.ResponseWriter, r *http.Request) { return } m.mu.Lock() + m.sync() err := m.applyWatchingClipboard(in) if err == nil { m.capture() diff --git a/feedback/activity_feed.go b/feedback/activity_feed.go index 0839203..a40bb82 100644 --- a/feedback/activity_feed.go +++ b/feedback/activity_feed.go @@ -47,7 +47,7 @@ type activityFeedCache struct { } // ActivityFeed lists events grouped by day, newest first as given, building only the rows in view. -func ActivityFeed(c *ui.Context, items []Activity, opts ActivityFeedOptions) *ui.Element { +func ActivityFeed(c *ui.Context, items []Activity, opts ActivityFeedOptions) ui.Element { loc := cmp.Or(opts.Location, time.Local) for _, a := range items { if a.ID == "" || a.Actor == "" || a.Action == "" || a.Time.IsZero() { @@ -92,7 +92,7 @@ func ActivityFeed(c *ui.Context, items []Activity, opts ActivityFeedOptions) *ui ui.Box(c).FillWidth().Height(rowH * float32(len(cache.rows))).Children(func() { for r := first; r < last; r++ { row := cache.rows[r] - box := ui.Row(c).Key(r).Absolute().Left(0).Right(0).Top(rowH*float32(r)).Height(rowH). + box := ui.Row(c.Key(r)).Absolute().Left(0).Right(0).Top(rowH*float32(r)).Height(rowH). AlignItems(ui.Center).Gap(d.Unit()*3).Padding(0, d.Unit()*3) if row.item < 0 { activityFeedHeader(c, k, box, row.day, today) @@ -106,7 +106,7 @@ func ActivityFeed(c *ui.Context, items []Activity, opts ActivityFeedOptions) *ui } // activityFeedHeader is a day's title: Today, Yesterday or the date, over an ornament rule. -func activityFeedHeader(c *ui.Context, k theme.Tokens, box *ui.Element, day, today core.Date) { +func activityFeedHeader(c *ui.Context, k theme.Tokens, box ui.Element, day, today core.Date) { label := core.DatetimeDayLabel(c, day) switch day { case today: @@ -122,7 +122,7 @@ func activityFeedHeader(c *ui.Context, k theme.Tokens, box *ui.Element, day, tod } // activityFeedEvent is one event: avatar, "Actor action Object", and the time. -func activityFeedEvent(c *ui.Context, k theme.Tokens, box *ui.Element, a Activity, loc *time.Location) { +func activityFeedEvent(c *ui.Context, k theme.Tokens, box ui.Element, a Activity, loc *time.Location) { when := a.Time.In(loc).Format("15:04") box.Role(ui.RoleListItem).Label(a.Actor + " " + a.Action + " " + a.Object + ", " + when) box.Children(func() { diff --git a/feedback/bars_loader.go b/feedback/bars_loader.go index 0b2c9fd..aaff56c 100644 --- a/feedback/bars_loader.go +++ b/feedback/bars_loader.go @@ -7,7 +7,7 @@ import ( ) // BarsLoader shows four bars rising and falling in turn. -func BarsLoader(c *ui.Context, opts LoaderOptions) *ui.Element { +func BarsLoader(c *ui.Context, opts LoaderOptions) ui.Element { return loaderMark(c, opts, "bars_loader", 1.25, 1000, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) { w := r.W / 7 for i := range 4 { diff --git a/feedback/blink_highlight.go b/feedback/blink_highlight.go index edf3051..a692124 100644 --- a/feedback/blink_highlight.go +++ b/feedback/blink_highlight.go @@ -29,7 +29,7 @@ type blinkState struct { } // BlinkHighlight tints value once on change and marks its direction. -func BlinkHighlight(c *ui.Context, value float64, opts BlinkHighlightOptions) *ui.Element { +func BlinkHighlight(c *ui.Context, value float64, opts BlinkHighlightOptions) ui.Element { if math.IsNaN(value) || math.IsInf(value, 0) || opts.Duration < 0 { panic("mujica: BlinkHighlight needs a finite value and a non-negative Duration") } diff --git a/feedback/blink_highlight_test.go b/feedback/blink_highlight_test.go index 8246afe..f620f1a 100644 --- a/feedback/blink_highlight_test.go +++ b/feedback/blink_highlight_test.go @@ -27,8 +27,7 @@ func TestBlinkHighlightFlashesOnceWithDirection(t *testing.T) { if _, ok := tt.Find("12.00, up"); !ok { t.Fatalf("direction not named: %q", tt.Texts()) } - time.Sleep(250 * time.Millisecond) - tt.Frame() + tt.Advance(250 * time.Millisecond) if bytes.Equal(a, encode(t, tt.Image())) || motionBusy(tt, n) { t.Fatal("flash did not fade and stop") } diff --git a/feedback/count_up.go b/feedback/count_up.go index c6d2d39..f60a3e2 100644 --- a/feedback/count_up.go +++ b/feedback/count_up.go @@ -28,7 +28,7 @@ type countUpState struct { } // CountUp counts from the last value to the new one. -func CountUp(c *ui.Context, value float64, opts CountUpOptions) *ui.Element { +func CountUp(c *ui.Context, value float64, opts CountUpOptions) ui.Element { if math.IsNaN(value) || math.IsInf(value, 0) { panic("mujica: CountUp value must be finite") } diff --git a/feedback/count_up_test.go b/feedback/count_up_test.go index 70f9ac7..8e3e152 100644 --- a/feedback/count_up_test.go +++ b/feedback/count_up_test.go @@ -22,8 +22,7 @@ func TestCountUpCountsAndSettles(t *testing.T) { } value = 200 tt.Frame() - time.Sleep(100 * time.Millisecond) - tt.Frame() + tt.Advance(100 * time.Millisecond) mid := "" for _, s := range tt.Texts() { if s != "200" { @@ -33,8 +32,7 @@ func TestCountUpCountsAndSettles(t *testing.T) { if mid == "" || mid == "100" { t.Fatalf("no value between: %q", tt.Texts()) } - time.Sleep(300 * time.Millisecond) - tt.Frame() + tt.Advance(300 * time.Millisecond) if motionCount(tt, "200") != 2 || motionBusy(tt, n) { t.Fatalf("did not settle: %q", tt.Texts()) } diff --git a/feedback/dots_loader.go b/feedback/dots_loader.go index be2dc52..61ed22f 100644 --- a/feedback/dots_loader.go +++ b/feedback/dots_loader.go @@ -24,7 +24,7 @@ type LoaderOptions struct { type loaderDraw func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) // loaderMark builds a loader that repaints only while painted. -func loaderMark(c *ui.Context, opts LoaderOptions, key string, aspect float32, period int64, draw loaderDraw) *ui.Element { +func loaderMark(c *ui.Context, opts LoaderOptions, key string, aspect float32, period int64, draw loaderDraw) ui.Element { if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) { panic("mujica: loader Size must be finite and not negative") } @@ -44,7 +44,7 @@ func loaderMark(c *ui.Context, opts LoaderOptions, key string, aspect float32, p still := c.Preferences().ReduceMotion row := ui.Row(c).Gap(core.Density(c).Unit() * 2).AlignItems(ui.Center).Shrink(0).Role(ui.RoleProgress).Label(label) row.Children(func() { - ui.Box(c).Key(still).Size(size*aspect, size).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + ui.Box(c.Key(still)).Size(size*aspect, size).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { t := 0.0 if !still { t = float64(p.Now().UnixMilli()%period) / float64(period) @@ -64,7 +64,7 @@ func loaderMark(c *ui.Context, opts LoaderOptions, key string, aspect float32, p func loaderWave(t float64) float32 { return float32(0.5 - 0.5*math.Cos(2*math.Pi*t)) } // DotsLoader shows three dots lighting up in turn. -func DotsLoader(c *ui.Context, opts LoaderOptions) *ui.Element { +func DotsLoader(c *ui.Context, opts LoaderOptions) ui.Element { return loaderMark(c, opts, "dots_loader", 2.5, 1200, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) { d := r.H * 0.36 for i := range 3 { diff --git a/feedback/empty_state.go b/feedback/empty_state.go index 597a178..3d14d31 100644 --- a/feedback/empty_state.go +++ b/feedback/empty_state.go @@ -38,7 +38,7 @@ type EmptyStateOptions struct { // EmptyStateResult is what an EmptyState built and reported this frame. type EmptyStateResult struct { - Element *ui.Element + Element ui.Element Action bool } diff --git a/feedback/eval_result_table.go b/feedback/eval_result_table.go index 8d7bedc..d07a75f 100644 --- a/feedback/eval_result_table.go +++ b/feedback/eval_result_table.go @@ -34,7 +34,7 @@ type EvalResultTableOptions struct { // EvalResultTableResult is a built EvalResultTable and its events. type EvalResultTableResult struct { - Element *ui.Element + Element ui.Element Table data.DataTableView diff string } diff --git a/feedback/layout_transition.go b/feedback/layout_transition.go index 94bae4d..35b8f7c 100644 --- a/feedback/layout_transition.go +++ b/feedback/layout_transition.go @@ -20,7 +20,7 @@ type LayoutTransitionOptions struct { } // LayoutTransition animates its size and place when they change. -func LayoutTransition(c *ui.Context, opts LayoutTransitionOptions, content func()) *ui.Element { +func LayoutTransition(c *ui.Context, opts LayoutTransitionOptions, content func()) ui.Element { if opts.Duration < 0 { panic("mujica: LayoutTransition Duration is negative") } diff --git a/feedback/layout_transition_test.go b/feedback/layout_transition_test.go index 3af0325..f0b8284 100644 --- a/feedback/layout_transition_test.go +++ b/feedback/layout_transition_test.go @@ -30,8 +30,7 @@ func TestLayoutTransitionResizes(t *testing.T) { if *n-before < 3 { t.Fatal("resize did not animate") } - time.Sleep(260 * time.Millisecond) - tt.Frame() + tt.Advance(260 * time.Millisecond) if motionBusy(tt, n) || w != 300 { t.Fatalf("settled width %v", w) } diff --git a/feedback/loaders_test.go b/feedback/loaders_test.go index 3b5b110..2cb08c3 100644 --- a/feedback/loaders_test.go +++ b/feedback/loaders_test.go @@ -39,11 +39,11 @@ func motionCount(tt *ui.Tester, s string) int { return n } -var loaderCases = map[string]func(c *ui.Context, o LoaderOptions) *ui.Element{ +var loaderCases = map[string]func(c *ui.Context, o LoaderOptions) ui.Element{ "dots": DotsLoader, "bars": BarsLoader, "pulse": PulseLoader, "orbit": OrbitLoader, "wave": WaveLoader, } -func testLoaderBehavior(t *testing.T, loader func(*ui.Context, LoaderOptions) *ui.Element) { +func testLoaderBehavior(t *testing.T, loader func(*ui.Context, LoaderOptions) ui.Element) { show := true tt, n := motionFrames(func(c *ui.Context) { if show { @@ -57,8 +57,7 @@ func testLoaderBehavior(t *testing.T, loader func(*ui.Context, LoaderOptions) *u t.Fatal("label shown without ShowLabel") } a := encode(t, tt.Image()) - time.Sleep(120 * time.Millisecond) - tt.Frame() + tt.Advance(120 * time.Millisecond) if bytes.Equal(a, encode(t, tt.Image())) || !motionBusy(tt, n) { t.Fatal("loader does not move") } @@ -67,8 +66,7 @@ func testLoaderBehavior(t *testing.T, loader func(*ui.Context, LoaderOptions) *u t.Fatalf("reduced motion: still repainting or no static label: %q", tt.Texts()) } a = encode(t, tt.Image()) - time.Sleep(120 * time.Millisecond) - tt.Frame() + tt.Advance(120 * time.Millisecond) if !bytes.Equal(a, encode(t, tt.Image())) { t.Fatal("loader moved under reduced motion") } @@ -96,7 +94,7 @@ func TestLoadersInvalidSize(t *testing.T) { } } -func loaderSnapshot(t *testing.T, name string, loader func(c *ui.Context, o LoaderOptions) *ui.Element) { +func loaderSnapshot(t *testing.T, name string, loader func(c *ui.Context, o LoaderOptions) ui.Element) { snapshot(t, name, 480, 90, func(c *ui.Context) { ui.Row(c).Padding(20).Gap(24).AlignItems(ui.Center).Children(func() { loader(c, LoaderOptions{}) diff --git a/feedback/loading_overlay.go b/feedback/loading_overlay.go index 3318cd3..cb4ff79 100644 --- a/feedback/loading_overlay.go +++ b/feedback/loading_overlay.go @@ -22,7 +22,7 @@ type LoadingOverlayOptions struct { // LoadingOverlayResult is what a LoadingOverlay built and reported this frame. type LoadingOverlayResult struct { - Element *ui.Element + Element ui.Element // Canceled reports the cancel button or Escape while loading. Canceled bool } diff --git a/feedback/notification_center.go b/feedback/notification_center.go index 92ef759..a4588e2 100644 --- a/feedback/notification_center.go +++ b/feedback/notification_center.go @@ -35,7 +35,7 @@ type NotificationCenterOptions struct { // NotificationCenterResult reports the frame's events by notification ID. type NotificationCenterResult struct { - Element *ui.Element + Element ui.Element Action string // Toggled changed its Read state; Opened was clicked and is now read. Toggled string @@ -127,7 +127,7 @@ func notificationRow(c *ui.Context, k theme.Tokens, u float32, n *Notification, if !n.Read { name = core.Msg(c, "notification_center.unread_mark") + ": " + n.Title } - row := ui.Row(c).Key(n.ID).FillWidth().AlignItems(ui.Start).Gap(u*2).Padding(u*3, u*3, u*3, u*2). + row := ui.Row(c.Key(n.ID)).FillWidth().AlignItems(ui.Start).Gap(u*2).Padding(u*3, u*3, u*3, u*2). Role(ui.RoleListItem) if row.Hovered() { row.Background(k.SurfaceHover) @@ -186,7 +186,7 @@ func notificationRow(c *ui.Context, k theme.Tokens, u float32, n *Notification, } } -func notificationIconButton(c *ui.Context, k theme.Tokens, u float32, icon *ui.SVG, label string) *ui.Element { +func notificationIconButton(c *ui.Context, k theme.Tokens, u float32, icon *ui.SVG, label string) ui.Element { b := input.Button(c, "", input.ButtonOptions{Variant: input.Ghost, Icon: icon, Label: label}) b.TextColor(k.TextMuted).Padding(0, u).Tooltip(label) return b diff --git a/feedback/notification_center_test.go b/feedback/notification_center_test.go index 82d8bed..bdf5bad 100644 --- a/feedback/notification_center_test.go +++ b/feedback/notification_center_test.go @@ -21,7 +21,7 @@ func notificationTester(items *[]Notification, last *NotificationCenterResult) * core.Use(c, core.Settings{}) ui.Column(c).Height(560).Children(func() { r := NotificationCenter(c, items, NotificationCenterOptions{}) - r.Element = nil + r.Element = ui.Element{} if r != (NotificationCenterResult{}) { *last = r } diff --git a/feedback/orbit_loader.go b/feedback/orbit_loader.go index 66db23e..3e51eac 100644 --- a/feedback/orbit_loader.go +++ b/feedback/orbit_loader.go @@ -9,7 +9,7 @@ import ( ) // OrbitLoader moves a diamond around a ring of diamonds. -func OrbitLoader(c *ui.Context, opts LoaderOptions) *ui.Element { +func OrbitLoader(c *ui.Context, opts LoaderOptions) ui.Element { return loaderMark(c, opts, "orbit_loader", 1, 1600, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) { cx, cy, rad := r.X+r.W/2, r.Y+r.H/2, r.W*0.36 at := func(turn float64) (float32, float32) { diff --git a/feedback/presence.go b/feedback/presence.go index fc5a2e7..bf68bcd 100644 --- a/feedback/presence.go +++ b/feedback/presence.go @@ -45,7 +45,7 @@ func presenceMotion(kind PresenceKind) *ui.Motion { type presenceState struct{ shown bool } // Presence builds content while show, entering and exiting with motion. -func Presence(c *ui.Context, show bool, opts PresenceOptions, content func()) *ui.Element { +func Presence(c *ui.Context, show bool, opts PresenceOptions, content func()) ui.Element { m := presenceMotion(opts.Kind) if opts.Duration < 0 { panic("mujica: Presence Duration is negative") @@ -64,11 +64,11 @@ func Presence(c *ui.Context, show bool, opts PresenceOptions, content func()) *u if !show { if c.Preferences().ReduceMotion { // Rebuilding the empty slot cancels MyGo's active exit. - ui.Column(c).Key("presence").Invisible().Transition(ui.ElementTransition{Duration: d, Position: true}) + ui.Column(c.Key("presence")).Invisible().Transition(ui.ElementTransition{Duration: d, Position: true}) } return } - box := ui.Column(c).AlignItems(ui.Stretch).Key("presence") + box := ui.Column(c.Key("presence")).AlignItems(ui.Stretch) box.Transition(ui.ElementTransition{Duration: core.Motion(c, d), Enter: m, Exit: m}) box.Children(content) }) diff --git a/feedback/presence_test.go b/feedback/presence_test.go index 71f19f7..2d565eb 100644 --- a/feedback/presence_test.go +++ b/feedback/presence_test.go @@ -27,16 +27,14 @@ func TestPresenceEntersExitsAndRemoves(t *testing.T) { if !tt.HasText("Notice") || *n-before < 3 { t.Fatalf("kind %d: no entry motion (%d frames)", kind, *n-before) } - time.Sleep(250 * time.Millisecond) - tt.Frame() + tt.Advance(250 * time.Millisecond) show = false before = *n tt.Frame() if *n-before < 3 { t.Fatalf("kind %d: no exit motion", kind) } - time.Sleep(250 * time.Millisecond) - tt.Frame() + tt.Advance(250 * time.Millisecond) if tt.HasText("Notice") || motionBusy(tt, n) { t.Fatalf("kind %d: not removed after exit", kind) } diff --git a/feedback/progress.go b/feedback/progress.go index 065d31e..d7d6c14 100644 --- a/feedback/progress.go +++ b/feedback/progress.go @@ -33,7 +33,7 @@ type ProgressOptions struct { } // Progress shows work done, value 0 to 1; indeterminate ones hold still under reduced motion. -func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element { +func Progress(c *ui.Context, value float64, opts ProgressOptions) ui.Element { if opts.Shape != ProgressLinear && opts.Shape != ProgressRing { panic(fmt.Sprintf("mujica: unknown ProgressShape %d", int(opts.Shape))) } @@ -102,7 +102,7 @@ func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element { return col } -func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, caption string, opts ProgressOptions, still bool) *ui.Element { +func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, caption string, opts ProgressOptions, still bool) ui.Element { d := opts.Size if d == 0 { d = 48 @@ -157,7 +157,7 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c } // progressAnimate eases toward target; under reduced motion it lands there and keeps the state in step. -func progressAnimate(e *ui.Element, target float32, still bool) float32 { +func progressAnimate(e ui.Element, target float32, still bool) float32 { if still { // A changed target lands at once under reduced motion; the detour stops a running transition. e.Animate("progress.value", target+1, theme.StateDuration) @@ -193,7 +193,7 @@ func meterLevel(v float64, l *ui.MeterLevels) int { } // Meter shows a level within a range with threshold ticks and a level word, not color alone. -func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element { +func Meter(c *ui.Context, value float64, opts MeterOptions) ui.Element { lo, hi := opts.Min, opts.Max if lo == 0 && hi == 0 { hi = 1 diff --git a/feedback/pulse_loader.go b/feedback/pulse_loader.go index ea3bed4..1f079db 100644 --- a/feedback/pulse_loader.go +++ b/feedback/pulse_loader.go @@ -7,7 +7,7 @@ import ( ) // PulseLoader shows one dot gently breathing, without a halo. -func PulseLoader(c *ui.Context, opts LoaderOptions) *ui.Element { +func PulseLoader(c *ui.Context, opts LoaderOptions) ui.Element { return loaderMark(c, opts, "pulse_loader", 1, 1600, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) { s, a := float32(1), float32(1) if !still { diff --git a/feedback/reorder_transition.go b/feedback/reorder_transition.go index 367a80d..10d72e6 100644 --- a/feedback/reorder_transition.go +++ b/feedback/reorder_transition.go @@ -20,7 +20,7 @@ type ReorderTransitionOptions struct { } // ReorderTransition slides keyed items to their new places. -func ReorderTransition[K comparable](c *ui.Context, keys []K, opts ReorderTransitionOptions, item func(key K)) *ui.Element { +func ReorderTransition[K comparable](c *ui.Context, keys []K, opts ReorderTransitionOptions, item func(key K)) ui.Element { if opts.Duration < 0 || !(opts.Gap >= 0) || math.IsInf(float64(opts.Gap), 0) { panic("mujica: ReorderTransition Duration and Gap must not be negative") } @@ -50,7 +50,7 @@ func ReorderTransition[K comparable](c *ui.Context, keys []K, opts ReorderTransi st.keys = append(st.keys[:0], keys...) col.Children(func() { for _, k := range keys { - ui.Column(c).Key(k).AlignItems(ui.Stretch).Transition(move).Children(func() { item(k) }) + ui.Column(c.Key(k)).AlignItems(ui.Stretch).Transition(move).Children(func() { item(k) }) } for k, until := range st.leaving { if seen[k] || !c.Now().Before(until) { @@ -58,7 +58,7 @@ func ReorderTransition[K comparable](c *ui.Context, keys []K, opts ReorderTransi continue } if c.Preferences().ReduceMotion { - ui.Column(c).Key(k).Absolute().Invisible().Transition(ui.ElementTransition{Duration: d, Position: true}) + ui.Column(c.Key(k)).Absolute().Invisible().Transition(ui.ElementTransition{Duration: d, Position: true}) delete(st.leaving, k) } } diff --git a/feedback/reorder_transition_test.go b/feedback/reorder_transition_test.go index 6e2ec7b..25b2e92 100644 --- a/feedback/reorder_transition_test.go +++ b/feedback/reorder_transition_test.go @@ -8,6 +8,7 @@ import ( "github.com/egoist/mygo/ui" "git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core" + "git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme" ) func TestReorderTransitionSlides(t *testing.T) { @@ -24,9 +25,9 @@ func TestReorderTransitionSlides(t *testing.T) { if *n-before < 3 { t.Fatal("reorder did not animate") } + tt.Advance(theme.StateDuration / 2) mid, _ := tt.Find("Item a") - time.Sleep(260 * time.Millisecond) - tt.Frame() + tt.Advance(260 * time.Millisecond) end, _ := tt.Find("Item a") if !(mid.Y > ra.Y && mid.Y < end.Y) || motionBusy(tt, n) { t.Fatalf("a moved %v → %v → %v", ra.Y, mid.Y, end.Y) diff --git a/feedback/result.go b/feedback/result.go index 3ab683a..3742f15 100644 --- a/feedback/result.go +++ b/feedback/result.go @@ -35,7 +35,7 @@ type ResultOptions struct { // ResultResult is what a Result built and reported this frame. type ResultResult struct { - Element *ui.Element + Element ui.Element // Action is the index of the clicked action, -1 for none. Action int } diff --git a/feedback/shimmer.go b/feedback/shimmer.go index 3b3ab62..7dee6d8 100644 --- a/feedback/shimmer.go +++ b/feedback/shimmer.go @@ -21,7 +21,7 @@ type ShimmerOptions struct { } // Shimmer sweeps a placeholder while loading, then builds content. -func Shimmer(c *ui.Context, loading bool, opts ShimmerOptions, content func()) *ui.Element { +func Shimmer(c *ui.Context, loading bool, opts ShimmerOptions, content func()) ui.Element { if !(opts.Width >= 0) || !(opts.Height >= 0) || math.IsInf(float64(opts.Width+opts.Height), 1) { panic("mujica: Shimmer size must be finite and not negative") } @@ -54,7 +54,7 @@ func Shimmer(c *ui.Context, loading bool, opts ShimmerOptions, content func()) * } // Keyed by motion so a flip repaints. e.Children(func() { - ui.Box(c).Key(still).Grow(1).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + ui.Box(c.Key(still)).Grow(1).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(r, fill, theme.CardRadius) if still { return diff --git a/feedback/shimmer_test.go b/feedback/shimmer_test.go index 1670cd2..69db3b3 100644 --- a/feedback/shimmer_test.go +++ b/feedback/shimmer_test.go @@ -19,8 +19,7 @@ func TestShimmerOnlyWhileLoading(t *testing.T) { t.Fatalf("loading: %q", tt.Texts()) } a := encode(t, tt.Image()) - time.Sleep(150 * time.Millisecond) - tt.Frame() + tt.Advance(150 * time.Millisecond) if bytes.Equal(a, encode(t, tt.Image())) || !motionBusy(tt, n) { t.Fatal("shimmer does not sweep") } diff --git a/feedback/shimmer_text.go b/feedback/shimmer_text.go index 1fb10e7..6bdaa92 100644 --- a/feedback/shimmer_text.go +++ b/feedback/shimmer_text.go @@ -13,7 +13,7 @@ type ShimmerTextOptions struct { } // ShimmerText sweeps light across a status such as "Thinking". -func ShimmerText(c *ui.Context, text string, opts ShimmerTextOptions) *ui.Element { +func ShimmerText(c *ui.Context, text string, opts ShimmerTextOptions) ui.Element { if text == "" { panic("mujica: ShimmerText needs text") } diff --git a/feedback/shimmer_text_test.go b/feedback/shimmer_text_test.go index d7d245e..c24cb96 100644 --- a/feedback/shimmer_text_test.go +++ b/feedback/shimmer_text_test.go @@ -19,8 +19,7 @@ func TestShimmerTextSweepsAndStops(t *testing.T) { t.Fatal("text not named") } a := encode(t, tt.Image()) - time.Sleep(200 * time.Millisecond) - tt.Frame() + tt.Advance(200 * time.Millisecond) if bytes.Equal(a, encode(t, tt.Image())) || !motionBusy(tt, n) { t.Fatal("no sweep") } diff --git a/feedback/skeleton.go b/feedback/skeleton.go index 11fcbad..97ff8d0 100644 --- a/feedback/skeleton.go +++ b/feedback/skeleton.go @@ -31,7 +31,7 @@ type SkeletonOptions struct { } // Skeleton stands in for loading content; it pulses while painted, static under reduced motion. -func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element { +func Skeleton(c *ui.Context, opts SkeletonOptions) ui.Element { if opts.Shape != SkeletonText && opts.Shape != SkeletonCircle && opts.Shape != SkeletonRect { panic(fmt.Sprintf("mujica: unknown SkeletonShape %d", int(opts.Shape))) } diff --git a/feedback/skeleton_test.go b/feedback/skeleton_test.go index 6e09783..bc188b2 100644 --- a/feedback/skeleton_test.go +++ b/feedback/skeleton_test.go @@ -43,15 +43,13 @@ func TestSkeletonStaticUnderReducedMotion(t *testing.T) { Skeleton(c, SkeletonOptions{Shape: SkeletonRect}) }, 200, 100) a := encode(t, tt.Image()) - time.Sleep(300 * time.Millisecond) - tt.Frame() + tt.Advance(300 * time.Millisecond) if bytes.Equal(a, encode(t, tt.Image())) { t.Fatal("skeleton did not pulse") } tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) a = encode(t, tt.Image()) - time.Sleep(300 * time.Millisecond) - tt.Frame() + tt.Advance(300 * time.Millisecond) if !bytes.Equal(a, encode(t, tt.Image())) { t.Fatal("skeleton moved under reduced motion") } diff --git a/feedback/stagger.go b/feedback/stagger.go index aafab55..c60cdaf 100644 --- a/feedback/stagger.go +++ b/feedback/stagger.go @@ -26,7 +26,7 @@ type staggerState struct { } // Stagger builds n items entering one after another, once. -func Stagger(c *ui.Context, n int, opts StaggerOptions, item func(i int)) *ui.Element { +func Stagger(c *ui.Context, n int, opts StaggerOptions, item func(i int)) ui.Element { if n < 0 || opts.Interval < 0 || opts.Duration < 0 { panic("mujica: Stagger needs non-negative n, Interval and Duration") } @@ -65,7 +65,7 @@ func Stagger(c *ui.Context, n int, opts StaggerOptions, item func(i int)) *ui.El } box.Children(func() { for i := range upto { - ui.Column(c).Key(i).AlignItems(ui.Stretch). + ui.Column(c.Key(i)).AlignItems(ui.Stretch). Transition(ui.ElementTransition{Duration: core.Motion(c, d), Enter: enter}).Children(func() { item(i) }) } }) diff --git a/feedback/stagger_test.go b/feedback/stagger_test.go index 1d52c91..88d61e5 100644 --- a/feedback/stagger_test.go +++ b/feedback/stagger_test.go @@ -19,15 +19,12 @@ func TestStaggerRevealsInTurnOnce(t *testing.T) { if !tt.HasText("Row 0") || tt.HasText("Row 4") { t.Fatalf("first frame: %q", tt.Texts()) } - time.Sleep(120 * time.Millisecond) - tt.Frame() + tt.Advance(120 * time.Millisecond) if !tt.HasText("Row 2") || tt.HasText("Row 4") { t.Fatalf("after 120 ms: %q", tt.Texts()) } - time.Sleep(300 * time.Millisecond) - tt.Frame() - time.Sleep(250 * time.Millisecond) - tt.Frame() + tt.Advance(300 * time.Millisecond) + tt.Advance(250 * time.Millisecond) if !tt.HasText("Row 4") || motionBusy(tt, n) { t.Fatalf("did not finish and settle: %q", tt.Texts()) } diff --git a/feedback/status_indicator.go b/feedback/status_indicator.go index cb8e2a7..90dcce2 100644 --- a/feedback/status_indicator.go +++ b/feedback/status_indicator.go @@ -44,7 +44,7 @@ type StatusIndicatorOptions struct { } // StatusIndicator shows a status by its own shape and a word, never by color alone. -func StatusIndicator(c *ui.Context, status Status, opts StatusIndicatorOptions) *ui.Element { +func StatusIndicator(c *ui.Context, status Status, opts StatusIndicatorOptions) ui.Element { key := "status_indicator." + status.String() // panics on an unknown Status k := core.Tokens(c) u := core.Density(c).Unit() diff --git a/feedback/toast.go b/feedback/toast.go index e224a44..780c25a 100644 --- a/feedback/toast.go +++ b/feedback/toast.go @@ -243,7 +243,7 @@ func Toaster(c *ui.Context) ToasterResult { // toastCard builds one toast and reports its events and whether hover or focus holds it. func toastCard(c *ui.Context, k theme.Tokens, u, width float32, m ToastMessage) (action, closed, held bool) { tone, icon := core.SeverityLook(k, m.Severity) - card := ui.Row(c).Key(m.ID).Width(width).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4). + card := ui.Row(c.Key(m.ID)).Width(width).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4). Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder). Role(ui.RoleStatus).Label(m.Message) card.Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.14)) diff --git a/feedback/toast_test.go b/feedback/toast_test.go index 1da0131..26fe374 100644 --- a/feedback/toast_test.go +++ b/feedback/toast_test.go @@ -179,10 +179,10 @@ func TestToastExpiresAndHoverPauses(t *testing.T) { if !res.wake.IsZero() { t.Fatal("hovered toast still schedules its expiry") } - time.Sleep(2 * life) - released := time.Now() + tt.Advance(2 * life) + released := tt.Now() tt.Move(5, 395) - done := time.Now() + done := tt.Now() // The frame that ends the hover must schedule the expiry itself. if res.wake.IsZero() || res.wake.Before(released) || res.wake.After(done.Add(life)) { t.Fatalf("release scheduled wake %v, released %v", res.wake, released) @@ -191,8 +191,7 @@ func TestToastExpiresAndHoverPauses(t *testing.T) { t.Fatal("hovered toast expired") } // Stands in for the frame MyGo's timer runs at res.wake. - time.Sleep(time.Until(res.wake) + 5*time.Millisecond) - tt.Frame() + tt.Advance(res.wake.Sub(tt.Now()) + 5*time.Millisecond) if tt.HasText("Copied") { t.Fatal("toast stayed after its wake-up") } diff --git a/feedback/tool_registry_panel.go b/feedback/tool_registry_panel.go index 46038e2..37cd167 100644 --- a/feedback/tool_registry_panel.go +++ b/feedback/tool_registry_panel.go @@ -37,7 +37,7 @@ type ToolRegistryPanelOptions struct { // ToolRegistryResult is a built ToolRegistryPanel and its events. type ToolRegistryResult struct { - Element *ui.Element + Element ui.Element List data.ListView toggled string on bool @@ -123,7 +123,7 @@ func toolSchema(c *ui.Context, t AgentTool) { ui.Text(c, core.Msg(c, "tool_registry_panel.no_params")).TextColor(k.TextMuted) return } - ui.Column(c).Key(t.Name).Grow(1).MinHeight(0).Children(func() { + ui.Column(c.Key(t.Name)).Grow(1).MinHeight(0).Children(func() { ui.List(c, nil, len(t.Params), func(i int) { p := t.Params[i] ui.Column(c).Gap(2).MinHeight(core.Density(c).ControlHeight()).Children(func() { diff --git a/feedback/trace_viewer.go b/feedback/trace_viewer.go index d3e730f..1f2b2e2 100644 --- a/feedback/trace_viewer.go +++ b/feedback/trace_viewer.go @@ -160,7 +160,7 @@ func traceAttrs(c *ui.Context, sp TraceSpan, height float32) { }) } if len(sp.Attrs) > 0 { - ui.Column(c).Key(sp.ID).Height(min(float32(len(sp.Attrs))*core.Density(c).ControlHeight(), max(core.Density(c).ControlHeight(), min(200, height/4)))).Children(func() { + ui.Column(c.Key(sp.ID)).Height(min(float32(len(sp.Attrs))*core.Density(c).ControlHeight(), max(core.Density(c).ControlHeight(), min(200, height/4)))).Children(func() { ui.List(c, nil, len(sp.Attrs), func(i int) { a := sp.Attrs[i] row(a.Key, a.Value) diff --git a/feedback/typewriter_test.go b/feedback/typewriter_test.go index 6a74380..74fc150 100644 --- a/feedback/typewriter_test.go +++ b/feedback/typewriter_test.go @@ -21,8 +21,7 @@ func TestTypewriterRevealsFinishesAndSkips(t *testing.T) { if tt.HasText(text) && motionCount(tt, text) == 2 { t.Fatal("whole text shown at once") } - time.Sleep(120 * time.Millisecond) - tt.Frame() + tt.Advance(120 * time.Millisecond) shown := 0 for _, s := range tt.Texts() { if s != text && s != "Skip" && len(s) > shown { @@ -43,8 +42,7 @@ func TestTypewriterRevealsFinishesAndSkips(t *testing.T) { if motionCount(tt, "The court assembles at dusk.") != 1 { t.Fatalf("appended text restarted: %q", tt.Texts()) } - time.Sleep(300 * time.Millisecond) - tt.Frame() + tt.Advance(300 * time.Millisecond) if motionCount(tt, text) != 2 || finished != 2 { t.Fatalf("append: finished %d %q", finished, tt.Texts()) } diff --git a/feedback/undo_toast.go b/feedback/undo_toast.go index 320b474..56aad70 100644 --- a/feedback/undo_toast.go +++ b/feedback/undo_toast.go @@ -27,7 +27,7 @@ type UndoToastOptions struct { // UndoToastResult reports how an UndoToast ended this frame, if it did. type UndoToastResult struct { - Element *ui.Element + Element ui.Element Undone bool // Expired: the countdown ran out, or the user closed it; the action stands. Expired bool diff --git a/feedback/undo_toast_test.go b/feedback/undo_toast_test.go index a6db265..e4577ce 100644 --- a/feedback/undo_toast_test.go +++ b/feedback/undo_toast_test.go @@ -50,14 +50,12 @@ func TestUndoToastCountdownPauseAndClose(t *testing.T) { _ = tt.Click("Delete") r, _ := tt.Find("File deleted") tt.Move(r.X+r.W/2, r.Y+r.H/2) - time.Sleep(90 * time.Millisecond) - tt.Frame() + tt.Advance(90 * time.Millisecond) if !open { t.Fatal("hovered countdown ran out") } tt.Move(5, 5) - time.Sleep(90 * time.Millisecond) - tt.Frame() + tt.Advance(90 * time.Millisecond) if open || !last.Expired || undos != 0 { t.Fatalf("expiry: %v %+v %d", open, last, undos) } diff --git a/feedback/wave_loader.go b/feedback/wave_loader.go index eb01e21..0ff98de 100644 --- a/feedback/wave_loader.go +++ b/feedback/wave_loader.go @@ -9,7 +9,7 @@ import ( ) // WaveLoader draws a sine line flowing to the right. -func WaveLoader(c *ui.Context, opts LoaderOptions) *ui.Element { +func WaveLoader(c *ui.Context, opts LoaderOptions) ui.Element { return loaderMark(c, opts, "wave_loader", 2.5, 1400, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) { var path ui.Path amp := r.H * 0.3 diff --git a/files/archive_viewer.go b/files/archive_viewer.go index 4f3de81..6480bd7 100644 --- a/files/archive_viewer.go +++ b/files/archive_viewer.go @@ -56,7 +56,7 @@ type ArchiveViewerOptions struct { // ArchiveViewerResult is the viewer and the entry opened this frame. type ArchiveViewerResult struct { - Element *ui.Element + Element ui.Element Opened *ArchiveEntry Retried bool } diff --git a/files/file_explorer.go b/files/file_explorer.go index 97154f2..c1e052f 100644 --- a/files/file_explorer.go +++ b/files/file_explorer.go @@ -83,7 +83,7 @@ func (s *FileExplorerState) step(from, to *[]string) { // FileExplorerResult is a FileExplorer and what happened in this frame. type FileExplorerResult struct { - Element *ui.Element + Element ui.Element // Navigated reports a new current folder; Changed a new selection. Navigated, Changed bool // Opened is a file the user opened. diff --git a/files/file_grid.go b/files/file_grid.go index 39d5ef3..ff1517d 100644 --- a/files/file_grid.go +++ b/files/file_grid.go @@ -23,7 +23,7 @@ type FileGridOptions struct { // FileGridResult is a FileGrid and what happened in this frame. type FileGridResult struct { - Element *ui.Element + Element ui.Element // Changed reports a new selection; Opened is the entry opened. Changed bool Opened *FileEntry diff --git a/files/file_icon.go b/files/file_icon.go index 2397ab3..12626dc 100644 --- a/files/file_icon.go +++ b/files/file_icon.go @@ -75,7 +75,7 @@ func fileKindColor(k theme.Tokens, kind FileKind) ui.Color { } // FileIcon shows file type or folder opening state. -func FileIcon(c *ui.Context, name string, opts FileIconOptions) *ui.Element { +func FileIcon(c *ui.Context, name string, opts FileIconOptions) ui.Element { if opts.Size < 0 { panic("mujica: FileIcon Size is negative") } diff --git a/files/file_operation_progress.go b/files/file_operation_progress.go index 7a14c05..fff5f53 100644 --- a/files/file_operation_progress.go +++ b/files/file_operation_progress.go @@ -47,7 +47,7 @@ const ( // FileOperationResult is the panel and the user's answer this frame. type FileOperationResult struct { - Element *ui.Element + Element ui.Element Canceled bool // Resolved answers a conflict; ApplyAll extends that decision. Resolved ConflictChoice diff --git a/files/file_preview.go b/files/file_preview.go index a45f415..fae5ef5 100644 --- a/files/file_preview.go +++ b/files/file_preview.go @@ -37,7 +37,7 @@ type FilePreviewOptions struct { // FilePreviewResult exposes the panel and requested actions. type FilePreviewResult struct { - Element *ui.Element + Element ui.Element // Closed, Opened and Retried report requested actions. Closed, Opened, Retried bool } diff --git a/files/path_bar.go b/files/path_bar.go index 90eb390..9535f3d 100644 --- a/files/path_bar.go +++ b/files/path_bar.go @@ -24,7 +24,7 @@ type PathBarOptions struct { // PathBarResult exposes the toolbar and requested path. type PathBarResult struct { - Element *ui.Element + Element ui.Element // Navigated is the chosen ancestor path, or empty. Navigated string } @@ -140,7 +140,7 @@ func pathBarFold(c *ui.Context, segs []pathSegment, room float32) int { } // pathBarCrumb builds a button, emphasizing the current segment. -func pathBarCrumb(c *ui.Context, label string, current bool) *ui.Element { +func pathBarCrumb(c *ui.Context, label string, current bool) ui.Element { k := core.Tokens(c) d := core.Density(c) b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*2). diff --git a/files/presence_avatars.go b/files/presence_avatars.go index 9cb6fe8..34a64df 100644 --- a/files/presence_avatars.go +++ b/files/presence_avatars.go @@ -34,7 +34,7 @@ type PresenceAvatarsOptions struct { // PresenceAvatarsResult is the avatars and the follow request this frame. type PresenceAvatarsResult struct { - Element *ui.Element + Element ui.Element // Follow identifies a collaborator; Unfollow requests stopping. Follow string Unfollow bool @@ -94,7 +94,7 @@ func PresenceAvatars(c *ui.Context, people []Collaborator, opts PresenceAvatarsO rows.Key = func(i int) any { return people[limit+i].ID } ui.List(c, rows, extra, func(i int) { p := people[limit+i] - ui.Row(c).Key(p.ID).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Row(c.Key(p.ID)).Gap(u * 2).AlignItems(ui.Center).Children(func() { if presenceAvatar(c, p, 24, p.ID == opts.Following).Clicked() { pick(p) } @@ -108,7 +108,7 @@ func PresenceAvatars(c *ui.Context, people []Collaborator, opts PresenceAvatarsO } // presenceAvatar builds a collaborator button with presence status. -func presenceAvatar(c *ui.Context, p Collaborator, size float32, following bool) *ui.Element { +func presenceAvatar(c *ui.Context, p Collaborator, size float32, following bool) ui.Element { if p.ID == "" { panic("mujica: Collaborator needs an ID") } @@ -117,7 +117,7 @@ func presenceAvatar(c *ui.Context, p Collaborator, size float32, following bool) if following { key = "presence_avatars.unfollow" } - b := ui.ButtonBase(c).Key(p.ID).Size(max(size+4, theme.MinHitSize), max(size+4, theme.MinHitSize)).Radius((size + 4) / 2).Label(fmt.Sprintf(core.Msg(c, key), p.Name)).Tooltip(p.Name) + b := ui.ButtonBase(c.Key(p.ID)).Size(max(size+4, theme.MinHitSize), max(size+4, theme.MinHitSize)).Radius((size + 4) / 2).Label(fmt.Sprintf(core.Msg(c, key), p.Name)).Tooltip(p.Name) if p.Away { b.Description(core.Msg(c, "presence_avatars.away")) } @@ -134,7 +134,7 @@ func presenceAvatar(c *ui.Context, p Collaborator, size float32, following bool) } // presenceStyle styles hover, focus and following rings. -func presenceStyle(c *ui.Context, b *ui.Element, size float32, following bool) { +func presenceStyle(c *ui.Context, b ui.Element, size float32, following bool) { k := core.Tokens(c) b.Shrink(0).Background(k.Surface).FocusRing(false) if b.Hovered() { diff --git a/files/reactions.go b/files/reactions.go index af47ebd..66b4f1e 100644 --- a/files/reactions.go +++ b/files/reactions.go @@ -33,7 +33,7 @@ type ReactionsOptions struct { // ReactionsResult exposes reactions and requested changes. type ReactionsResult struct { - Element *ui.Element + Element ui.Element // Toggled identifies a reaction; Added identifies a picker choice. Toggled, Added string } @@ -65,7 +65,7 @@ func Reactions(c *ui.Context, reactions []Reaction, opts ReactionsOptions) React overlay.Popover(c, add, open, overlay.PopoverOptions{Label: core.Msg(c, "reactions.pick")}, func() { ui.Grid(c).Columns(min(len(opts.Choices), 6)).Gap(u).FocusGroup(ui.Horizontal).Children(func() { for _, e := range opts.Choices { - b := ui.ButtonBase(c).Key(e).Size(36, 36).Radius(theme.ControlRadius).Label(e) + b := ui.ButtonBase(c.Key(e)).Size(36, 36).Radius(theme.ControlRadius).Label(e) internal.Ring(b, core.Tokens(c).Focus, theme.ControlRadius) if b.Hovered() { b.Background(core.Tokens(c).SurfaceHover) @@ -85,14 +85,14 @@ func Reactions(c *ui.Context, reactions []Reaction, opts ReactionsOptions) React } // reactionChip frames and marks the current user's reaction. -func reactionChip(c *ui.Context, r Reaction) *ui.Element { +func reactionChip(c *ui.Context, r Reaction) ui.Element { k := core.Tokens(c) u := core.Density(c).Unit() label := fmt.Sprintf(core.Msg(c, "reactions.count"), r.Emoji, r.Count) if r.Mine { label += ", " + core.Msg(c, "reactions.mine") } - b := ui.ButtonBase(c).Key(r.Emoji).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, u*2).Gap(u). + b := ui.ButtonBase(c.Key(r.Emoji)).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, u*2).Gap(u). Radius(theme.MinHitSize / 2).Role(ui.RoleToggleButton).Selected(r.Mine).Label(label).Transition(core.ColorFade) if len(r.People) > 0 { b.Tooltip(strings.Join(r.People, ", ")) diff --git a/files/recent_files.go b/files/recent_files.go index 9ee7e07..c267421 100644 --- a/files/recent_files.go +++ b/files/recent_files.go @@ -49,7 +49,7 @@ type recentRow struct{ group, file int } // RecentFilesResult is the list and what happened this frame. type RecentFilesResult struct { - Element *ui.Element + Element ui.Element Opened *RecentFile // Removed identifies one file; Cleared requests clearing all files. Removed string diff --git a/files/remote_cursor.go b/files/remote_cursor.go index a50e8df..45bd6a5 100644 --- a/files/remote_cursor.go +++ b/files/remote_cursor.go @@ -30,7 +30,7 @@ type RemoteCursorOptions struct { // RemoteCursor overlays collaborator carets, selections and names; input passes through. // Place it filling the editor's box, after the editor. -func RemoteCursor(c *ui.Context, carets []RemoteCaret, opts RemoteCursorOptions) *ui.Element { +func RemoteCursor(c *ui.Context, carets []RemoteCaret, opts RemoteCursorOptions) ui.Element { if opts.Caret == nil { panic("mujica: RemoteCursor needs Caret") } diff --git a/files/rename_inline.go b/files/rename_inline.go index 1cb45b1..a5096a8 100644 --- a/files/rename_inline.go +++ b/files/rename_inline.go @@ -23,7 +23,7 @@ type RenameInlineOptions struct { // RenameInlineResult is a RenameInline and its outcome in this frame. type RenameInlineResult struct { - Element *ui.Element + Element ui.Element // Renamed reports a saved name; Canceled reports an abandoned edit. Renamed, Canceled bool } @@ -104,10 +104,10 @@ func RenameInline(c *ui.Context, name *string, editing *bool, opts RenameInlineO *editing, st.active = false, false slog.Debug("mujica: rename_inline end", "renamed", res.Renamed) } - ui.Box(c).Key(st.gen).Grow(1).MinWidth(0).Children(func() { + ui.Box(c.Key(st.gen)).Grow(1).MinWidth(0).Children(func() { in := input.TextInput(c, &st.draft, input.TextInputOptions{Label: core.Msg(c, "rename_inline.label"), Error: st.err}) in.Element.FillWidth() - in.Input.AutoFocus() + core.AutoFocus(c, in.Input) if in.Changed() { st.err = "" renameTyped(st, *name, opts.Folder) @@ -140,7 +140,7 @@ func renameCheck(c *ui.Context, name string, validate func(string) string) strin } // renameSelection marks the stem for replacement on initial input. -func renameSelection(c *ui.Context, in *ui.Element, st *renameState) { +func renameSelection(c *ui.Context, in ui.Element, st *renameState) { k := core.Tokens(c) stem := renameStem(st.draft, st.folder) if st.stemSel && st.tail == "" { @@ -149,8 +149,9 @@ func renameSelection(c *ui.Context, in *ui.Element, st *renameState) { p.Fill(ui.Rect{X: r.X, Y: r.Y + 2, W: w, H: r.H - 4}, k.Selection, 2) }) } + disabled := in.IsDisabled() in.HandleInput(func(ev ui.InputEvent) bool { - if !st.stemSel || in.IsDisabled() { + if !st.stemSel || disabled { return false } switch { diff --git a/files/share_dialog.go b/files/share_dialog.go index a54687f..c683c7a 100644 --- a/files/share_dialog.go +++ b/files/share_dialog.go @@ -43,7 +43,7 @@ type ShareDialogOptions struct { // ShareDialogResult is what happened in the dialog this frame. type ShareDialogResult struct { - Element *ui.Element + Element ui.Element // Invited requests membership; Changed and Removed identify applied changes. Invited *ShareInvite Changed, Removed string @@ -119,7 +119,7 @@ func shareMembers(c *ui.Context, members *[]ShareMember, res *ShareDialogResult, if m.ID == "" { panic("mujica: ShareMember needs an ID") } - ui.Row(c).Key(m.ID).Gap(u * 2).AlignItems(ui.Center).MinHeight(core.Density(c).ControlHeight()).Children(func() { + ui.Row(c.Key(m.ID)).Gap(u * 2).AlignItems(ui.Center).MinHeight(core.Density(c).ControlHeight()).Children(func() { display.Avatar(c, m.Name, display.AvatarOptions{Size: 28}) ui.Column(c).Grow(1).MinWidth(0).Children(func() { ui.Text(c, m.Name).SingleLine() diff --git a/files/storage_usage.go b/files/storage_usage.go index 85266b3..d65f695 100644 --- a/files/storage_usage.go +++ b/files/storage_usage.go @@ -39,7 +39,7 @@ type storageUsageState struct { } // StorageUsage displays segmented capacity with keyboard readouts and details. -func StorageUsage(c *ui.Context, segments []StorageSegment, opts StorageUsageOptions) *ui.Element { +func StorageUsage(c *ui.Context, segments []StorageSegment, opts StorageUsageOptions) ui.Element { if opts.Capacity <= 0 { panic("mujica: StorageUsage needs a positive Capacity") } diff --git a/files/transfer_queue.go b/files/transfer_queue.go index 9ee6967..cbc0bf0 100644 --- a/files/transfer_queue.go +++ b/files/transfer_queue.go @@ -54,7 +54,7 @@ type TransferQueueOptions struct { // TransferQueueResult exposes the queue and requested item action. type TransferQueueResult struct { - Element *ui.Element + Element ui.Element Action TransferAction // ID is the transfer the action is for. ID string diff --git a/finance/asset_allocation_chart.go b/finance/asset_allocation_chart.go index 8838cd3..529f721 100644 --- a/finance/asset_allocation_chart.go +++ b/finance/asset_allocation_chart.go @@ -99,7 +99,7 @@ func AssetAllocationChart(c *ui.Context, items []AllocationItem, opts AssetAlloc ui.Grid(c).ColumnTracks(ui.Fr(2), ui.Fr(1), ui.Fr(1), ui.Fr(2)).GapX(u * 2).MinHeight(core.Density(c).ControlHeight()).Children(func() { w := it.Value / total * 100 drift := w - it.Target - ui.Row(c).Key(it.Name).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Row(c.Key(it.Name)).Gap(u * 2).AlignItems(ui.Center).Children(func() { ui.Box(c).Size(12, 12).Draw(func(p *ui.Painter, r ui.Rect) { internal.Marker(p, memo.markers[i], r.X+6, r.Y+6, 10, pal[memo.markers[i]%len(pal)]) }) diff --git a/finance/bid_ask_bar.go b/finance/bid_ask_bar.go index ff43a11..173fa9e 100644 --- a/finance/bid_ask_bar.go +++ b/finance/bid_ask_bar.go @@ -20,7 +20,7 @@ type BidAskBarOptions struct { } // BidAskBar compares buy and sell volume in a split bar. -func BidAskBar(c *ui.Context, bid, ask float64, opts BidAskBarOptions) *ui.Element { +func BidAskBar(c *ui.Context, bid, ask float64, opts BidAskBarOptions) ui.Element { if bid < 0 || ask < 0 || math.IsNaN(bid+ask) { panic("mujica: BidAskBar volumes must be zero or more") } diff --git a/finance/currency_converter.go b/finance/currency_converter.go index 9c33223..3e8ad7a 100644 --- a/finance/currency_converter.go +++ b/finance/currency_converter.go @@ -39,7 +39,7 @@ type CurrencyConverterOptions struct { } type CurrencyConverterView struct { - Element *ui.Element + Element ui.Element Result float64 // PairChanged asks the caller for the new pair's rate. PairChanged bool @@ -92,12 +92,12 @@ func CurrencyConverter(c *ui.Context, s *CurrencyConverterState, currencies []Cu } v.Element.Children(func() { ui.Row(c).Gap(u * 2).AlignItems(ui.End).Wrap().Children(func() { - input.FormField(c, core.Msg(c, "currency_converter.amount"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, core.Msg(c, "currency_converter.amount"), input.FormFieldOptions{}, func() ui.Element { r := input.NumberInput(c, &s.Amount, input.NumberInputOptions{Min: 0, Max: 1e15, Step: math.Pow(10, -float64(from.Decimals)), Precision: from.Decimals, Label: core.Msg(c, "currency_converter.amount"), Disabled: opts.Disabled}) return r.Element.Width(170) }) - input.FormField(c, core.Msg(c, "currency_converter.from"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, core.Msg(c, "currency_converter.from"), input.FormFieldOptions{}, func() ui.Element { r := input.Select(c, &s.From, options, input.SelectOptions{Label: core.Msg(c, "currency_converter.from"), Disabled: opts.Disabled}) v.PairChanged = v.PairChanged || r.Changed() return r.Element @@ -106,7 +106,7 @@ func CurrencyConverter(c *ui.Context, s *CurrencyConverterState, currencies []Cu if swap.Clicked() && !swap.IsDisabled() { s.From, s.To, v.PairChanged = s.To, s.From, true } - input.FormField(c, core.Msg(c, "currency_converter.to"), input.FormFieldOptions{}, func() *ui.Element { + input.FormField(c, core.Msg(c, "currency_converter.to"), input.FormFieldOptions{}, func() ui.Element { r := input.Select(c, &s.To, options, input.SelectOptions{Label: core.Msg(c, "currency_converter.to"), Disabled: opts.Disabled}) v.PairChanged = v.PairChanged || r.Changed() return r.Element diff --git a/finance/depth_ladder.go b/finance/depth_ladder.go index 1286f1a..4ab6f9e 100644 --- a/finance/depth_ladder.go +++ b/finance/depth_ladder.go @@ -38,7 +38,7 @@ type depthLadderCache struct { // DepthLadderResult is a built DepthLadder and its clicked cell. type DepthLadderResult struct { - Element *ui.Element + Element ui.Element price float64 side OrderSide } @@ -118,7 +118,7 @@ func depthLadderRow(c *ui.Context, r *DepthLadderResult, s *DepthLadderState, t d := core.Density(c) price := float64(t) / scale text := data.StatisticFormat(price, dec) - ui.Row(c).Key(t).MinHeight(d.ControlHeight()).AlignItems(ui.Stretch).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() { + ui.Row(c.Key(t)).MinHeight(d.ControlHeight()).AlignItems(ui.Stretch).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() { cell := func(side OrderSide, size float64) { label := fmt.Sprintf(core.Msg(c, fmt.Sprintf("depth_ladder.order%d", side)), text) b := ui.ButtonBase(c).Grow(1).Basis(0).Padding(0, d.Unit()*2).Label(label) diff --git a/finance/greeks_table.go b/finance/greeks_table.go index 51e4432..689ef29 100644 --- a/finance/greeks_table.go +++ b/finance/greeks_table.go @@ -33,7 +33,7 @@ type GreeksTableOptions struct { var greekNames = [5]string{"Delta", "Gamma", "Theta", "Vega", "Rho"} -func GreeksTable(c *ui.Context, rows []GreeksRow, opts GreeksTableOptions) *ui.Element { +func GreeksTable(c *ui.Context, rows []GreeksRow, opts GreeksTableOptions) ui.Element { if opts.Decimals < 0 || opts.Decimals > 12 { panic("mujica: GreeksTable Decimals is negative") } @@ -50,7 +50,7 @@ func GreeksTable(c *ui.Context, rows []GreeksRow, opts GreeksTableOptions) *ui.E ui.Text(c, core.Msg(c, "greeks_table.contract")).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)). MinHeight(d.ControlHeight()).Role(ui.RoleColumnHeader) for i, name := range greekNames { - h := ui.Row(c).Key(name).Gap(2).AlignItems(ui.Center).Justify(ui.End).MinHeight(d.ControlHeight()). + h := ui.Row(c.Key(name)).Gap(2).AlignItems(ui.Center).Justify(ui.End).MinHeight(d.ControlHeight()). Role(ui.RoleColumnHeader).Label(name).Description(core.Msg(c, "greeks_table.note_"+greekKey(i))) h.Tooltip(core.Msg(c, "greeks_table.note_"+greekKey(i))) h.Children(func() { @@ -65,13 +65,13 @@ func GreeksTable(c *ui.Context, rows []GreeksRow, opts GreeksTableOptions) *ui.E ui.Grid(c).ColumnTracks(ui.Fr(2), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).GapX(u*2).Padding(0, u*3).MinHeight(d.ControlHeight()).Children(func() { r := rows[ri] vals := [5]float64{r.Greeks.Delta, r.Greeks.Gamma, r.Greeks.Theta, r.Greeks.Vega, r.Greeks.Rho} - ui.Text(c, r.Label).Key(ri).SingleLine().MinHeight(d.ControlHeight()).Role(ui.RoleCell) + ui.Text(c.Key(ri), r.Label).SingleLine().MinHeight(d.ControlHeight()).Role(ui.RoleCell) for i, v := range vals { s := "—" if !math.IsNaN(v) { s = data.StatisticSigned(v, dec) } - ui.Text(c, s).Key([2]int{ri, i}).JustifySelf(ui.End).FontFeatures("tnum").TextColor(k.Text). + ui.Text(c.Key([2]int{ri, i}), s).JustifySelf(ui.End).FontFeatures("tnum").TextColor(k.Text). Role(ui.RoleCell).Label(greekNames[i] + " " + s) } }) diff --git a/finance/index_card.go b/finance/index_card.go index 72ef7e0..f0e4d2e 100644 --- a/finance/index_card.go +++ b/finance/index_card.go @@ -36,7 +36,7 @@ func IndexCard(c *ui.Context, q IndexQuote, opts IndexCardOptions) core.ChoiceRe dec := core.PriceDecimals("IndexCard", opts.Decimals) k := core.Tokens(c) u := core.Density(c).Unit() - var e *ui.Element + var e ui.Element ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { if opts.Clickable { e = ui.ButtonBase(c).Justify(ui.Start) diff --git a/finance/level2_quotes.go b/finance/level2_quotes.go index a2858c2..fc0073f 100644 --- a/finance/level2_quotes.go +++ b/finance/level2_quotes.go @@ -40,7 +40,7 @@ type level2Mark struct { } // Level2Quotes lists bid and ask levels; changes flash and mark. -func Level2Quotes(c *ui.Context, data Level2Data, opts Level2QuotesOptions) *ui.Element { +func Level2Quotes(c *ui.Context, data Level2Data, opts Level2QuotesOptions) ui.Element { dec := core.PriceDecimals("Level2Quotes", opts.Decimals) if opts.SizeDecimals < 0 || opts.Levels < 0 { panic("mujica: Level2Quotes SizeDecimals and Levels cannot be negative") diff --git a/finance/margin_indicator.go b/finance/margin_indicator.go index 5ebd87b..be01c7c 100644 --- a/finance/margin_indicator.go +++ b/finance/margin_indicator.go @@ -21,7 +21,7 @@ type MarginIndicatorOptions struct { } // MarginIndicator shows maintenance over equity against its two lines. -func MarginIndicator(c *ui.Context, ratio float64, opts MarginIndicatorOptions) *ui.Element { +func MarginIndicator(c *ui.Context, ratio float64, opts MarginIndicatorOptions) ui.Element { warn, liq := opts.Warning, opts.Liquidation if warn == 0 && liq == 0 { warn, liq = 0.8, 1 diff --git a/finance/market_overview.go b/finance/market_overview.go index d0b2444..2a96351 100644 --- a/finance/market_overview.go +++ b/finance/market_overview.go @@ -23,7 +23,7 @@ type MarketOverviewOptions struct { } type MarketOverviewView struct { - Element *ui.Element + Element ui.Element Chosen int } @@ -39,7 +39,7 @@ func MarketOverview(c *ui.Context, indices []IndexQuote, opts MarketOverviewOpti v.Element.Children(func() { ui.Row(c).Wrap().Gap(u * 3).Children(func() { for i, q := range indices { - ui.Column(c).Key(q.Name).Children(func() { + ui.Column(c.Key(q.Name)).Children(func() { if IndexCard(c, q, IndexCardOptions{Decimals: opts.Decimals, Clickable: true, Disabled: opts.Disabled}).Changed() { v.Chosen = i } diff --git a/finance/market_status.go b/finance/market_status.go index 2bbc3df..963bde3 100644 --- a/finance/market_status.go +++ b/finance/market_status.go @@ -36,7 +36,7 @@ type MarketStatusOptions struct { Now time.Time } -func MarketStatus(c *ui.Context, phase MarketPhase, opts MarketStatusOptions) *ui.Element { +func MarketStatus(c *ui.Context, phase MarketPhase, opts MarketStatusOptions) ui.Element { marketPhaseCheck(phase) marketPhaseCheck(opts.NextPhase) k := core.Tokens(c) diff --git a/finance/news_feed.go b/finance/news_feed.go index 446904d..e686839 100644 --- a/finance/news_feed.go +++ b/finance/news_feed.go @@ -58,7 +58,7 @@ type NewsFeedOptions struct { // NewsFeedView is a built NewsFeed and this frame's events. type NewsFeedView struct { - Element *ui.Element + Element ui.Element Opened string // Symbol is the related code clicked; empty for none. Symbol string @@ -136,7 +136,7 @@ func NewsFeed(c *ui.Context, s *NewsFeedState, items []NewsItem, opts NewsFeedOp ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Wrap().Children(func() { ui.Text(c, it.Source+" · "+chat.DateSeparatorWhen(c, it.Time, today)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) for _, sym := range it.Symbols { - ui.Row(c).Key(sym).Children(func() { + ui.Row(c.Key(sym)).Children(func() { if SymbolBadge(c, MarketSymbol{Code: sym}, SymbolBadgeOptions{Clickable: true, Disabled: opts.Disabled}).Changed() { v.Symbol = sym } diff --git a/finance/option_chain.go b/finance/option_chain.go index c9cb2ee..a129ffb 100644 --- a/finance/option_chain.go +++ b/finance/option_chain.go @@ -54,7 +54,7 @@ type OptionChainOptions struct { // OptionChainView is a built OptionChain and this frame's events. type OptionChainView struct { - Element *ui.Element + Element ui.Element // ExpiryChanged asks the caller for the strikes of State.Expiry. ExpiryChanged bool Changed, Submitted bool diff --git a/finance/order_book.go b/finance/order_book.go index af2676d..b8e4fba 100644 --- a/finance/order_book.go +++ b/finance/order_book.go @@ -90,7 +90,7 @@ func bookKeyOf(book OrderBookData, group float64) bookKey { // OrderBookResult is a built OrderBook and its clicked level. type OrderBookResult struct { - Element *ui.Element + Element ui.Element price float64 side OrderSide } @@ -191,7 +191,7 @@ func orderBookRow(c *ui.Context, r *OrderBookResult, l BookLevel, cum, total flo d := core.Density(c) col := core.TrendColor(c, side.sign()) price := data.StatisticFormat(l.Price, dec) - b := ui.ButtonBase(c).Key(fmt.Sprint(side, price)).MinHeight(d.ControlHeight()). + b := ui.ButtonBase(c.Key(fmt.Sprint(side, price))).MinHeight(d.ControlHeight()). Padding(0, d.Unit()*2).Radius(theme.ControlRadius). Label(fmt.Sprintf(core.Msg(c, "order_book.level"), core.Msg(c, fmt.Sprintf("order_book.side%d", side)), price, data.StatisticFormat(l.Size, sizeDec))) internal.Ring(b, k.Focus, theme.ControlRadius) diff --git a/finance/order_confirm_dialog.go b/finance/order_confirm_dialog.go index 640df89..a3301f5 100644 --- a/finance/order_confirm_dialog.go +++ b/finance/order_confirm_dialog.go @@ -50,7 +50,7 @@ func OrderConfirmDialog(c *ui.Context, open *bool, opts OrderConfirmDialogOption was := *open var st *orderConfirmState overlay.Dialog(c, open, overlay.DialogOptions{Title: title, KeepOnBackdrop: true, HideClose: true, Actions: func() { - if input.Button(c, core.Msg(c, "order_confirm_dialog.cancel"), input.ButtonOptions{Variant: input.Secondary}).AutoFocus().Clicked() { + if core.AutoFocus(c, input.Button(c, core.Msg(c, "order_confirm_dialog.cancel"), input.ButtonOptions{Variant: input.Secondary})).Clicked() { res.Canceled = true } locked := opts.Acknowledge != "" && !st.ack diff --git a/finance/order_entry.go b/finance/order_entry.go index 768a296..a07573c 100644 --- a/finance/order_entry.go +++ b/finance/order_entry.go @@ -78,7 +78,7 @@ func (s *OrderEntryState) EndSubmit(formError string) { s.Form.EndSubmit(formErr // OrderEntryResult is a built OrderEntry and its submitted order. type OrderEntryResult struct { - Element *ui.Element + Element ui.Element order OrderDraft submitted bool } @@ -167,13 +167,13 @@ func orderEntryFields(c *ui.Context, f *input.FormScope, s *OrderEntryState, opt input.FormItem(c, f, "quantity", &s.Draft, input.FormItemOptions[OrderDraft]{Label: core.Msg(c, "order_entry.quantity"), Required: true, Initial: initial, Validate: func(o OrderDraft) string { return orderEntryCheck(c, o, opts, maxQ) }, Async: opts.Check, - }, func() *ui.Element { + }, func() ui.Element { return input.NumberInput(c, &s.Draft.Quantity, input.NumberInputOptions{Step: lot, Disabled: disabled, Error: s.Form.Error("quantity")}).Input }) if s.Draft.Type == OrderStop || s.Draft.Type == OrderStopLimit { input.FormItem(c, f, "trigger", &s.Draft.Trigger, input.FormItemOptions[float64]{Label: core.Msg(c, "order_entry.trigger"), Required: true, Initial: initial.Trigger, Validate: func(v float64) string { return orderEntryTrigger(c, s.Draft, opts.Last) }, - }, func() *ui.Element { + }, func() ui.Element { return input.NumberInput(c, &s.Draft.Trigger, input.NumberInputOptions{Step: tick, Disabled: disabled, Error: s.Form.Error("trigger")}).Input }) } @@ -185,7 +185,7 @@ func orderEntryFields(c *ui.Context, f *input.FormScope, s *OrderEntryState, opt } return "" }, - }, func() *ui.Element { + }, func() ui.Element { return input.NumberInput(c, &s.Draft.Price, input.NumberInputOptions{Step: tick, Disabled: disabled, Error: s.Form.Error("price")}).Input }) } @@ -193,7 +193,7 @@ func orderEntryFields(c *ui.Context, f *input.FormScope, s *OrderEntryState, opt for t := TIFDay; t <= TIFFillOrKill; t++ { tifs = append(tifs, input.SelectOption[TimeInForce]{Value: t, Label: core.Msg(c, fmt.Sprintf("order_entry.tif%d", t))}) } - input.FormItem(c, f, "tif", &s.Draft.TIF, input.FormItemOptions[TimeInForce]{Label: core.Msg(c, "order_entry.tif"), Initial: initial.TIF}, func() *ui.Element { + input.FormItem(c, f, "tif", &s.Draft.TIF, input.FormItemOptions[TimeInForce]{Label: core.Msg(c, "order_entry.tif"), Initial: initial.TIF}, func() ui.Element { return input.Select(c, &s.Draft.TIF, tifs, input.SelectOptions{Label: core.Msg(c, "order_entry.tif"), Disabled: disabled}).Element }) } diff --git a/finance/position_table.go b/finance/position_table.go index 439d125..db51bf1 100644 --- a/finance/position_table.go +++ b/finance/position_table.go @@ -46,7 +46,7 @@ type PositionTableOptions struct { // TableActionResult is a trading table and its acted row. type TableActionResult struct { - Element *ui.Element + Element ui.Element Table data.DataTableView id string } diff --git a/finance/price_change_badge.go b/finance/price_change_badge.go index 2c5002f..a1f1d73 100644 --- a/finance/price_change_badge.go +++ b/finance/price_change_badge.go @@ -17,7 +17,7 @@ type PriceChangeBadgeOptions struct { } // PriceChangeBadge shows a signed change and percent with an arrow. -func PriceChangeBadge(c *ui.Context, change, percent float64, opts PriceChangeBadgeOptions) *ui.Element { +func PriceChangeBadge(c *ui.Context, change, percent float64, opts PriceChangeBadgeOptions) ui.Element { dec := core.PriceDecimals("PriceChangeBadge", opts.Decimals) sign := core.TrendSign(change) if sign == 0 { diff --git a/finance/price_text.go b/finance/price_text.go index 4c322dc..1062d6a 100644 --- a/finance/price_text.go +++ b/finance/price_text.go @@ -37,7 +37,7 @@ type priceTextState struct { } // PriceText shows a price; a change tints it once. -func PriceText(c *ui.Context, value float64, opts PriceTextOptions) *ui.Element { +func PriceText(c *ui.Context, value float64, opts PriceTextOptions) ui.Element { dec := core.PriceDecimals("PriceText", opts.Decimals) if opts.Size < 0 { panic("mujica: PriceText with negative Size") diff --git a/finance/price_text_test.go b/finance/price_text_test.go index 05c250c..7b2917b 100644 --- a/finance/price_text_test.go +++ b/finance/price_text_test.go @@ -79,8 +79,7 @@ func TestPriceTextFlashesOnceAndNotUnderReducedMotion(t *testing.T) { if same == !still { t.Fatalf("reduced motion %v: flashed %v", still, !same) } - time.Sleep(priceTextFlash + 20*time.Millisecond) - tt.Frame() + tt.Advance(priceTextFlash + 20*time.Millisecond) if !bytes.Equal(tt.Image().Pix, fresh.Image().Pix) { t.Fatalf("reduced motion %v: flash did not end", still) } diff --git a/finance/quick_trade_buttons.go b/finance/quick_trade_buttons.go index d6dfcf9..a5e94f5 100644 --- a/finance/quick_trade_buttons.go +++ b/finance/quick_trade_buttons.go @@ -29,7 +29,7 @@ type QuickTradeButtonsOptions struct { // QuickTradeResult is built QuickTradeButtons and their trade. type QuickTradeResult struct { - Element *ui.Element + Element ui.Element side OrderSide quantity float64 } @@ -110,7 +110,7 @@ func QuickTradeButtons(c *ui.Context, quantity *float64, opts QuickTradeButtonsO } // quickTradeButton is a large button tinted by its side. -func quickTradeButton(c *ui.Context, label string, side OrderSide) *ui.Element { +func quickTradeButton(c *ui.Context, label string, side OrderSide) ui.Element { k := core.Tokens(c) d := core.Density(c) col := core.TrendColor(c, side.sign()) diff --git a/finance/quote_card.go b/finance/quote_card.go index 5d78544..74beacf 100644 --- a/finance/quote_card.go +++ b/finance/quote_card.go @@ -24,7 +24,7 @@ type QuoteCardOptions struct { } // QuoteCard shows a symbol, name, price, change and mini chart. -func QuoteCard(c *ui.Context, q Quote, opts QuoteCardOptions) *ui.Element { +func QuoteCard(c *ui.Context, q Quote, opts QuoteCardOptions) ui.Element { if q.Symbol == "" { panic("mujica: QuoteCard needs a Symbol") } diff --git a/finance/risk_meter.go b/finance/risk_meter.go index 130265c..23dbd46 100644 --- a/finance/risk_meter.go +++ b/finance/risk_meter.go @@ -25,7 +25,7 @@ type RiskMeterOptions struct { } // RiskMeter shows level, from 1, on a named graded scale. -func RiskMeter(c *ui.Context, level int, opts RiskMeterOptions) *ui.Element { +func RiskMeter(c *ui.Context, level int, opts RiskMeterOptions) ui.Element { levels := opts.Levels if levels == nil { for i := range 5 { diff --git a/finance/screener.go b/finance/screener.go index 3a3c779..627908f 100644 --- a/finance/screener.go +++ b/finance/screener.go @@ -87,7 +87,7 @@ type ScreenerOptions struct { // ScreenerView is a built Screener and this frame's events. type ScreenerView struct { - Element *ui.Element + Element ui.Element Changed, Save bool Table data.DataTableView } @@ -137,7 +137,7 @@ func Screener(c *ui.Context, s *ScreenerState, fields []ScreenerField, rows []Sc cd := &s.Conditions[i] f := screenerField(fields, cd.Field) removed := false - ui.Row(c).Key(i).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Row(c.Key(i)).Gap(u * 2).AlignItems(ui.Center).Children(func() { input.Select(c, &cd.Field, fieldItems, input.SelectOptions{Label: core.Msg(c, "screener.field"), Disabled: opts.Disabled}) input.Select(c, &cd.Op, opItems, input.SelectOptions{Label: core.Msg(c, "screener.compare"), Disabled: opts.Disabled}).Element.MinWidth(120) input.NumberInput(c, &cd.Value, input.NumberInputOptions{Step: math.Pow(10, -float64(f.Decimals)), Precision: f.Decimals, diff --git a/finance/sentiment_gauge.go b/finance/sentiment_gauge.go index a740bc0..a5e76b0 100644 --- a/finance/sentiment_gauge.go +++ b/finance/sentiment_gauge.go @@ -30,7 +30,7 @@ var sentimentBands = [5]string{"sentiment_gauge.extreme_fear", "sentiment_gauge. func sentimentBand(v float64) int { return min(4, int(v/20)) } -func SentimentGauge(c *ui.Context, value float64, opts SentimentGaugeOptions) *ui.Element { +func SentimentGauge(c *ui.Context, value float64, opts SentimentGaugeOptions) ui.Element { if math.IsNaN(value) || value < 0 || value > 100 { panic("mujica: SentimentGauge value must be in 0..100") } diff --git a/finance/sentiment_gauge_test.go b/finance/sentiment_gauge_test.go index 33a743c..df97cef 100644 --- a/finance/sentiment_gauge_test.go +++ b/finance/sentiment_gauge_test.go @@ -40,15 +40,16 @@ func TestSentimentGaugeStatesKeepSize(t *testing.T) { status := data.DataReady empty := false retried := 0 - var element *ui.Element + var bounds ui.Rect tt := chatTester(400, 300, func(c *ui.Context) { - element = SentimentGauge(c, 72, SentimentGaugeOptions{Label: "Sentiment", Status: status, Empty: empty, Error: "Caller failed", Retry: func() { retried++ }}) + bounds = SentimentGauge(c, 72, SentimentGaugeOptions{Label: "Sentiment", Status: status, Empty: empty, Error: "Caller failed", Retry: func() { retried++ }}).Bounds() }) - height := element.Bounds().H + settled := func() float32 { tt.Frame(); return bounds.H } // Bounds reads the previous frame + height := settled() status = data.DataLoading tt.Frame() - if element.Bounds().H != height { - t.Fatalf("loading height %v, ready %v", element.Bounds().H, height) + if h := settled(); h != height { + t.Fatalf("loading height %v, ready %v", h, height) } status = data.DataFailed tt.Frame() @@ -59,7 +60,7 @@ func TestSentimentGaugeStatesKeepSize(t *testing.T) { status = data.DataReady empty = true tt.Frame() - if !tt.HasText("No data") || element.Bounds().H != height { - t.Fatalf("empty height %v, ready %v", element.Bounds().H, height) + if h := settled(); !tt.HasText("No data") || h != height { + t.Fatalf("empty height %v, ready %v", h, height) } } diff --git a/finance/spread_indicator.go b/finance/spread_indicator.go index 7e2de2b..06de8ed 100644 --- a/finance/spread_indicator.go +++ b/finance/spread_indicator.go @@ -20,7 +20,7 @@ type SpreadIndicatorOptions struct { } // SpreadIndicator shows ask minus bid and its percent. -func SpreadIndicator(c *ui.Context, bid, ask float64, opts SpreadIndicatorOptions) *ui.Element { +func SpreadIndicator(c *ui.Context, bid, ask float64, opts SpreadIndicatorOptions) ui.Element { if opts.WarnPercent < 0 { panic("mujica: SpreadIndicator with negative WarnPercent") } diff --git a/finance/symbol_badge.go b/finance/symbol_badge.go index 7685e54..364d7bd 100644 --- a/finance/symbol_badge.go +++ b/finance/symbol_badge.go @@ -33,7 +33,7 @@ func SymbolBadge(c *ui.Context, s MarketSymbol, opts SymbolBadgeOptions) core.Ch } k := core.Tokens(c) u := core.Density(c).Unit() - var e *ui.Element + var e ui.Element wrap := ui.Row(c).Shrink(0).Disabled(opts.Disabled) wrap.Children(func() { if opts.Clickable { diff --git a/finance/symbol_search.go b/finance/symbol_search.go index 9ee54a2..f2be738 100644 --- a/finance/symbol_search.go +++ b/finance/symbol_search.go @@ -33,7 +33,7 @@ type SymbolSearchOptions struct { // SymbolSearchView is a built SymbolSearch and this frame's events. type SymbolSearchView struct { - Element *ui.Element + Element ui.Element Changed bool // Chosen is the symbol picked when Picked is set. Chosen MarketSymbol @@ -131,7 +131,7 @@ func SymbolSearch(c *ui.Context, s *SymbolSearchState, results []MarketSymbol, o func symbolSearchRow(c *ui.Context, sym MarketSymbol, selected bool) bool { k := core.Tokens(c) d := core.Density(c) - b := ui.ButtonBase(c).Key(sym.Market+":"+sym.Code).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 3). + b := ui.ButtonBase(c.Key(sym.Market+":"+sym.Code)).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 3). Justify(ui.Start).Radius(theme.ControlRadius).Label(symbolBadgeName(sym)).Role(ui.RoleListItem) internal.Ring(b, k.Focus, theme.ControlRadius) diff --git a/finance/ticker_tape.go b/finance/ticker_tape.go index a8a638b..395399f 100644 --- a/finance/ticker_tape.go +++ b/finance/ticker_tape.go @@ -41,7 +41,7 @@ type tickerTapeState struct { } // TickerTape loops quotes, pausing on hover or focus. -func TickerTape(c *ui.Context, items []TickerItem, opts TickerTapeOptions) *ui.Element { +func TickerTape(c *ui.Context, items []TickerItem, opts TickerTapeOptions) ui.Element { if opts.Speed < 0 || opts.PerPage < 0 { panic("mujica: TickerTape Speed and PerPage cannot be negative") } @@ -91,7 +91,7 @@ func TickerTape(c *ui.Context, items []TickerItem, opts TickerTapeOptions) *ui.E strip.Children(func() { // The second copy fills in as the first scrolls out. for n := range 2 { - r := ui.Row(c).Key(n).AlignItems(ui.Center).Shrink(0) + r := ui.Row(c.Key(n)).AlignItems(ui.Center).Shrink(0) if n == 1 { r.Role(ui.RoleNone) } @@ -107,7 +107,7 @@ func TickerTape(c *ui.Context, items []TickerItem, opts TickerTapeOptions) *ui.E } // tickerTapePages shows one page of items, for reduced motion. -func tickerTapePages(c *ui.Context, e *ui.Element, st *tickerTapeState, items []TickerItem, per, dec int) { +func tickerTapePages(c *ui.Context, e ui.Element, st *tickerTapeState, items []TickerItem, per, dec int) { pages := max(1, (len(items)+per-1)/per) st.page = min(st.page, pages-1) turn := func(by int) { diff --git a/finance/ticker_tape_test.go b/finance/ticker_tape_test.go index 6404cdd..c0091c8 100644 --- a/finance/ticker_tape_test.go +++ b/finance/ticker_tape_test.go @@ -32,8 +32,7 @@ func TestTickerTapeScrollsAndPausesOnHover(t *testing.T) { ui.Box(c).Width(380).Children(func() { TickerTape(c, tickerTapeItems(6), TickerTapeOptions{Speed: 200}) }) }) x0 := tickerTapeX(t, tt) - time.Sleep(60 * time.Millisecond) - tt.Frame() + tt.Advance(60 * time.Millisecond) x1 := tickerTapeX(t, tt) if x1 >= x0 { t.Fatalf("did not scroll: %v -> %v", x0, x1) @@ -41,8 +40,7 @@ func TestTickerTapeScrollsAndPausesOnHover(t *testing.T) { tt.Move(100, 25) tt.Frame() x2 := tickerTapeX(t, tt) - time.Sleep(60 * time.Millisecond) - tt.Frame() + tt.Advance(60 * time.Millisecond) if x3 := tickerTapeX(t, tt); x3 != x2 { t.Fatalf("moved under the pointer: %v -> %v", x2, x3) } @@ -57,8 +55,7 @@ func TestTickerTapePausesOnFocus(t *testing.T) { t.Fatal("tape not focusable") } x0 := tickerTapeX(t, tt) - time.Sleep(60 * time.Millisecond) - tt.Frame() + tt.Advance(60 * time.Millisecond) if x1 := tickerTapeX(t, tt); x1 != x0 { t.Fatalf("moved while focused: %v -> %v", x0, x1) } diff --git a/finance/trade_history_table.go b/finance/trade_history_table.go index 4a98914..061a191 100644 --- a/finance/trade_history_table.go +++ b/finance/trade_history_table.go @@ -47,7 +47,7 @@ type TradeHistoryTableState struct { // TradeHistoryResult is a built table and its export request. type TradeHistoryResult struct { - Element *ui.Element + Element ui.Element Table data.DataTableView exported []FillRow export bool diff --git a/finance/trading_session_clock.go b/finance/trading_session_clock.go index 1b7a909..9899e32 100644 --- a/finance/trading_session_clock.go +++ b/finance/trading_session_clock.go @@ -29,7 +29,7 @@ type TradingSessionClockOptions struct { Now time.Time } -func TradingSessionClock(c *ui.Context, markets []SessionMarket, opts TradingSessionClockOptions) *ui.Element { +func TradingSessionClock(c *ui.Context, markets []SessionMarket, opts TradingSessionClockOptions) ui.Element { now := opts.Now if now.IsZero() { now = c.Now() @@ -43,7 +43,7 @@ func TradingSessionClock(c *ui.Context, markets []SessionMarket, opts TradingSes tradingSessionCheck(m) local := now.In(m.Location) phase, next, nextPhase := tradingSessionPhase(m, local) - ui.Column(c).Key(m.Name).Gap(2).Children(func() { + ui.Column(c.Key(m.Name)).Gap(2).Children(func() { ui.Text(c, m.Name).Font(theme.SerifFont).FontWeight(600) ui.Text(c, local.Format("15:04 MST")).FontSize(size).TextColor(k.TextMuted).FontFeatures("tnum") }) diff --git a/finance/watchlist.go b/finance/watchlist.go index b9625c9..8ca413d 100644 --- a/finance/watchlist.go +++ b/finance/watchlist.go @@ -58,7 +58,7 @@ type WatchlistOptions struct { // WatchlistView is a built Watchlist and this frame's events. type WatchlistView struct { - Element *ui.Element + Element ui.Element Changed, Submitted bool Moved bool Rows []int diff --git a/git/ansi_text.go b/git/ansi_text.go index 12e1570..8790414 100644 --- a/git/ansi_text.go +++ b/git/ansi_text.go @@ -17,7 +17,7 @@ type AnsiTextOptions struct { } // AnsiText renders terminal output with its SGR styles. -func AnsiText(c *ui.Context, text string, opts AnsiTextOptions) *ui.Element { +func AnsiText(c *ui.Context, text string, opts AnsiTextOptions) ui.Element { k := core.Tokens(c) col := ui.Column(c).Font(theme.MonoFont).TextColor(k.Text).Shrink(0) col.Children(func() { diff --git a/git/blame_view.go b/git/blame_view.go index 5b59cb9..b8f082b 100644 --- a/git/blame_view.go +++ b/git/blame_view.go @@ -40,7 +40,7 @@ type BlameViewOptions struct { } // BlameView shows code with its commits; give it a height. -func BlameView(c *ui.Context, s *BlameViewState, lines []BlameLine, opts BlameViewOptions) *ui.Element { +func BlameView(c *ui.Context, s *BlameViewState, lines []BlameLine, opts BlameViewOptions) ui.Element { var first *BlameLine if len(lines) > 0 { first = &lines[0] diff --git a/git/blame_view_test.go b/git/blame_view_test.go index 31ba6e0..464455a 100644 --- a/git/blame_view_test.go +++ b/git/blame_view_test.go @@ -58,7 +58,7 @@ func TestBlameViewHoverDetails(t *testing.T) { }, 700, 340) r, _ := tt.Find("Uika") tt.Move(r.X+2, r.Y+2) - time.Sleep(650 * time.Millisecond) + tt.Advance(650 * time.Millisecond) tt.Move(r.X+3, r.Y+2) tt.Frame() if tt.HasText(gitTestHash(2) + "\nUika · 2026-10-05\nComment the lanes") { diff --git a/git/branch_selector.go b/git/branch_selector.go index c032766..23f56db 100644 --- a/git/branch_selector.go +++ b/git/branch_selector.go @@ -23,7 +23,7 @@ type BranchSelectorOptions struct { // BranchSelectorResult is a built BranchSelector and its events. type BranchSelectorResult struct { - Element *ui.Element + Element ui.Element changed bool create string } @@ -82,7 +82,7 @@ func BranchSelector(c *ui.Context, selected *string, branches []Branch, opts Bra ui.Icon(c, icons.Must("chevrons-up-down")).Size(14, 14).TextColor(k.TextMuted) }) b := tr.Bounds() - ui.PopoverBase(c, tr, &s.open, func(panel *ui.Element) { + ui.PopoverBase(c, tr, &s.open, func(panel ui.Element) { core.OverlayStyle(c, panel, overlay.OverlayAnchored(c, panel, b)) panel.Margin(6, 0, 0, 0).Padding(u * 2).Gap(u * 2).Width(300).MaxHeight(overlay.PopoverRoom(c, b)).AlignItems(ui.Stretch) var first *Branch @@ -100,7 +100,7 @@ func BranchSelector(c *ui.Context, selected *string, branches []Branch, opts Bra } in := input.TextInput(c, &s.query, input.TextInputOptions{Placeholder: core.Msg(c, "branch_selector.search"), Label: core.Msg(c, "branch_selector.search"), Leading: func() { ui.Icon(c, icons.Must("search")).Size(14, 14).TextColor(k.TextMuted) }}) - in.Input.AutoFocus() + core.AutoFocus(c, in.Input) if in.Changed() { s.hl, s.reveal = 0, true } diff --git a/git/changes_list.go b/git/changes_list.go index 4f565f9..b9e4868 100644 --- a/git/changes_list.go +++ b/git/changes_list.go @@ -78,7 +78,7 @@ func ChangesList(c *ui.Context, s *ChangesListState, files []ChangedFile, opts C } prev := s.cursor outer := ui.Column(c).AlignItems(ui.Stretch).Disabled(opts.Disabled) - var e *ui.Element + var e ui.Element outer.Children(func() { restore := data.DataSelectTheme(c, 0) defer restore() diff --git a/git/command_block.go b/git/command_block.go index cfdf23e..9828581 100644 --- a/git/command_block.go +++ b/git/command_block.go @@ -24,7 +24,7 @@ type CommandBlockOptions struct { // CommandBlockResult is a built CommandBlock and its events. type CommandBlockResult struct { - Element *ui.Element + Element ui.Element toggled bool copied string } diff --git a/git/commit_input.go b/git/commit_input.go index 868a9cc..1897bbc 100644 --- a/git/commit_input.go +++ b/git/commit_input.go @@ -31,7 +31,7 @@ type CommitInputOptions struct { // CommitInputResult is a built CommitInput and its events. type CommitInputResult struct { - Element *ui.Element + Element ui.Element committed, amend bool } diff --git a/git/commit_list.go b/git/commit_list.go index 42f9621..486e79a 100644 --- a/git/commit_list.go +++ b/git/commit_list.go @@ -24,7 +24,7 @@ type Commit struct { // GitListResult is a built version-control list and its events. type GitListResult struct { - Element *ui.Element + Element ui.Element changed, submitted bool action, item string } diff --git a/git/diff_stat.go b/git/diff_stat.go index c2e83fa..6e55603 100644 --- a/git/diff_stat.go +++ b/git/diff_stat.go @@ -17,7 +17,7 @@ type DiffStatOptions struct { } // DiffStat shows added and deleted counts with a ratio bar. -func DiffStat(c *ui.Context, added, deleted int, opts DiffStatOptions) *ui.Element { +func DiffStat(c *ui.Context, added, deleted int, opts DiffStatOptions) ui.Element { if added < 0 || deleted < 0 { panic("mujica: DiffStat counts must not be negative") } diff --git a/git/diff_viewer.go b/git/diff_viewer.go index 0c9db47..f198abd 100644 --- a/git/diff_viewer.go +++ b/git/diff_viewer.go @@ -42,7 +42,7 @@ type diffViewerRow struct { } // DiffViewer compares two texts by line; give it a height. -func DiffViewer(c *ui.Context, s *DiffViewerState, from, to string, opts DiffViewerOptions) *ui.Element { +func DiffViewer(c *ui.Context, s *DiffViewerState, from, to string, opts DiffViewerOptions) ui.Element { ctx := opts.Context if ctx == 0 { ctx = 3 diff --git a/git/git_status_badge.go b/git/git_status_badge.go index 3fdce28..623efe5 100644 --- a/git/git_status_badge.go +++ b/git/git_status_badge.go @@ -38,7 +38,7 @@ type GitStatusBadgeOptions struct { } // GitStatusBadge marks a status by letter, outline and color. -func GitStatusBadge(c *ui.Context, status GitStatus, opts GitStatusBadgeOptions) *ui.Element { +func GitStatusBadge(c *ui.Context, status GitStatus, opts GitStatusBadgeOptions) ui.Element { k := core.Tokens(c) name := core.Msg(c, "git_status_badge."+gitStatusKeys[gitStatusIndex(status)]) col := gitStatusColor(k, status) diff --git a/git/inline_diff.go b/git/inline_diff.go index 30b4213..1a79991 100644 --- a/git/inline_diff.go +++ b/git/inline_diff.go @@ -21,7 +21,7 @@ type inlineDiffCache struct { } // InlineDiff shows old and new lines with changed words marked. -func InlineDiff(c *ui.Context, from, to string, opts InlineDiffOptions) *ui.Element { +func InlineDiff(c *ui.Context, from, to string, opts InlineDiffOptions) ui.Element { k := core.Tokens(c) col := ui.Column(c).AlignItems(ui.Stretch).Shrink(0).Radius(theme.ControlRadius).Clip() cache := ui.Local(col, "words", func() *inlineDiffCache { return &inlineDiffCache{} }) diff --git a/git/review_comment.go b/git/review_comment.go index b62aed5..94bb523 100644 --- a/git/review_comment.go +++ b/git/review_comment.go @@ -39,7 +39,7 @@ type ReviewCommentOptions struct { // ReviewCommentResult is a built ReviewComment and its events. type ReviewCommentResult struct { - Element *ui.Element + Element ui.Element reply string resolved, reopened bool } diff --git a/git/three_way_merge.go b/git/three_way_merge.go index 7a0337c..7e4efe1 100644 --- a/git/three_way_merge.go +++ b/git/three_way_merge.go @@ -187,7 +187,7 @@ func threeWayMergeChunk(c *ui.Context, s *ThreeWayMergeState, i int) bool { } // mergeChoiceButton is a toggle marking the chosen side. -func mergeChoiceButton(c *ui.Context, label string, on bool) *ui.Element { +func mergeChoiceButton(c *ui.Context, label string, on bool) ui.Element { k := core.Tokens(c) u := core.Density(c).Unit() b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*3).Gap(u*1.5).Radius(theme.ControlRadius). diff --git a/go.mod b/go.mod index afa995f..b6d8b2a 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,11 @@ module git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI go 1.27.1 -require github.com/egoist/mygo v0.2.10 +require github.com/egoist/mygo v0.5.5 require ( github.com/ebitengine/purego v0.11.1 // indirect github.com/go-text/typesetting v0.3.5 // indirect golang.org/x/image v0.46.0 // indirect + golang.org/x/text v0.43.0 // indirect ) diff --git a/go.sum b/go.sum index 3b02edf..1e4c3d4 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,12 @@ github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0= github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= -github.com/egoist/mygo v0.2.10 h1:Mfe6SpQngC9kLjrejpGM7L/k3Q62BdBAyJ8MrHfL6bQ= -github.com/egoist/mygo v0.2.10/go.mod h1:1sRzZ6jEhKE7iuEQXjyoXDgxyxQ1uTgMHDcDKZvoDIU= +github.com/egoist/mygo v0.5.5 h1:3pvlEwumsck/L99cbDpay/Eqcf51B7zhKNXekiodLOw= +github.com/egoist/mygo v0.5.5/go.mod h1:daHGetyAlBkHhIZOOIcAI7C82yLUY7HgeT2+Z6w2VPU= github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk= github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68= github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY= github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= diff --git a/input/button.go b/input/button.go index 09ddf80..1b872a9 100644 --- a/input/button.go +++ b/input/button.go @@ -34,7 +34,7 @@ type ButtonOptions struct { const buttonSpinnerSize float32 = 16 // Button is a push button. Loading keeps its size and blocks clicks. -func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element { +func Button(c *ui.Context, label string, opts ButtonOptions) ui.Element { if label == "" && opts.Label == "" { panic("mujica: Button without a label needs Label to name it") } diff --git a/input/button_group.go b/input/button_group.go index f734d1a..c531051 100644 --- a/input/button_group.go +++ b/input/button_group.go @@ -29,7 +29,7 @@ type ButtonGroupOptions struct { // ButtonGroupResult is the group element and the item clicked this frame. type ButtonGroupResult struct { - Element *ui.Element + Element ui.Element clicked int } @@ -59,7 +59,7 @@ func ButtonGroup(c *ui.Context, items []ButtonGroupItem, opts ButtonGroupOptions if it.Label == "" && it.Name == "" { panic("mujica: icon-only ButtonGroupItem needs a Name") } - b := ui.ButtonBase(c).Key(i).MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()). + b := ui.ButtonBase(c.Key(i)).MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()). Padding(0, d.Unit()*3).Gap(d.Unit() * 2).Disabled(it.Disabled) internal.Ring(b, k.Focus, theme.ControlRadius) if it.Name != "" { diff --git a/input/cascader.go b/input/cascader.go index d68932e..3c6a256 100644 --- a/input/cascader.go +++ b/input/cascader.go @@ -62,6 +62,7 @@ type cascaderState struct { requested map[string]bool focus []string focusSet bool + settling bool // the popup opened this frame; MyGo records its opener before rows take focus } type cascaderKey struct{} @@ -84,7 +85,7 @@ func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) core.ChoiceRe st.open = !st.open if st.open { st.active = slices.Clone(*path) - st.focus, st.focusSet = slices.Clone(*path), true + st.focus, st.focusSet, st.settling = slices.Clone(*path), true, true if len(*path) == 0 { st.focus = []string{""} } @@ -97,7 +98,7 @@ func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) core.ChoiceRe } ui.Text(c, cascaderLabels(opts, *path)).SingleLine().Grow(1).Shrink(1) }) - ui.PopoverBase(c, tr, &st.open, func(panel *ui.Element) { + ui.PopoverBase(c, tr, &st.open, func(panel ui.Element) { SelectPanel(c, panel) panel.Padding(0) ui.Row(c).AlignItems(ui.Stretch).Dividers(theme.BorderWidth, core.Tokens(c).Border).Children(func() { @@ -108,6 +109,10 @@ func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) core.ChoiceRe } }) }) + if st.settling { + st.settling = false + c.Invalidate() + } changed := !slices.Equal(before, *path) if changed { slog.Debug("mujica: cascader changed", "label", opts.Label, "path", *path) @@ -147,7 +152,7 @@ func cascaderColumn(c *ui.Context, st *cascaderState, path *[]string, opts Casca delete(st.requested, id) } deeper := false - col := ui.Column(c).Key(id).Width(180).FocusGroup(ui.Vertical) + col := ui.Column(c.Key(id)).Width(180).FocusGroup(ui.Vertical) col.Children(func() { switch { case lv.Status == CascaderIdle || lv.Status == CascaderLoading: @@ -188,10 +193,10 @@ func cascaderRow(c *ui.Context, st *cascaderState, path *[]string, opts Cascader d := core.Density(c) full := append(slices.Clone(prefix), n.Value) depth := len(prefix) - b := ui.ButtonBase(c).Key(n.Value).Disabled(n.Disabled).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2). + b := ui.ButtonBase(c.Key(n.Value)).Disabled(n.Disabled).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2). Gap(d.Unit() * 2).Justify(ui.Start).Radius(theme.ControlRadius).FillWidth().Role(ui.RoleListItem) internal.Ring(b, k.Focus, theme.ControlRadius) - if st.focusSet && (slices.Equal(st.focus, full) || first && slices.Equal(st.focus, append(slices.Clone(prefix), ""))) { + if st.focusSet && !st.settling && (slices.Equal(st.focus, full) || first && slices.Equal(st.focus, append(slices.Clone(prefix), ""))) { b.Focus() st.focusSet = false } diff --git a/input/checkbox.go b/input/checkbox.go index d4585e4..fb38cac 100644 --- a/input/checkbox.go +++ b/input/checkbox.go @@ -21,14 +21,14 @@ type CheckboxOptions struct { const checkboxSize float32 = 16 // Checkbox toggles *value; a click on the box or its label toggles, as does Space. -func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *ui.Element { +func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) ui.Element { if value == nil { panic("mujica: Checkbox needs a value") } k := core.Tokens(c) d := core.Density(c) // Disabled on a wrapper blocks the base's click before it toggles. - var row *ui.Element + var row ui.Element wrap := ui.Row(c).Shrink(0).Disabled(opts.Disabled) wrap.Children(func() { row = ui.CheckboxBase(c, value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false) diff --git a/input/choice_chips.go b/input/choice_chips.go index cbebd59..ead129f 100644 --- a/input/choice_chips.go +++ b/input/choice_chips.go @@ -54,7 +54,7 @@ func ChoiceChips[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], return core.NewChoiceResult(g, changed) } -func choiceChipLook[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, b *ui.Element, on bool, it ToggleItem[T]) { +func choiceChipLook[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, b ui.Element, on bool, it ToggleItem[T]) { b.MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit() * 1.5). Radius(theme.PopupRadius) internal.Ring(b, k.Focus, theme.PopupRadius) diff --git a/input/choice_review_test.go b/input/choice_review_test.go index c8a075f..13ba948 100644 --- a/input/choice_review_test.go +++ b/input/choice_review_test.go @@ -139,15 +139,16 @@ func TestChoiceHitAreas(t *testing.T) { func TestChoiceRowsFollowDensity(t *testing.T) { for _, d := range []theme.Density{theme.Compact, theme.Default, theme.Comfortable} { v, on := "apple", false - var sw *ui.Element + var sw ui.Rect tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{Density: d}) ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() { - sw = Switch(c, &on, "Wi-Fi", SwitchOptions{}) + sw = Switch(c, &on, "Wi-Fi", SwitchOptions{}).Bounds() Select(c, &v, selectTestOptions, SelectOptions{Label: "Fruit"}) }) }, 400, 500) - if h := sw.Bounds().H; h < d.ControlHeight() { + tt.Frame() // Bounds reads the previous frame + if h := sw.H; h < d.ControlHeight() { t.Errorf("%v: switch %v high", d, h) } _ = tt.Click("Fruit") diff --git a/input/color_palette.go b/input/color_palette.go index 0396124..4b92206 100644 --- a/input/color_palette.go +++ b/input/color_palette.go @@ -52,7 +52,7 @@ func ColorPalette(c *ui.Context, value *ui.Color, swatches []ColorSwatch, opts C changed := false g.Children(func() { grid := ui.Box(c).Grid().Columns(cols).Gap(u * 1.5).FocusGroup(ui.Horizontal) - var cells []*ui.Element + var cells []ui.Element grid.Children(func() { for i, s := range swatches { b := colorPaletteSwatch(c, k, s, *value == s.Color).Key(i) @@ -86,7 +86,7 @@ func ColorPalette(c *ui.Context, value *ui.Color, swatches []ColorSwatch, opts C } // colorPaletteSwatch is one square swatch; the chosen one has an inset frame and a check. -func colorPaletteSwatch(c *ui.Context, k theme.Tokens, s ColorSwatch, on bool) *ui.Element { +func colorPaletteSwatch(c *ui.Context, k theme.Tokens, s ColorSwatch, on bool) ui.Element { b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Role(ui.RoleRadio). Label(s.Name).Tooltip(s.Name).Selected(on) internal.Ring(b, k.Focus, theme.ControlRadius) diff --git a/input/color_picker.go b/input/color_picker.go index 781273d..a701ec9 100644 --- a/input/color_picker.go +++ b/input/color_picker.go @@ -26,7 +26,7 @@ type ColorPickerOptions struct { // ColorPickerParts are the parts of a ColorPicker. type ColorPickerParts struct { - Trigger *ui.Element + Trigger ui.Element changed bool } @@ -114,7 +114,7 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP ui.Icon(c, icons.Must("chevron-down")).Size(16, 16).TextColor(k.TextMuted) }) opened := st.open && !st.wasOpen - ui.PopoverBase(c, trigger, &st.open, func(panel *ui.Element) { + ui.PopoverBase(c, trigger, &st.open, func(panel ui.Element) { core.OverlayStyle(c, panel, core.OverlayEnter(c, panel)) panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit() * 3).Gap(d.Unit() * 3).Width(260) colorPickerSquare(c, st, set, opened) @@ -211,7 +211,7 @@ func colorPickerSquare(c *ui.Context, st *colorPickerState, set func(colorPicker } // colorPickerSlider is a channel slider from 0 to hi over the track track paints. -func colorPickerSlider(c *ui.Context, v *float64, hi float64, name string, track func(p *ui.Painter, r ui.Rect)) *ui.Element { +func colorPickerSlider(c *ui.Context, v *float64, hi float64, name string, track func(p *ui.Painter, r ui.Rect)) ui.Element { k := core.Tokens(c) const knob, look = 10, 18 s := ui.SliderBase(c, v, 0, hi).Height(theme.MinHitSize).PaddingX(knob / 2).FocusRing(false).Label(name) diff --git a/input/combobox.go b/input/combobox.go index 69782a5..b72a3eb 100644 --- a/input/combobox.go +++ b/input/combobox.go @@ -54,16 +54,10 @@ func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptio // MyGo keeps the caret on replaced text (ui/editor.go:117): a choice builds a fresh input. gen := ui.Local(f, comboboxKey{"input"}, func() int { return 0 }) refocus := ui.Local(f, comboboxKey{"refocus"}, func() bool { return false }) - var p *ui.ComboboxParts + var p ui.ComboboxParts f.Children(func() { - // A focused ComboboxBase takes typing under a disabled wrapper, so it edits a copy. - target := query - if opts.Disabled { - cp := *query - target = &cp - } - ui.Row(c).Key(*gen).Grow(1).Children(func() { - p = ui.ComboboxBase(c, target) + ui.Row(c.Key(*gen)).Grow(1).Children(func() { + p = ui.ComboboxBase(c, query) }) if *refocus { p.Input.Focus() @@ -113,7 +107,7 @@ func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptio if !p.Open() { *lastHi = -1 } - p.Popup(func(panel *ui.Element) { + p.Popup(func(panel ui.Element) { SelectPanel(c, panel) ui.Scroll(c).MaxHeight(240).Children(func() { if custom != "" { diff --git a/input/copy_button.go b/input/copy_button.go index 81efb00..4986df3 100644 --- a/input/copy_button.go +++ b/input/copy_button.go @@ -26,7 +26,7 @@ func CopyButton(c *ui.Context, text string, opts CopyButtonOptions) core.ChoiceR copied := core.Msg(c, "copy_button.copied") name := core.Msg(c, "copy_button.copy") label, icon := opts.Label, icons.Must("copy") - var b *ui.Element + var b ui.Element box := ui.Box(c) at := ui.Local(box, "copy_button.at", func() time.Time { return time.Time{} }) left := copyButtonShown - c.Now().Sub(*at) diff --git a/input/copy_button_test.go b/input/copy_button_test.go index c794afe..c3ba63c 100644 --- a/input/copy_button_test.go +++ b/input/copy_button_test.go @@ -54,8 +54,7 @@ func TestCopyButtonKeyboardRevertAndDisabled(t *testing.T) { if _, ok := tt.Find("已复制"); !ok { t.Fatal("icon button lacks the copied name") } - time.Sleep(copyButtonShown + 50*time.Millisecond) - tt.Frame() + tt.Advance(copyButtonShown + 50*time.Millisecond) if _, ok := tt.Find("复制"); !ok { t.Fatal("did not revert to Copy") } diff --git a/input/currency_input.go b/input/currency_input.go index ef347b9..c4e574f 100644 --- a/input/currency_input.go +++ b/input/currency_input.go @@ -101,7 +101,7 @@ func CurrencyInput(c *ui.Context, value *int64, opts CurrencyInputOptions) Input k := core.Tokens(c) var r InputResult look := &textInputLook{disabled: opts.Disabled, err: opts.Error} - r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element { if opts.Symbol != "" { ui.Text(c, opts.Symbol).TextColor(k.TextMuted).SingleLine() } diff --git a/input/dock_panel.go b/input/dock_panel.go index f035abc..404cd2e 100644 --- a/input/dock_panel.go +++ b/input/dock_panel.go @@ -124,7 +124,7 @@ func dockPanelZone(c *ui.Context, k theme.Tokens, u float32, layout *DockLayout, if len(ids) == 0 { return false } - box := ui.Column(c).Key(z).MinWidth(0).MinHeight(0).Background(k.Surface).Clip().Role(ui.RoleGroup). + box := ui.Column(c.Key(z)).MinWidth(0).MinHeight(0).Background(k.Surface).Clip().Role(ui.RoleGroup). Label(core.Msg(c, fmt.Sprintf("dock_panel.zone%d", z))) switch z { case DockLeft, DockRight: @@ -173,7 +173,7 @@ func dockPanelZone(c *ui.Context, k theme.Tokens, u float32, layout *DockLayout, // dockPanelEdges shows drop strips on the four edges while a tab is dragged. func dockPanelEdges(c *ui.Context, k theme.Tokens, u float32, move func(string, DockZone)) { for _, z := range []DockZone{DockLeft, DockRight, DockBottom, DockCenter} { - e := ui.Box(c).Key(fmt.Sprint("edge", z)).Absolute().Role(ui.RoleNone) + e := ui.Box(c.Key(fmt.Sprint("edge", z))).Absolute().Role(ui.RoleNone) switch z { case DockLeft: e.Left(0).Top(0).Bottom(0).Width(12 * u) diff --git a/input/example_form_test.go b/input/example_form_test.go index acab48e..acc92f2 100644 --- a/input/example_form_test.go +++ b/input/example_form_test.go @@ -16,7 +16,7 @@ func ExampleForm() { view := func(c *ui.Context) { core.Use(c, core.Settings{}) res := input.Form(c, &state, input.FormOptions{}, func(f *input.FormScope) { - input.FormItem(c, f, "name", &name, input.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element { + input.FormItem(c, f, "name", &name, input.FormItemOptions[string]{Label: "Name", Required: true}, func() ui.Element { return input.InputGroup(c, &name, input.InputGroupOptions{Invalid: state.Error("name") != ""}).Input }) }) @@ -31,7 +31,7 @@ func ExampleFormField() { email := "" view := func(c *ui.Context) { core.Use(c, core.Settings{}) - input.FormField(c, "Email", input.FormFieldOptions{Required: true, Description: "For receipts."}, func() *ui.Element { + input.FormField(c, "Email", input.FormFieldOptions{Required: true, Description: "For receipts."}, func() ui.Element { return input.InputGroup(c, &email, input.InputGroupOptions{}).Input }) } diff --git a/input/file_drop_zone.go b/input/file_drop_zone.go index 0ff7726..7c9cdf8 100644 --- a/input/file_drop_zone.go +++ b/input/file_drop_zone.go @@ -209,7 +209,7 @@ func fileDropZoneList(c *ui.Context, st *fileDropZoneState, files *[]DroppedFile k := core.Tokens(c) d := core.Density(c) row := func(key string, icon string, color ui.Color, name, detail string, remove func()) { - ui.Row(c).Key(key).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2). + ui.Row(c.Key(key)).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2). BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(color) ui.Text(c, name).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1) diff --git a/input/file_picker.go b/input/file_picker.go index 8a8da53..1a7b666 100644 --- a/input/file_picker.go +++ b/input/file_picker.go @@ -32,7 +32,7 @@ type FilePickerOptions struct { // FilePickerParts are the parts of a FilePicker. type FilePickerParts struct { - Element *ui.Element + Element ui.Element changed bool canceled bool } @@ -124,7 +124,7 @@ func FilePicker(c *ui.Context, paths *[]string, opts FilePickerOptions) FilePick default: ui.Column(c).Gap(d.Unit()).Children(func() { for _, p := range *paths { - ui.Row(c).Key(p).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Row(c.Key(p)).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { ui.Icon(c, icon).Size(14, 14).TextColor(k.TextMuted) ui.Text(c, filepath.Base(p)).SingleLine() ui.Text(c, filepath.Dir(p)).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1) diff --git a/input/form.go b/input/form.go index 1c18e30..2aa8a03 100644 --- a/input/form.go +++ b/input/form.go @@ -35,6 +35,7 @@ type FormFieldState struct { asyncFor uint64 asyncErr string pending bool + owed bool // blurred valid; the check starts once no form button is held hasAsync bool check func() string refresh func() @@ -107,7 +108,7 @@ type FormScope struct { // FormParts are the parts of a Form. type FormParts struct { - Element *ui.Element + Element ui.Element submitted, reset bool } @@ -142,9 +143,10 @@ func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormSco col := ui.Column(c).Gap(d.Unit() * 5) parts.Element = col scope := &FormScope{st: st, horizontal: opts.Horizontal} + held := false col.Children(func() { // A new key after Reset drops the controls' drafts and errors. - ui.Box(c).Key(st.generation).Children(func() { + ui.Box(c.Key(st.generation)).Children(func() { if opts.Horizontal { formFieldGrid(c, func() { fields(scope) }) } else { @@ -162,13 +164,17 @@ func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormSco if reset == "" { reset = core.Msg(c, "form.reset") } - if Button(c, submit, ButtonOptions{Loading: st.submitting || st.Awaiting}).Clicked() { + sb := Button(c, submit, ButtonOptions{Loading: st.submitting || st.Awaiting}) + if sb.Clicked() { st.Requested = true } - if Button(c, reset, ButtonOptions{Variant: Secondary, Disabled: st.submitting}).Clicked() { + rb := Button(c, reset, ButtonOptions{Variant: Secondary, Disabled: st.submitting}) + if rb.Clicked() { formReset(st) parts.reset = true } + // A "Checking…" line would move the buttons under the pointer before the release. + held = sb.Pressed() || rb.Pressed() }) }) if st.Requested { @@ -180,15 +186,29 @@ func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormSco if st.Awaiting { parts.submitted = formSettle(c, st) } + if !held { + formStartOwed(c, st) + } return parts } +// formStartOwed starts the async checks of fields blurred while a form button was held. +func formStartOwed(c *ui.Context, st *FormState) { + for _, name := range st.Order { + if f := st.Fields[name]; f != nil && f.owed { + f.owed = false + f.startAsync() + c.Invalidate() + } + } +} + // formStartSubmit validates every field and starts the async checks still owed. func formStartSubmit(st *FormState) { st.tried, st.Awaiting, st.formError = true, true, "" for _, name := range st.Order { f := st.Fields[name] - f.err = f.check() + f.err, f.owed = f.check(), false if f.err == "" { f.startAsync() } @@ -243,7 +263,7 @@ func formReset(st *FormState) { } // FormItem registers field name around the focusable element control returns. -func FormItem[T any](c *ui.Context, f *FormScope, name string, value *T, opts FormItemOptions[T], control func() *ui.Element) *ui.Element { +func FormItem[T any](c *ui.Context, f *FormScope, name string, value *T, opts FormItemOptions[T], control func() ui.Element) ui.Element { st := f.st if name == "" || opts.Label == "" { panic("mujica: FormItem needs a name and a Label") @@ -294,8 +314,8 @@ func FormItem[T any](c *ui.Context, f *FormScope, name string, value *T, opts Fo if fs.pending { desc = core.Msg(c, "form.checking") } - var ctrl *ui.Element - field := formFieldBuild(c, name, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, f.horizontal, func() *ui.Element { + var ctrl ui.Element + field := formFieldBuild(c, name, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, f.horizontal, func() ui.Element { ctrl = control() return ctrl }) @@ -304,9 +324,7 @@ func FormItem[T any](c *ui.Context, f *FormScope, name string, value *T, opts Fo if fs.focused && !focused { fs.touched = true fs.err = fs.check() - if fs.err == "" { - fs.startAsync() - } + fs.owed = fs.err == "" slog.Debug("mujica: form field blurred", "field", name, "valid", fs.err == "") } fs.focused = focused diff --git a/input/form_field.go b/input/form_field.go index f6b1e8e..387df29 100644 --- a/input/form_field.go +++ b/input/form_field.go @@ -19,17 +19,17 @@ type FormFieldOptions struct { } // FormField labels the element control returns and shows help and error text for it. -func FormField(c *ui.Context, label string, opts FormFieldOptions, control func() *ui.Element) *ui.Element { +func FormField(c *ui.Context, label string, opts FormFieldOptions, control func() ui.Element) ui.Element { if !opts.Horizontal { return formFieldBuild(c, "", label, opts, false, control) } - var f *ui.Element + var f ui.Element formFieldGrid(c, func() { f = formFieldBuild(c, "", label, opts, true, control) }) return f } // formFieldGrid holds fields whose labels sit in a column beside their controls. -func formFieldGrid(c *ui.Context, fields func()) *ui.Element { +func formFieldGrid(c *ui.Context, fields func()) ui.Element { d := core.Density(c) return ui.Grid(c).ColumnTracks(ui.FitContent(), ui.Fr(1)).GapX(d.Unit() * 3).GapY(d.Unit() * 4). AlignItems(ui.Start).Children(fields) @@ -37,13 +37,13 @@ func formFieldGrid(c *ui.Context, fields func()) *ui.Element { // formFieldBuild builds the label and the control column; beside makes them two grid cells. // formFieldBuild keys its cells by key when set, so focus follows a reordered field. -func formFieldBuild(c *ui.Context, key, label string, opts FormFieldOptions, beside bool, control func() *ui.Element) *ui.Element { +func formFieldBuild(c *ui.Context, key, label string, opts FormFieldOptions, beside bool, control func() ui.Element) ui.Element { if label == "" { panic("mujica: FormField needs a label") } k := core.Tokens(c) d := core.Density(c) - var head, content, ctrl *ui.Element + var head, content, ctrl ui.Element body := func() { head = ui.Row(c) if key != "" && beside { @@ -53,7 +53,7 @@ func formFieldBuild(c *ui.Context, key, label string, opts FormFieldOptions, bes if beside { head.MinHeight(d.ControlHeight()).Justify(ui.End) } - var text *ui.Element + var text ui.Element head.Children(func() { text = ui.Text(c, label).FontWeight(500).SingleLine() if opts.Required { @@ -68,7 +68,7 @@ func formFieldBuild(c *ui.Context, key, label string, opts FormFieldOptions, bes content.Gap(d.Unit() * 1.5).MinWidth(0) content.Children(func() { ctrl = control() - if ctrl == nil { + if !ctrl.Valid() { panic("mujica: FormField " + label + ": control returned nil") } if opts.Description != "" { diff --git a/input/form_field_test.go b/input/form_field_test.go index 9be95b9..625e67a 100644 --- a/input/form_field_test.go +++ b/input/form_field_test.go @@ -13,7 +13,7 @@ func TestFormFieldLabelFocusesControl(t *testing.T) { name := "" tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) - FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() *ui.Element { + FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() ui.Element { return InputGroup(c, &name, InputGroupOptions{}).Input }) }, 400, 200) @@ -35,10 +35,10 @@ func TestFormFieldTargetsReturnedControl(t *testing.T) { tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) ui.Column(c).Gap(12).Children(func() { - FormField(c, "Flowers", FormFieldOptions{Error: "Too few."}, func() *ui.Element { + FormField(c, "Flowers", FormFieldOptions{Error: "Too few."}, func() ui.Element { return TagsInput(c, &tags, TagsInputOptions{}).Input }) - FormField(c, "Site", FormFieldOptions{}, func() *ui.Element { + FormField(c, "Site", FormFieldOptions{}, func() ui.Element { return InputGroup(c, &site, InputGroupOptions{Buttons: []InputGroupButton{{Label: "Back", Leading: true}}}).Input }) }) @@ -64,7 +64,7 @@ func TestFormFieldErrorAndLayout(t *testing.T) { horizontal := false tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) - FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() *ui.Element { + FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() ui.Element { return InputGroup(c, &v, InputGroupOptions{Prefix: "mailto:"}).Input }) }, 500, 200) @@ -93,7 +93,7 @@ func TestFormFieldNeedsLabel(t *testing.T) { }() ui.Render(func(c *ui.Context) { core.Use(c, core.Settings{}) - FormField(c, "", FormFieldOptions{}, func() *ui.Element { return ui.Box(c) }) + FormField(c, "", FormFieldOptions{}, func() ui.Element { return ui.Box(c) }) }, 100, 100, 1) } @@ -101,10 +101,10 @@ func TestFormFieldSnapshot(t *testing.T) { a, b := "Sakiko Togawa", "uika@" snapshot(t, "form_field", 520, 260, func(c *ui.Context) { ui.Column(c).Padding(20).Gap(16).Children(func() { - FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() *ui.Element { + FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() ui.Element { return InputGroup(c, &a, InputGroupOptions{}).Input }) - FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() *ui.Element { + FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() ui.Element { return InputGroup(c, &b, InputGroupOptions{Invalid: true}).Input }) }) diff --git a/input/form_test.go b/input/form_test.go index 26ab68f..1f3ffdd 100644 --- a/input/form_test.go +++ b/input/form_test.go @@ -27,7 +27,7 @@ func (p *formProbe) run(c *ui.Context) { func (p *formProbe) view(c *ui.Context) { res := Form(c, &p.st, FormOptions{Horizontal: p.horizontal}, func(f *FormScope) { - FormItem(c, f, "name", &p.name, FormItemOptions[string]{Label: "Name", Required: true, Initial: "Sakiko"}, func() *ui.Element { + FormItem(c, f, "name", &p.name, FormItemOptions[string]{Label: "Name", Required: true, Initial: "Sakiko"}, func() ui.Element { return InputGroup(c, &p.name, InputGroupOptions{Invalid: p.st.Error("name") != ""}).Input }) opts := FormItemOptions[string]{Label: "Email", Validate: func(v string) string { @@ -42,10 +42,10 @@ func (p *formProbe) view(c *ui.Context) { p.asyncValues = append(p.asyncValues, v) } } - FormItem(c, f, "email", &p.email, opts, func() *ui.Element { + FormItem(c, f, "email", &p.email, opts, func() ui.Element { return InputGroup(c, &p.email, InputGroupOptions{Invalid: p.st.Error("email") != ""}).Input }) - FormItem(c, f, "tags", &p.tags, FormItemOptions[[]string]{Label: "Tags", Required: true, Initial: []string{"rose"}}, func() *ui.Element { + FormItem(c, f, "tags", &p.tags, FormItemOptions[[]string]{Label: "Tags", Required: true, Initial: []string{"rose"}}, func() ui.Element { return TagsInput(c, &p.tags, TagsInputOptions{}).Input }) }) @@ -224,7 +224,7 @@ func TestFormResetClearsControlState(t *testing.T) { tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) Form(c, &st, FormOptions{}, func(f *FormScope) { - FormItem(c, f, "tags", &tags, FormItemOptions[[]string]{Label: "Tags"}, func() *ui.Element { + FormItem(c, f, "tags", &tags, FormItemOptions[[]string]{Label: "Tags"}, func() ui.Element { return TagsInput(c, &tags, TagsInputOptions{Validate: func(s string) string { seen = s return "Invalid tag" @@ -291,10 +291,10 @@ func TestFormCrossFieldRuleAtSettle(t *testing.T) { tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) res := Form(c, &st, FormOptions{}, func(f *FormScope) { - FormItem(c, f, "user", &user, FormItemOptions[string]{Label: "User", Async: func(_ string, v uint64) { version = v }}, func() *ui.Element { + FormItem(c, f, "user", &user, FormItemOptions[string]{Label: "User", Async: func(_ string, v uint64) { version = v }}, func() ui.Element { return InputGroup(c, &user, InputGroupOptions{}).Input }) - FormItem(c, f, "pw", &pw, FormItemOptions[string]{Label: "Password"}, func() *ui.Element { + FormItem(c, f, "pw", &pw, FormItemOptions[string]{Label: "Password"}, func() ui.Element { return InputGroup(c, &pw, InputGroupOptions{}).Input }) FormItem(c, f, "confirm", &confirm, FormItemOptions[string]{Label: "Confirm", Validate: func(v string) string { @@ -302,7 +302,7 @@ func TestFormCrossFieldRuleAtSettle(t *testing.T) { return "Passwords differ." } return "" - }}, func() *ui.Element { + }}, func() ui.Element { return InputGroup(c, &confirm, InputGroupOptions{}).Input }) }) diff --git a/input/hold_to_confirm.go b/input/hold_to_confirm.go index 98cc2ea..9bb4069 100644 --- a/input/hold_to_confirm.go +++ b/input/hold_to_confirm.go @@ -63,9 +63,10 @@ func HoldToConfirm(c *ui.Context, label string, opts HoldToConfirmOptions) core. internal.Ring(b, k.Focus, theme.ControlRadius) st := ui.Local(b, "hold_to_confirm", func() *holdToConfirmState { return &holdToConfirmState{} }) s := *st + disabled := b.IsDisabled() // Space holds; Enter and Space never click, so no single key press confirms. b.HandleInput(func(ev ui.InputEvent) bool { - if ev.Key != ui.KeySpace && ev.Key != ui.KeyEnter || b.IsDisabled() { + if ev.Key != ui.KeySpace && ev.Key != ui.KeyEnter || disabled { return false } if ev.Key == ui.KeySpace && (ev.Kind == ui.InputKeyDown || ev.Kind == ui.InputKeyUp) { @@ -74,7 +75,6 @@ func HoldToConfirm(c *ui.Context, label string, opts HoldToConfirmOptions) core. return true }) _ = b.Clicked() // a click alone does nothing; it keeps the base pressable - disabled := b.IsDisabled() held := !disabled && (s.key || b.Pressed()) if !b.Focused() { s.key = false diff --git a/input/hold_to_confirm_test.go b/input/hold_to_confirm_test.go index 46ac59c..02b5802 100644 --- a/input/hold_to_confirm_test.go +++ b/input/hold_to_confirm_test.go @@ -57,14 +57,12 @@ func TestHoldToConfirmPointerHold(t *testing.T) { tt.Press(cx, cy) time.Sleep(20 * time.Millisecond) tt.Release(cx, cy) - time.Sleep(60 * time.Millisecond) - tt.Frame() + tt.Advance(60 * time.Millisecond) if n != 0 { t.Fatal("an early release confirmed") } tt.Press(cx, cy) - time.Sleep(80 * time.Millisecond) - tt.Frame() + tt.Advance(80 * time.Millisecond) tt.Frame() tt.Release(cx, cy) if n != 1 { @@ -73,8 +71,7 @@ func TestHoldToConfirmPointerHold(t *testing.T) { dis = true tt.Frame() tt.Press(cx, cy) - time.Sleep(80 * time.Millisecond) - tt.Frame() + tt.Advance(80 * time.Millisecond) tt.Release(cx, cy) if n != 1 { t.Fatal("disabled hold confirmed") diff --git a/input/icon_button.go b/input/icon_button.go index 7ac608c..c644b20 100644 --- a/input/icon_button.go +++ b/input/icon_button.go @@ -17,7 +17,7 @@ type IconButtonOptions struct { } // IconButton is a square button showing only icon; label names its action. -func IconButton(c *ui.Context, icon *ui.SVG, label string, opts IconButtonOptions) *ui.Element { +func IconButton(c *ui.Context, icon *ui.SVG, label string, opts IconButtonOptions) ui.Element { if icon == nil { panic("mujica: IconButton needs an icon") } diff --git a/input/input_group.go b/input/input_group.go index 97af883..b61ba69 100644 --- a/input/input_group.go +++ b/input/input_group.go @@ -37,9 +37,9 @@ type InputGroupOptions struct { // InputGroupParts are the parts of an InputGroup. type InputGroupParts struct { - Group *ui.Element + Group ui.Element // Input takes the focus; it is selectable text when ReadOnly. - Input *ui.Element + Input ui.Element // Clicked is the index in Buttons of the button clicked, or -1. Clicked int locked bool @@ -77,7 +77,7 @@ func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroup n++ } seg := 0 - segment := func(e *ui.Element) { + segment := func(e ui.Element) { var left, right float32 if seg == 0 { left = r @@ -180,15 +180,15 @@ type inputGroupLock struct { } // inputGroupEditor builds the input, or plain selectable text while locked, so no edit command runs. -func inputGroupEditor(c *ui.Context, box *ui.Element, value *string, locked bool) *ui.Element { +func inputGroupEditor(c *ui.Context, box ui.Element, value *string, locked bool) ui.Element { lk := ui.Local(box, "lock", func() inputGroupLock { return inputGroupLock{} }) if lk.locked && !locked { // A new identity drops the edits queued in the old editor while locked. lk.gen++ } lk.locked = locked - var in *ui.Element - ui.Row(c).Key(lk.gen).FillWidth().MinWidth(0).Children(func() { + var in ui.Element + ui.Row(c.Key(lk.gen)).FillWidth().MinWidth(0).Children(func() { if locked { in = ui.Text(c, *value).SingleLine().Selectable() } else { diff --git a/input/input_review_test.go b/input/input_review_test.go index 636d18c..5603fef 100644 --- a/input/input_review_test.go +++ b/input/input_review_test.go @@ -8,6 +8,7 @@ import ( "git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core" "git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons" + "git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme" ) func TestButtonLoadingKeepsWidth(t *testing.T) { @@ -165,9 +166,12 @@ func inputReviewStates(t *testing.T, name string, view func(c *ui.Context)) (pla t.Fatalf("no %q", name) } plain = encode(t, tt.Image()) + // Colors still fade under reduced motion; let each fade finish on the Tester clock. tt.Move(r.X+r.W/2, r.Y+r.H/2) + tt.Advance(theme.StateDuration) hover = encode(t, tt.Image()) tt.Press(r.X+r.W/2, r.Y+r.H/2) + tt.Advance(theme.StateDuration) pressed = encode(t, tt.Image()) return } diff --git a/input/knob.go b/input/knob.go index e0d7ad7..766a74b 100644 --- a/input/knob.go +++ b/input/knob.go @@ -51,7 +51,7 @@ func Knob(c *ui.Context, value *float64, opts KnobOptions) core.ChoiceResult { *value = old col := ui.Column(c).AlignItems(ui.Center).Gap(core.Density(c).Unit()).Disabled(opts.Disabled) disabled := col.IsDisabled() - var s *ui.Element + var s ui.Element col.Children(func() { if opts.Label != "" { ui.Text(c, opts.Label).TextColor(k.Text).FontSize(core.FontSize(c, theme.CaptionSize)) diff --git a/input/masked_input.go b/input/masked_input.go index 1c24233..deb6901 100644 --- a/input/masked_input.go +++ b/input/masked_input.go @@ -102,7 +102,7 @@ func MaskedInput(c *ui.Context, value *string, opts MaskedInputOptions) InputRes k := core.Tokens(c) var r InputResult look := &textInputLook{disabled: opts.Disabled, err: opts.Error} - r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element { in := ui.Row(c).Grow(1).Focusable().Role(ui.RoleTextField).Label(opts.Label).Description(MaskFormat(opts.Mask, *value)) st := ui.Local(in, "masked_input", func() *maskedInputState { return &maskedInputState{caret: len(raw)} }) s := *st @@ -116,8 +116,10 @@ func MaskedInput(c *ui.Context, value *string, opts MaskedInputOptions) InputRes } } } + disabled := in.IsDisabled() + svc := c.Services() in.HandleInput(func(ev ui.InputEvent) bool { - if in.IsDisabled() { + if disabled { return false } raw = []rune(*value) @@ -130,9 +132,9 @@ func MaskedInput(c *ui.Context, value *string, opts MaskedInputOptions) InputRes case ui.InputCommand: switch ev.Text { case "paste": - insert(c.ReadClipboard()) + insert(svc.ReadClipboard()) case "copy": - c.WriteClipboard(*value) + svc.WriteClipboard(*value) default: return false } diff --git a/input/masonry.go b/input/masonry.go index 34770be..f8d3e7b 100644 --- a/input/masonry.go +++ b/input/masonry.go @@ -46,7 +46,7 @@ type masonryLayout struct { } // Masonry lays n items in as many columns as fit, each into the shortest column, building only those in view. -func Masonry(c *ui.Context, n int, opts MasonryOptions, item func(i int)) *ui.Element { +func Masonry(c *ui.Context, n int, opts MasonryOptions, item func(i int)) ui.Element { if opts.Key == nil || item == nil { panic("mujica: Masonry needs Key and item") } @@ -98,7 +98,7 @@ func masonryBuild(c *ui.Context, s *masonryState, l *masonryLayout, colW, top, b } *seen = append(*seen, i) key := l.keys[i] - box := ui.Column(c).Key(key).Absolute().Left(l.x[i]).Top(l.y[i]).Width(colW) + box := ui.Column(c.Key(key)).Absolute().Left(l.x[i]).Top(l.y[i]).Width(colW) box.Children(func() { item(i) }) if h := box.Bounds().H; h > 0 && math.Abs(float64(h-l.h[i])) > 0.5 { s.heights[key] = h diff --git a/input/mention_input.go b/input/mention_input.go index adacdc7..7e3b4f1 100644 --- a/input/mention_input.go +++ b/input/mention_input.go @@ -41,6 +41,14 @@ type mentionInputState struct { panel ui.Rect // the popup's box last frame, which keeps it through a click offset float32 changed, submitted bool + field ui.Handle + pointer []mentionPointer // presses and drags, hit-tested at the next build +} + +// mentionPointer is a pointer event waiting for the build, where text can be measured. +type mentionPointer struct { + x float32 + extend bool } // MentionInput edits *value with "@" mentions kept in *mentions; a mention is deleted and stepped over whole. @@ -57,7 +65,7 @@ func MentionInput(c *ui.Context, value *string, mentions *[]Mention, opts Mentio k := core.Tokens(c) var r InputResult look := &textInputLook{disabled: opts.Disabled, err: opts.Error} - r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element { in := ui.Box(c).Grow(1).MinWidth(core.Density(c).Unit() * 20).Focusable().Clip().Role(ui.RoleTextField).Label(opts.Label).Cursor(ui.CursorText) s := *ui.Local(in, "mention_input", func() *mentionInputState { return &mentionInputState{at: -1} }) if s.doc.text == nil || s.synced != *value || !slices.Equal(s.syncedMentions, *mentions) { @@ -66,16 +74,23 @@ func MentionInput(c *ui.Context, value *string, mentions *[]Mention, opts Mentio s.undo, s.redo = nil, nil s.synced, s.syncedMentions = *value, slices.Clone(*mentions) } + for _, p := range s.pointer { + s.doc.moveTo(mentionInputHit(c, s, p.x), p.extend) + } + s.pointer = s.pointer[:0] mentionInputQuery(s, opts) px, py, _ := frame.PointerPosition() fb := frame.Bounds() onPanel := s.panel.Contains(fb.X+px, fb.Y+py) popup := (in.Focused() || onPanel) && s.at >= 0 && !s.dismissed && len(s.found) > 0 + disabled := in.IsDisabled() + in.Bind(&s.field) + svc := c.Services() in.HandleInput(func(ev ui.InputEvent) bool { - if in.IsDisabled() { + if disabled { return false } - return mentionInputEvent(c, s, ev, popup, in) + return mentionInputEvent(svc, s, ev, popup) }) if s.changed && !look.disabled { *value, *mentions = string(s.doc.text), slices.Clone(s.doc.mentions) @@ -110,7 +125,7 @@ func mentionInputQuery(s *mentionInputState, opts MentionInputOptions) { } // mentionInputEvent applies one input event to the document and reports whether it took it. -func mentionInputEvent(c *ui.Context, s *mentionInputState, ev ui.InputEvent, popup bool, in *ui.Element) bool { +func mentionInputEvent(svc ui.Services, s *mentionInputState, ev ui.InputEvent, popup bool) bool { edit := func(f func()) { s.undo = append(s.undo, s.doc.clone()) s.redo = nil @@ -125,23 +140,23 @@ func mentionInputEvent(c *ui.Context, s *mentionInputState, ev ui.InputEvent, po case ui.InputCompose: s.compose = ev.Text case ui.InputPointerDown: - in.Focus() - s.doc.moveTo(mentionInputHit(c, s, ev.X), extend) + s.field.Focus() + s.pointer = append(s.pointer, mentionPointer{ev.X, extend}) case ui.InputPointerMove: if ev.Button != 0 { return false } - s.doc.moveTo(mentionInputHit(c, s, ev.X), true) + s.pointer = append(s.pointer, mentionPointer{ev.X, true}) case ui.InputCommand: a, b := s.doc.span() switch ev.Text { case "copy", "cut": - c.WriteClipboard(string(s.doc.text[a:b])) + svc.WriteClipboard(string(s.doc.text[a:b])) if ev.Text == "cut" && a != b { edit(func() { s.doc.erase(false) }) } case "paste": - edit(func() { s.doc.insert(c.ReadClipboard()) }) + edit(func() { s.doc.insert(svc.ReadClipboard()) }) case "selectAll": s.doc.anchor, s.doc.caret = 0, len(s.doc.text) case "delete": @@ -230,7 +245,7 @@ func mentionInputHit(c *ui.Context, s *mentionInputState, x float32) int { } // mentionInputView draws the text with mention chips, the selection and the caret, scrolled to keep the caret in view. -func mentionInputView(c *ui.Context, k theme.Tokens, s *mentionInputState, in *ui.Element, placeholder string) { +func mentionInputView(c *ui.Context, k theme.Tokens, s *mentionInputState, in ui.Element, placeholder string) { size := core.FontSize(c, theme.BodySize) measure := func(i int) float32 { w, _ := c.MeasureText(0, ui.Span{Text: string(s.doc.text[:i]), Size: size}) @@ -298,7 +313,7 @@ func mentionInputSpans(k theme.Tokens, s *mentionInputState, size float32, place } // mentionInputPopup lists the candidates under the field; a click or Enter picks one. -func mentionInputPopup(c *ui.Context, k theme.Tokens, s *mentionInputState, frame, in *ui.Element) { +func mentionInputPopup(c *ui.Context, k theme.Tokens, s *mentionInputState, frame, in ui.Element) { b := frame.Bounds() u := core.Density(c).Unit() ui.Overlay(c, func() { @@ -311,7 +326,7 @@ func mentionInputPopup(c *ui.Context, k theme.Tokens, s *mentionInputState, fram panel.Children(func() { ui.Scroll(c).Shrink(1).Children(func() { for i, m := range s.found { - it := ui.Row(c).Key(m.ID).MinHeight(core.Density(c).ControlHeight()).AlignItems(ui.Center).Padding(0, u*2). + it := ui.Row(c.Key(m.ID)).MinHeight(core.Density(c).ControlHeight()).AlignItems(ui.Center).Padding(0, u*2). Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(m.Name).Cursor(ui.CursorPointer) if i == s.pick { it.Selected(true).Background(k.Selection).TextColor(k.AccentText) diff --git a/input/multi_select.go b/input/multi_select.go index 9d486af..39ec2a9 100644 --- a/input/multi_select.go +++ b/input/multi_select.go @@ -55,7 +55,7 @@ func MultiSelect[T comparable](c *ui.Context, values *[]T, options []SelectOptio SelectField(c, tr, *open, func() { MultiSelectTags(c, values, options, opts) }) - ui.PopoverBase(c, tr, open, func(panel *ui.Element) { + ui.PopoverBase(c, tr, open, func(panel ui.Element) { SelectPanel(c, panel) panel.MinWidth(tr.Bounds().W).MaxWidth(max(tr.Bounds().W, 320)) multiSelectPopup(c, k, values, options, query, opts) @@ -84,7 +84,7 @@ func MultiSelectTags[T comparable](c *ui.Context, values *[]T, options []SelectO break } label, _ := SelectLabel(options, v) - ui.Row(c).Key(v).Shrink(0).Children(func() { + ui.Row(c.Key(v)).Shrink(0).Children(func() { if multiSelectTag(c, label, opts.Disabled) { *values = slices.Delete(slices.Clone(*values), i, i+1) } @@ -160,7 +160,7 @@ func multiSelectPopup[T comparable](c *ui.Context, k theme.Tokens, values *[]T, if on { st = internal.Checked } - row := ui.Row(c).Key(o.Value) + row := ui.Row(c.Key(o.Value)) row.Children(func() { cb := MultiSelectCheck(c, k, &on, st, o.Label, o.Disabled || (full && !on)) if cb.Changed() { @@ -178,9 +178,9 @@ func multiSelectPopup[T comparable](c *ui.Context, k theme.Tokens, values *[]T, } // MultiSelectCheck is a CheckboxBase row drawing its own box. -func MultiSelectCheck(c *ui.Context, k theme.Tokens, on *bool, st internal.CheckState, label string, disabled bool) *ui.Element { +func MultiSelectCheck(c *ui.Context, k theme.Tokens, on *bool, st internal.CheckState, label string, disabled bool) ui.Element { d := core.Density(c) - var cb *ui.Element + var cb ui.Element // A disabled wrapper blocks the base, which toggles as it is built. ui.Row(c).Grow(1).Disabled(disabled).Children(func() { cb = ui.CheckboxBase(c, on).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2). @@ -227,17 +227,17 @@ func MultiSelectAll[T comparable](values []T, shown []SelectOption[T], on bool, } // SelectSearch is the search field at the top of a popup; it takes the focus as the popup opens. -func SelectSearch(c *ui.Context, query *string, placeholder string) *ui.Element { +func SelectSearch(c *ui.Context, query *string, placeholder string) ui.Element { k := core.Tokens(c) d := core.Density(c) f := ui.Row(c).Gap(d.Unit()*2).Padding(0, d.Unit()*2).MinHeight(d.ControlHeight()-4).AlignItems(ui.Center). Radius(theme.ControlRadius).Background(k.Background).Border(theme.BorderWidth, k.ControlBorder) - var in *ui.Element + var in ui.Element f.Children(func() { ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, r, "search", k.TextMuted) }) - in = ui.TextInputBase(c, query).Grow(1).Placeholder(placeholder).Label(placeholder).AutoFocus() + in = core.AutoFocus(c, ui.TextInputBase(c, query).Grow(1).Placeholder(placeholder).Label(placeholder)) }) if in.Focused() { f.Border(theme.BorderWidth, k.AccentText) diff --git a/input/number_input.go b/input/number_input.go index 989f33c..67aed44 100644 --- a/input/number_input.go +++ b/input/number_input.go @@ -156,7 +156,7 @@ func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputRe } stepBy := func(n int) { set(s.StepBy(*value, n)) } look := &textInputLook{disabled: opts.Disabled, err: opts.Error} - r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element { text := ui.Local(frame, "text", func() string { return s.format(*value) }) invalid := ui.Local(frame, "invalid", func() string { return "" }) seen := ui.Local(frame, "seen", func() float64 { return *value }) diff --git a/input/password_input.go b/input/password_input.go index 259bfaa..2cfb8e6 100644 --- a/input/password_input.go +++ b/input/password_input.go @@ -26,15 +26,15 @@ func PasswordInput(c *ui.Context, value *string, opts PasswordInputOptions) Inpu } var r InputResult look := &textInputLook{disabled: opts.Disabled, err: opts.Error} - r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element { shown := ui.Local(frame, "shown", func() bool { return false }) - var in *ui.Element + var in ui.Element // MyGo's mask cannot be lifted, so each mode keeps its own editor. mode := "masked" if *shown { mode = "shown" } - ui.Row(c).Key(mode).Grow(1).Children(func() { + ui.Row(c.Key(mode)).Grow(1).Children(func() { in = TextInputEditor(c, frame, value, look.disabled, ui.TextInputBase).Grow(1).MinWidth(core.Density(c).Unit() * 10). Placeholder(opts.Placeholder).Label(opts.Label) if !*shown { diff --git a/input/pin_input.go b/input/pin_input.go index ea516cc..2cf1214 100644 --- a/input/pin_input.go +++ b/input/pin_input.go @@ -24,7 +24,7 @@ type PinInputOptions struct { // PinInputParts are the parts of a PinInput. type PinInputParts struct { - Element *ui.Element + Element ui.Element changed, completed bool } diff --git a/input/qr_code.go b/input/qr_code.go index 9d52382..e044d2f 100644 --- a/input/qr_code.go +++ b/input/qr_code.go @@ -30,7 +30,7 @@ type qrCodeCache struct { } // QRCode draws text as a QR code, encoded offline; too long a text shows an error. -func QRCode(c *ui.Context, text string, opts QRCodeOptions) *ui.Element { +func QRCode(c *ui.Context, text string, opts QRCodeOptions) ui.Element { _ = opts.Level.String() if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) { panic("mujica: QRCode Size must be finite and >= 0") diff --git a/input/radio_group.go b/input/radio_group.go index 2fa7751..1f7e042 100644 --- a/input/radio_group.go +++ b/input/radio_group.go @@ -71,8 +71,8 @@ func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opt // radioGroupItem builds one radio and reports whether it was chosen. func radioGroupItem[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, value *T, it RadioItem[T], key int) bool { // Keyed by value so focus follows the item, not its position. - var row *ui.Element - item := ui.Box(c).Key(key).Disabled(it.Disabled) + var row ui.Element + item := ui.Box(c.Key(key)).Disabled(it.Disabled) item.Children(func() { row = ui.RadioBase(c, value, it.Value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false) }) diff --git a/input/range_slider.go b/input/range_slider.go index f9c7840..79d306b 100644 --- a/input/range_slider.go +++ b/input/range_slider.go @@ -39,7 +39,7 @@ func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) cor px, _, _ := e.PointerPosition() r := e.Bounds() at := g.snap(g.lo + float64((px-SliderThumb/2)/max(r.W-SliderThumb, 1))*(g.hi-g.lo)) - var knobs [2]*ui.Element + var knobs [2]ui.Element pressed := false e.Children(func() { for i, v := range []*float64{low, high} { @@ -48,11 +48,11 @@ func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) cor from, to = *low, g.hi } x := g.X(ui.Rect{W: r.W}, *v) - SliderThumb/2 - var kb *ui.Element + var kb ui.Element var next float64 var held bool // Each thumb is a SliderBase, which reports the range and value to assistive technology. - ui.Box(c).Key(i).Absolute().Left(x).Top(g.TrackY-SliderThumb/2).Size(SliderThumb, SliderThumb).Children(func() { + ui.Box(c.Key(i)).Absolute().Left(x).Top(g.TrackY-SliderThumb/2).Size(SliderThumb, SliderThumb).Children(func() { kb, next, held = SliderControl(c, *v, g, from, to) }) kb.Size(SliderThumb, SliderThumb).FocusRing(false) diff --git a/input/rating_test.go b/input/rating_test.go index 910af51..759a18c 100644 --- a/input/rating_test.go +++ b/input/rating_test.go @@ -106,15 +106,15 @@ func TestRatingSnapshot(t *testing.T) { func TestRatingHitAreaCompact(t *testing.T) { v := 2.0 - var r core.ChoiceResult + var b ui.Rect tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{Density: theme.Compact}) ui.Column(c).AlignItems(ui.Start).Children(func() { - r = Rating(c, &v, RatingOptions{}) + b = Rating(c, &v, RatingOptions{}).Element.Bounds() }) }, 300, 100) tt.Frame() - if b := r.Element.Bounds(); b.W < 5*28 || b.H < 28 { + if b.W < 5*28 || b.H < 28 { t.Fatalf("stars %v", b) } } diff --git a/input/resizable_panel_group.go b/input/resizable_panel_group.go index e897b73..12c248d 100644 --- a/input/resizable_panel_group.go +++ b/input/resizable_panel_group.go @@ -32,7 +32,7 @@ type ResizablePanelGroupOptions struct { // ResizablePanelGroupResult reports a group's changes: Resized while moving, Committed when a change is done, to save. type ResizablePanelGroupResult struct { - Element *ui.Element + Element ui.Element Resized bool Committed bool } @@ -88,7 +88,7 @@ func ResizablePanelGroup(c *ui.Context, sizes *[]float32, panels []ResizablePane for i, p := range panels { mins[i] = cmp.Or(p.Min, 10*u) } - along := func(e *ui.Element, v float32) *ui.Element { + along := func(e ui.Element, v float32) ui.Element { if opts.Vertical { return e.Height(v).FillWidth() } @@ -109,7 +109,7 @@ func ResizablePanelGroup(c *ui.Context, sizes *[]float32, panels []ResizablePane if (*sizes)[i] == 0 { continue } - pane := ui.Box(c).Key(i).Shrink(0).Clip().Role(ui.RoleGroup).Label(p.Label) + pane := ui.Box(c.Key(i)).Shrink(0).Clip().Role(ui.RoleGroup).Label(p.Label) if avail > 0 { along(pane, (*sizes)[i]*avail) } else { @@ -128,7 +128,7 @@ func ResizablePanelGroup(c *ui.Context, sizes *[]float32, panels []ResizablePane // resizablePanelDivider builds the divider after panel i, stepped with arrows, Enter collapsing; it returns the grip's builder. func resizablePanelDivider(c *ui.Context, k theme.Tokens, u float32, st *resizablePanelState, sizes *[]float32, panels []ResizablePanel, mins []float32, avail float32, i int, vertical, disabled bool, res *ResizablePanelGroupResult) func() { s := *sizes - div := ui.Box(c).Key(fmt.Sprint("div", i)).Shrink(0).Focusable().FocusRing(false).Role(ui.RoleSplitter). + div := ui.Box(c.Key(fmt.Sprint("div", i))).Shrink(0).Focusable().FocusRing(false).Role(ui.RoleSplitter). Label(fmt.Sprintf(core.Msg(c, "resizable_panel_group.divider"), panels[i].Label, panels[i+1].Label)) if vertical { div.Height(theme.BorderWidth).FillWidth() @@ -202,7 +202,7 @@ func resizablePanelDivider(c *ui.Context, k theme.Tokens, u float32, st *resizab div.Description(core.Msg(c, "split_pane.collapsed")) } return func() { - grip := ui.Box(c).Key(fmt.Sprint("grip", i)).Absolute().Cursor(ui.CursorResizeEW) + grip := ui.Box(c.Key(fmt.Sprint("grip", i))).Absolute().Cursor(ui.CursorResizeEW) if vertical { grip.Cursor(ui.CursorResizeNS) } @@ -231,7 +231,7 @@ func resizablePanelDivider(c *ui.Context, k theme.Tokens, u float32, st *resizab } // resizablePanelGrip places the grip over divider i and draws the divider. -func resizablePanelGrip(c *ui.Context, k theme.Tokens, u float32, s []float32, avail float32, i int, vertical bool, grip, div *ui.Element, held bool) { +func resizablePanelGrip(c *ui.Context, k theme.Tokens, u float32, s []float32, avail float32, i int, vertical bool, grip, div ui.Element, held bool) { // The grip is wider than the 1 DIP line, centered on the boundary. var at float32 for _, f := range s[:i+1] { diff --git a/input/search_input.go b/input/search_input.go index ae65686..dcaaaa6 100644 --- a/input/search_input.go +++ b/input/search_input.go @@ -72,7 +72,7 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn if opts.Find { *opts.Current = max(0, min(*opts.Current, opts.Matches-1)) } - r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element { if opts.Find { // Built inside the frame, where step knows of disabled ancestors. mac := runtime.GOOS == "darwin" diff --git a/input/select.go b/input/select.go index abc7ccb..1dc9f4a 100644 --- a/input/select.go +++ b/input/select.go @@ -53,7 +53,7 @@ func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], op old := *value // SelectBase chooses into a copy as it is built; a choice counts once the option is known enabled. pick := old - var s *ui.SelectParts[T] + var s ui.SelectParts[T] site := ui.Row(c).Shrink(0).Disabled(opts.Disabled) life := ui.Local(site, selectKey{"lifetime"}, func() selectLifetime { return selectLifetime{} }) if disabled := site.IsDisabled(); disabled != life.disabled { @@ -61,7 +61,7 @@ func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], op life.generation++ } site.Children(func() { - ui.Row(c).Key(life.generation).Children(func() { s = ui.SelectBase(c, &pick) }) + ui.Row(c.Key(life.generation)).Children(func() { s = ui.SelectBase(c, &pick) }) }) tr := s.Trigger commit := func() { @@ -75,18 +75,19 @@ func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], op tr.Label(opts.Label) } ta := ui.Local(tr, selectKey{"typeahead"}, func() selectTypeahead { return selectTypeahead{} }) + disabled := tr.IsDisabled() tr.TextCaret(ui.Rect{W: 1, H: core.Density(c).ControlHeight()}).HandleInput(func(ev ui.InputEvent) bool { - if tr.IsDisabled() { + if disabled { return false } if ev.Kind != ui.InputText || strings.TrimSpace(ev.Text) == "" { return false } - if time.Since(ta.at) > time.Second { + if ev.Time.Sub(ta.at) > time.Second { ta.prefix = "" } ta.prefix += strings.ToLower(ev.Text) - ta.pending, ta.at = true, time.Now() + ta.pending, ta.at = true, ev.Time return true }) if ta.pending { @@ -117,7 +118,7 @@ func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], op if tr.IsDisabled() { return core.NewChoiceResult(tr, false) } - s.Popup(func(panel *ui.Element) { + s.Popup(func(panel ui.Element) { SelectPanel(c, panel) ui.Scroll(c).MaxHeight(280).Children(func() { if len(options) == 0 { @@ -196,7 +197,7 @@ func selectOr(s, fallback string) string { } // SelectField styles tr as a field that opens a popup, with content and a chevron. -func SelectField(c *ui.Context, tr *ui.Element, open bool, content func()) { +func SelectField(c *ui.Context, tr ui.Element, open bool, content func()) { k := core.Tokens(c) d := core.Density(c) tr.MinHeight(d.ControlHeight()).MinWidth(180).Padding(0, d.Unit()*2, 0, d.Unit()*3).Gap(d.Unit() * 2). @@ -254,13 +255,13 @@ func SelectCheck(c *ui.Context, on bool) { } // SelectPanel styles a popup panel. -func SelectPanel(c *ui.Context, panel *ui.Element) { +func SelectPanel(c *ui.Context, panel ui.Element) { core.OverlayStyle(c, panel, core.OverlayEnter(c, panel)) panel.Margin(4, 0, 0, 0).Padding(4) } // SelectRow styles an option row: chosen has the selection fill and a check, highlighted a side bar. -func SelectRow(c *ui.Context, row *ui.Element, highlighted, chosen bool, label string) { +func SelectRow(c *ui.Context, row ui.Element, highlighted, chosen bool, label string) { k := core.Tokens(c) d := core.Density(c) row.MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*2).Gap(d.Unit() * 2). diff --git a/input/signature_pad.go b/input/signature_pad.go index 0c87a8d..e9724c0 100644 --- a/input/signature_pad.go +++ b/input/signature_pad.go @@ -50,6 +50,7 @@ func SignaturePath(strokes [][]SignaturePoint) string { // signaturePadDrawing is the stroke in progress. type signaturePadDrawing struct { active, ended bool + pad ui.Handle } // SignaturePad records pointer strokes into *strokes; Changed reports a finished stroke or a clear. @@ -73,8 +74,9 @@ func SignaturePad(c *ui.Context, strokes *[][]SignaturePoint, opts SignaturePadO internal.Ring(pad, k.Focus, theme.CardRadius) d := ui.Local(pad, "signature_pad", func() *signaturePadDrawing { return &signaturePadDrawing{} }) s := *d + pad.Bind(&s.pad) pad.HandleInput(func(ev ui.InputEvent) bool { - if pad.IsDisabled() { + if disabled { return false } pt := SignaturePoint{ev.X, ev.Y} @@ -82,7 +84,7 @@ func SignaturePad(c *ui.Context, strokes *[][]SignaturePoint, opts SignaturePadO case ev.Kind == ui.InputPointerDown && ev.Button == 0: *strokes = append(*strokes, []SignaturePoint{pt}) s.active = true - pad.Focus() + s.pad.Focus() case ev.Kind == ui.InputPointerMove && s.active && len(*strokes) > 0: last := &(*strokes)[len(*strokes)-1] if q := (*last)[len(*last)-1]; math.Hypot(float64(pt.X-q.X), float64(pt.Y-q.Y)) >= 1 { diff --git a/input/slider.go b/input/slider.go index a9562e1..619e167 100644 --- a/input/slider.go +++ b/input/slider.go @@ -78,7 +78,7 @@ type sliderKey struct { type sliderKeyID struct{} // SliderControl builds a SliderBase on a copy of v and returns where the pointer or a key moves v. -func SliderControl(c *ui.Context, v float64, g SliderGeom, from, to float64) (s *ui.Element, next float64, dragged bool) { +func SliderControl(c *ui.Context, v float64, g SliderGeom, from, to float64) (s ui.Element, next float64, dragged bool) { raw := v s = ui.SliderBase(c, &raw, g.lo, g.hi) ks := *ui.Local(s, sliderKeyID{}, func() *sliderKey { return &sliderKey{} }) @@ -175,7 +175,7 @@ func Slider(c *ui.Context, value *float64, opts SliderOptions) core.ChoiceResult k := core.Tokens(c) old := g.snap(*value) *value = old - var s *ui.Element + var s ui.Element var next float64 var held bool // Disabled on a wrapper blocks the base before it takes input. diff --git a/input/spinner_test.go b/input/spinner_test.go index 1b7a6da..a723f34 100644 --- a/input/spinner_test.go +++ b/input/spinner_test.go @@ -49,15 +49,13 @@ func TestSpinnerMotion(t *testing.T) { } }, 100, 100) a := encode(t, tt.Image()) - time.Sleep(150 * time.Millisecond) - tt.Frame() + tt.Advance(150 * time.Millisecond) if bytes.Equal(a, encode(t, tt.Image())) { t.Fatal("spinner did not turn") } tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) a = encode(t, tt.Image()) - time.Sleep(150 * time.Millisecond) - tt.Frame() + tt.Advance(150 * time.Millisecond) if !bytes.Equal(a, encode(t, tt.Image())) { t.Fatal("spinner turned under reduced motion") } diff --git a/input/stable_keys_test.go b/input/stable_keys_test.go index b077b35..0a3a068 100644 --- a/input/stable_keys_test.go +++ b/input/stable_keys_test.go @@ -18,12 +18,12 @@ func TestFormFocusFollowsFieldAfterReorder(t *testing.T) { Form(c, &st, FormOptions{}, func(f *FormScope) { fields := []func(){ func() { - FormItem(c, f, "first", &first, FormItemOptions[string]{Label: "First"}, func() *ui.Element { + FormItem(c, f, "first", &first, FormItemOptions[string]{Label: "First"}, func() ui.Element { return TextInput(c, &first, TextInputOptions{Label: "First"}).Input }) }, func() { - FormItem(c, f, "second", &second, FormItemOptions[string]{Label: "Second"}, func() *ui.Element { + FormItem(c, f, "second", &second, FormItemOptions[string]{Label: "Second"}, func() ui.Element { return TextInput(c, &second, TextInputOptions{Label: "Second"}).Input }) }, diff --git a/input/switch.go b/input/switch.go index 9a7f9eb..08f3a37 100644 --- a/input/switch.go +++ b/input/switch.go @@ -26,11 +26,11 @@ const ( ) // Switch turns *on over by click or Space; Changed reports it, the caller applies it. -func Switch(c *ui.Context, on *bool, label string, opts SwitchOptions) *ui.Element { +func Switch(c *ui.Context, on *bool, label string, opts SwitchOptions) ui.Element { k := core.Tokens(c) d := core.Density(c) blocked := opts.Disabled || opts.Loading - var sw *ui.Element + var sw ui.Element // Disabled on a wrapper blocks the base before it takes input. ui.Row(c).Shrink(0).Disabled(blocked).Children(func() { sw = ui.SwitchBase(c, on).Gap(d.Unit() * 2).AlignItems(ui.Center). diff --git a/input/tags_input.go b/input/tags_input.go index a617786..f237f44 100644 --- a/input/tags_input.go +++ b/input/tags_input.go @@ -34,9 +34,9 @@ type TagsInputOptions struct { // TagsInputParts are the parts of a TagsInput. type TagsInputParts struct { - Element *ui.Element + Element ui.Element // Input takes the focus. - Input *ui.Element + Input ui.Element changed bool } @@ -44,7 +44,7 @@ type TagsInputParts struct { func (p TagsInputParts) Changed() bool { return p.changed } // NewTagsInputParts reports a tag field built from parts in another component. -func NewTagsInputParts(e, input *ui.Element, changed bool) TagsInputParts { +func NewTagsInputParts(e, input ui.Element, changed bool) TagsInputParts { return TagsInputParts{e, input, changed} } @@ -109,13 +109,13 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa remove(len(*tags) - 1) } } - var box *ui.Element + var box ui.Element outer.Children(func() { box = ui.Row(c).Wrap().AlignItems(ui.Center).Gap(d.Unit()*1.5).MinHeight(d.ControlHeight()). Padding(d.Unit(), d.Unit()*2).Radius(theme.ControlRadius).Background(k.Surface).Disabled(opts.Disabled) box.Children(func() { for i, tag := range *tags { - chip := ui.Row(c).Key(tag).AlignItems(ui.Center).Padding(0, 0, 0, d.Unit()*2).Radius(theme.ControlRadius).Background(k.Selection).Shrink(0) + chip := ui.Row(c.Key(tag)).AlignItems(ui.Center).Padding(0, 0, 0, d.Unit()*2).Radius(theme.ControlRadius).Background(k.Selection).Shrink(0) chip.Children(func() { ui.Text(c, tag).SingleLine() x := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).TextColor(k.TextMuted). @@ -131,7 +131,7 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa }) } // The input keeps its identity whatever chips come before it. - holder := ui.Box(c).Key("input").Grow(1).MinWidth(d.Unit() * 24) + holder := ui.Box(c.Key("input")).Grow(1).MinWidth(d.Unit() * 24) holder.Children(func() { in := inputGroupEditor(c, holder, st.q, disabled).FocusRing(false).FillWidth().Placeholder(opts.Placeholder) parts.Input = in @@ -177,7 +177,7 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa } // tagsInputCommit adds the text before each separator, and all of it on Enter. -func tagsInputCommit(st *tagsInputState, in *ui.Element, seps string, add func(string) bool) { +func tagsInputCommit(st *tagsInputState, in ui.Element, seps string, add func(string) bool) { if in.Changed() { st.err = "" } diff --git a/input/text_area.go b/input/text_area.go index 7e4f412..d45a1c8 100644 --- a/input/text_area.go +++ b/input/text_area.go @@ -57,7 +57,7 @@ func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult { count := textAreaCount(c, n, opts.MaxLength) var r InputResult look := &textInputLook{disabled: opts.Disabled, readOnly: opts.ReadOnly, err: errMsg, area: true} - r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element { in := TextInputEditor(c, frame, value, opts.ReadOnly || look.disabled, ui.TextAreaBase).Grow(1).Padding(d.Unit()*2, d.Unit()*3). Placeholder(opts.Placeholder).Label(opts.Label) if opts.MinHeight > 0 { diff --git a/input/text_input.go b/input/text_input.go index 22c9252..f514cc4 100644 --- a/input/text_input.go +++ b/input/text_input.go @@ -13,9 +13,9 @@ import ( // InputResult is a value input's outer element and its events. type InputResult struct { - Element *ui.Element + Element ui.Element // Input is the text field inside, for focus and keys. - Input *ui.Element + Input ui.Element changed bool submitted bool } @@ -27,7 +27,7 @@ func (r InputResult) Changed() bool { return r.changed } func (r InputResult) Submitted() bool { return r.submitted } // NewInputResult reports a text control built from parts in another component. -func NewInputResult(e, input *ui.Element, changed, submitted bool) InputResult { +func NewInputResult(e, input ui.Element, changed, submitted bool) InputResult { return InputResult{e, input, changed, submitted} } @@ -55,7 +55,7 @@ func TextInput(c *ui.Context, value *string, opts TextInputOptions) InputResult } var r InputResult look := &textInputLook{disabled: opts.Disabled, readOnly: opts.ReadOnly, err: opts.Error} - r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element { if opts.Leading != nil { opts.Leading() } @@ -85,7 +85,7 @@ type textInputLook struct { } // textInputFrame builds the bordered field around what build returns as the input. -func textInputFrame(c *ui.Context, look *textInputLook, build func(frame *ui.Element) *ui.Element, footer func()) (outer, in *ui.Element) { +func textInputFrame(c *ui.Context, look *textInputLook, build func(frame ui.Element) ui.Element, footer func()) (outer, in ui.Element) { k := core.Tokens(c) d := core.Density(c) outer = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(look.disabled) @@ -147,7 +147,7 @@ func textInputFrame(c *ui.Context, look *textInputLook, build func(frame *ui.Ele } // TextInputEditor builds the MyGo editor; a locked one edits a copy, undone at once. -func TextInputEditor(c *ui.Context, frame *ui.Element, value *string, locked bool, base func(*ui.Context, *string) *ui.Element) *ui.Element { +func TextInputEditor(c *ui.Context, frame ui.Element, value *string, locked bool, base func(*ui.Context, *string) ui.Element) ui.Element { if !locked { return base(c, value) } @@ -159,7 +159,7 @@ func TextInputEditor(c *ui.Context, frame *ui.Element, value *string, locked boo } // textInputClear builds the clear button and reports a clear by click or Escape. -func textInputClear(c *ui.Context, in *ui.Element) bool { +func textInputClear(c *ui.Context, in ui.Element) bool { k := core.Tokens(c) b := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Center().Radius(theme.ControlRadius). Role(ui.RoleButton).Label(core.Msg(c, "text_input.clear")).Cursor(ui.CursorPointer) diff --git a/input/toggle.go b/input/toggle.go index ff4915f..a6c4d7f 100644 --- a/input/toggle.go +++ b/input/toggle.go @@ -20,11 +20,11 @@ type ToggleOptions struct { } // Toggle is a button that stays pressed while *on. Click or Space flips it; Changed reports it. -func Toggle(c *ui.Context, on *bool, label string, opts ToggleOptions) *ui.Element { +func Toggle(c *ui.Context, on *bool, label string, opts ToggleOptions) ui.Element { if label == "" && opts.Label == "" { panic("mujica: Toggle needs a label or Label") } - var b *ui.Element + var b ui.Element ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { b = ui.ToggleBase(c, on) }) @@ -39,7 +39,7 @@ func Toggle(c *ui.Context, on *bool, label string, opts ToggleOptions) *ui.Eleme } // toggleLook styles a pressed-or-not button and builds its content. -func toggleLook(c *ui.Context, b *ui.Element, on bool, label string, icon *ui.SVG) { +func toggleLook(c *ui.Context, b ui.Element, on bool, label string, icon *ui.SVG) { k := core.Tokens(c) d := core.Density(c) b.MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()).Padding(0, d.Unit()*3).Gap(d.Unit() * 2). @@ -69,7 +69,7 @@ func toggleLook(c *ui.Context, b *ui.Element, on bool, label string, icon *ui.SV } // toggleMark draws a bar under the content: the pressed state beside the color. -func toggleMark(b *ui.Element, k theme.Tokens) { +func toggleMark(b ui.Element, k theme.Tokens) { b.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X + r.W*0.25, Y: r.Y + r.H - 4, W: r.W * 0.5, H: 2}, k.AccentText, 1) }) diff --git a/input/toggle_group.go b/input/toggle_group.go index 887157e..a6da0cc 100644 --- a/input/toggle_group.go +++ b/input/toggle_group.go @@ -57,7 +57,7 @@ func ToggleGroup[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], on := slices.Contains(*value, it.Value) pressed := on // Keyed by value so focus follows the item, not its position. - ui.Box(c).Key(stableKey(known, it.Value)).Children(func() { + ui.Box(c.Key(stableKey(known, it.Value))).Children(func() { b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled) // The base took input before Disabled applied: drop a disabled item's press. if b.Changed() && !it.Disabled && !opts.Disabled && toggleGroupApply(value, it.Value, pressed, opts) { @@ -90,7 +90,7 @@ func toggleGroupApply[T comparable](value *[]T, v T, pressed bool, opts ToggleGr return true } -func toggleGroupLook[T comparable](c *ui.Context, k theme.Tokens, b *ui.Element, on bool, it ToggleItem[T]) { +func toggleGroupLook[T comparable](c *ui.Context, k theme.Tokens, b ui.Element, on bool, it ToggleItem[T]) { d := core.Density(c) b.MinHeight(d.ControlHeight()-2).MinWidth(d.ControlHeight()).Padding(0, d.Unit()*3).Gap(d.Unit() * 2) internal.Ring(b, k.Focus, theme.ControlRadius) diff --git a/input/transfer.go b/input/transfer.go index 3162b81..d2fbc71 100644 --- a/input/transfer.go +++ b/input/transfer.go @@ -175,7 +175,7 @@ func transferPanel(c *ui.Context, st *transferState, side int, title string, ite } for _, it := range shown { on := st.checked[it.ID] - ui.Box(c).Key(it.ID).Children(func() { + ui.Box(c.Key(it.ID)).Children(func() { cb := ui.CheckboxBase(c, &on).Gap(d.Unit()*2).MinHeight(max(d.ControlHeight()-d.Unit(), theme.MinHitSize)).Padding(0, d.Unit()*2). Radius(theme.ControlRadius).FillWidth().Disabled(it.Disabled) if cb.Changed() { @@ -203,7 +203,7 @@ func transferPartial(c *ui.Context, checked, enabled int) string { } // transferMark draws a check box's square inside box, which takes the focus ring. -func transferMark(c *ui.Context, box *ui.Element, on, mixed bool) { +func transferMark(c *ui.Context, box ui.Element, on, mixed bool) { k := core.Tokens(c) internal.Ring(box, k.Focus, theme.ControlRadius) box.Children(func() { diff --git a/input/tree_select.go b/input/tree_select.go index 9095b01..3265ff4 100644 --- a/input/tree_select.go +++ b/input/tree_select.go @@ -94,7 +94,7 @@ func TreeSelect(c *ui.Context, selected *[]string, nodes []TreeSelectNode, opts MultiSelectTags(c, selected, options, MultiSelectOptions{MaxTags: opts.MaxTags, Disabled: opts.Disabled}) } }) - ui.PopoverBase(c, tr, &st.open, func(panel *ui.Element) { + ui.PopoverBase(c, tr, &st.open, func(panel ui.Element) { SelectPanel(c, panel) panel.MinWidth(max(tr.Bounds().W, 260)) ui.Column(c).Gap(4).Children(func() { @@ -219,7 +219,7 @@ func treeSelectFirstChild(n *TreeSelectNode, shown map[string]bool) string { } // treeSelectDescribe says what MyGo cannot set on custom rows (ui/element.go:282). -func treeSelectDescribe(c *ui.Context, b *ui.Element, branch, open bool, state internal.CheckState) { +func treeSelectDescribe(c *ui.Context, b ui.Element, branch, open bool, state internal.CheckState) { var parts []string if branch { parts = append(parts, core.Msg(c, map[bool]string{true: "tree_select.expanded", false: "tree_select.collapsed"}[open])) @@ -238,7 +238,7 @@ func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r n := r.node branch := len(n.Children) > 0 open := branch && (st.expanded[n.ID] || strings.TrimSpace(st.query) != "") - ui.Row(c).Key(n.ID).AlignItems(ui.Center).Gap(2).Padding(0, 0, 0, float32(r.depth)*18).Children(func() { + ui.Row(c.Key(n.ID)).AlignItems(ui.Center).Gap(2).Padding(0, 0, 0, float32(r.depth)*18).Children(func() { arrow := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Radius(theme.ControlRadius) if branch { arrow.Role(ui.RoleButton).Label(fmt.Sprintf(core.Msg(c, map[bool]string{true: "tree_select.collapse", false: "tree_select.expand"}[open]), n.Label)) @@ -255,7 +255,7 @@ func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r }) } state := treeSelectCheck(n, *selected, opts) - var b *ui.Element + var b ui.Element if opts.Multiple { on := state == internal.Checked b = MultiSelectCheck(c, k, &on, state, n.Label, n.Disabled) diff --git a/input/vertical_slider.go b/input/vertical_slider.go index bdad215..65593c5 100644 --- a/input/vertical_slider.go +++ b/input/vertical_slider.go @@ -41,7 +41,7 @@ func VerticalSlider(c *ui.Context, value *float64, opts VerticalSliderOptions) c h := cmp.Or(opts.Height, 160) old := g.snap(*value) *value = old - var s *ui.Element + var s ui.Element var next float64 var held bool wrap := ui.Column(c).Disabled(opts.Disabled) diff --git a/internal/layout.go b/internal/layout.go index ea1ed3e..fd4b88d 100644 --- a/internal/layout.go +++ b/internal/layout.go @@ -3,7 +3,7 @@ package internal import "github.com/egoist/mygo/ui" // Remeasure rebuilds the view when layout gives e another size than the w×h it was built for. -func Remeasure(c *ui.Context, e *ui.Element, w, h float32) *ui.Element { +func Remeasure(c *ui.Context, e ui.Element, w, h float32) ui.Element { return e.DrawOver(func(_ *ui.Painter, r ui.Rect) { if abs(r.W-w) > 0.5 || abs(r.H-h) > 0.5 { c.Invalidate() diff --git a/internal/navigation.go b/internal/navigation.go index d5f4841..b9872c2 100644 --- a/internal/navigation.go +++ b/internal/navigation.go @@ -8,7 +8,7 @@ import ( ) // Ease moves e's background, border and text color fg to their targets over d. -func Ease(e *ui.Element, d time.Duration, fg ui.Color) *ui.Element { +func Ease(e ui.Element, d time.Duration, fg ui.Color) ui.Element { if d > 0 { e.Transition(ui.ElementTransition{Colors: true, Duration: d}) } diff --git a/internal/paint.go b/internal/paint.go index 7665e45..3ed026d 100644 --- a/internal/paint.go +++ b/internal/paint.go @@ -16,7 +16,7 @@ func FocusRing(p *ui.Painter, r ui.Rect, radius float32, c ui.Color) { } // Ring replaces MyGo's ring on e with the MujicaUI ring. -func Ring(e *ui.Element, c ui.Color, radius float32) *ui.Element { +func Ring(e ui.Element, c ui.Color, radius float32) ui.Element { return e.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) { if e.FocusVisible() { FocusRing(p, r, radius, c) diff --git a/layout/app_shell.go b/layout/app_shell.go index 6837fa8..9a0d1a0 100644 --- a/layout/app_shell.go +++ b/layout/app_shell.go @@ -28,7 +28,7 @@ type AppShellOptions struct { // AppShellResult is an AppShell and its event in this frame. type AppShellResult struct { - Element *ui.Element + Element ui.Element // Toggled reports the sidebar opened or collapsed. Toggled bool } diff --git a/layout/aspect_ratio.go b/layout/aspect_ratio.go index 1b23041..d5dd572 100644 --- a/layout/aspect_ratio.go +++ b/layout/aspect_ratio.go @@ -13,7 +13,7 @@ type AspectRatioOptions struct { } // AspectRatio centers the largest Ratio frame that fits; the child is centered, not stretched. -func AspectRatio(c *ui.Context, opts AspectRatioOptions, child func()) *ui.Element { +func AspectRatio(c *ui.Context, opts AspectRatioOptions, child func()) ui.Element { if opts.Ratio < 0 { panic("mujica: AspectRatio Ratio must be positive") } diff --git a/layout/container.go b/layout/container.go index 1867ed4..906086c 100644 --- a/layout/container.go +++ b/layout/container.go @@ -62,7 +62,7 @@ type ContainerOptions struct { } // Container pads its children, caps their width and can center, fill and clip. -func Container(c *ui.Context, opts ContainerOptions, children func()) *ui.Element { +func Container(c *ui.Context, opts ContainerOptions, children func()) ui.Element { if opts.MaxWidth < 0 { panic("mujica: Container MaxWidth must not be negative") } diff --git a/layout/divider.go b/layout/divider.go index 17a9671..c04ac28 100644 --- a/layout/divider.go +++ b/layout/divider.go @@ -22,7 +22,7 @@ type DividerOptions struct { } // Divider is a thin separating line. It never takes focus and its ornament is not read out. -func Divider(c *ui.Context, opts DividerOptions) *ui.Element { +func Divider(c *ui.Context, opts DividerOptions) ui.Element { if opts.Vertical && opts.Label != "" { panic("mujica: a vertical Divider cannot have a Label") } diff --git a/layout/grid.go b/layout/grid.go index 0d1ae98..cfe4655 100644 --- a/layout/grid.go +++ b/layout/grid.go @@ -19,7 +19,7 @@ type GridOptions struct { } // Grid lays children out in columns and rows; GridCell places and spans them. -func Grid(c *ui.Context, opts GridOptions, children func()) *ui.Element { +func Grid(c *ui.Context, opts GridOptions, children func()) ui.Element { if children == nil { panic("mujica: Grid needs children") } @@ -48,7 +48,7 @@ type GridCellOptions struct { } // GridCell is one child of a Grid, placed and spanned by opts; build makes one element, stretched over the cell. -func GridCell(c *ui.Context, opts GridCellOptions, build func()) *ui.Element { +func GridCell(c *ui.Context, opts GridCellOptions, build func()) ui.Element { if build == nil { panic("mujica: GridCell needs build") } @@ -59,7 +59,7 @@ func GridCell(c *ui.Context, opts GridCellOptions, build func()) *ui.Element { panic("mujica: GridCell spans must be positive, or -1 for the rest") } // A one-track grid stretches its child over the whole cell, as the grid does its own children. - e := ui.Grid(c).Columns(1).Rows(1) + e := ui.Grid(c).Columns(1).GridRows(1) if opts.Column > 0 { e.ColumnStart(opts.Column) } diff --git a/layout/scroll_area.go b/layout/scroll_area.go index 1d7c840..5bd8ece 100644 --- a/layout/scroll_area.go +++ b/layout/scroll_area.go @@ -24,11 +24,11 @@ type ScrollAreaOptions struct { } // ScrollArea scrolls children; state keeps and sets the offset, nil keeps it by place. -func ScrollArea(c *ui.Context, state *ui.ScrollState, opts ScrollAreaOptions, children func()) *ui.Element { +func ScrollArea(c *ui.Context, state *ui.ScrollState, opts ScrollAreaOptions, children func()) ui.Element { if children == nil { panic("mujica: ScrollArea needs children") } - var e *ui.Element + var e ui.Element switch opts.Direction { case ScrollVertical: e = ui.Scroll(c) diff --git a/layout/split_pane.go b/layout/split_pane.go index 9372e1b..ac4e684 100644 --- a/layout/split_pane.go +++ b/layout/split_pane.go @@ -40,7 +40,7 @@ type SplitPaneOptions struct { // SplitPaneResult is a SplitPane and its events in this frame. type SplitPaneResult struct { - Element *ui.Element + Element ui.Element Resized bool Toggled bool } @@ -80,7 +80,7 @@ func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, firs var res SplitPaneResult e.Children(func() { // Sizes come from this inner box: the caller's border and padding stay outside it. - in := ui.Row(c).Key("in").Grow(1).MinWidth(0).AlignItems(ui.Stretch) + in := ui.Row(c.Key("in")).Grow(1).MinWidth(0).AlignItems(ui.Stretch) if opts.Vertical { in.Column().MinHeight(0) } @@ -94,7 +94,7 @@ func SplitPane(c *ui.Context, state *SplitPaneState, opts SplitPaneOptions, firs } // splitPaneBuild lays the panes, divider and drag handle out in the inner box in. -func splitPaneBuild(c *ui.Context, in *ui.Element, state *SplitPaneState, opts SplitPaneOptions, lo, minSecond float32, res *SplitPaneResult, first, second func()) { +func splitPaneBuild(c *ui.Context, in ui.Element, state *SplitPaneState, opts SplitPaneOptions, lo, minSecond float32, res *SplitPaneResult, first, second func()) { k := core.Tokens(c) u := core.Density(c).Unit() was := in.Bounds() @@ -123,7 +123,7 @@ func splitPaneBuild(c *ui.Context, in *ui.Element, state *SplitPaneState, opts S } firstShown := !(state.Collapsed && opts.Collapsible == SplitPaneFirst) secondShown := !(state.Collapsed && opts.Collapsible == SplitPaneSecond) - along := func(el *ui.Element, v float32) { + along := func(el ui.Element, v float32) { if opts.Vertical { el.Height(v) } else { @@ -132,7 +132,7 @@ func splitPaneBuild(c *ui.Context, in *ui.Element, state *SplitPaneState, opts S } in.Children(func() { if firstShown { - a := ui.Box(c).Key("first").Shrink(0).Clip() + a := ui.Box(c.Key("first")).Shrink(0).Clip() switch { case !secondShown: a.Grow(1).Shrink(1) @@ -145,7 +145,7 @@ func splitPaneBuild(c *ui.Context, in *ui.Element, state *SplitPaneState, opts S } a.Children(first) } - div := ui.Box(c).Key("divider").Shrink(0).Focusable().FocusRing(false).Role(ui.RoleSplitter).Label(core.Msg(c, "split_pane.divider")) + div := ui.Box(c.Key("divider")).Shrink(0).Focusable().FocusRing(false).Role(ui.RoleSplitter).Label(core.Msg(c, "split_pane.divider")) along(div, theme.BorderWidth) if state.Collapsed { div.Description(core.Msg(c, "split_pane.collapsed")) @@ -154,7 +154,7 @@ func splitPaneBuild(c *ui.Context, in *ui.Element, state *SplitPaneState, opts S } splitPaneKeys(c, div, state, opts, lo, hi, u, resize, toggle) if secondShown { - b := ui.Box(c).Key("second").Grow(1).Shrink(1).Clip() + b := ui.Box(c.Key("second")).Grow(1).Shrink(1).Clip() if opts.Vertical { b.MinHeight(0) } else { @@ -170,7 +170,7 @@ func splitPaneBuild(c *ui.Context, in *ui.Element, state *SplitPaneState, opts S at = total - theme.BorderWidth } grip := 1.5 * u - handle := ui.Box(c).Key("handle").Absolute() + handle := ui.Box(c.Key("handle")).Absolute() if opts.Vertical { handle.Left(0).Right(0).Top(at - (grip-1)/2).Height(grip).Cursor(ui.CursorResizeNS) } else { @@ -238,7 +238,7 @@ func splitPaneFollow(state *SplitPaneState, opts SplitPaneOptions, want, lo, min } // splitPaneKeys moves the divider with the arrows, Home and End, and toggles the collapsible pane with Enter. -func splitPaneKeys(c *ui.Context, div *ui.Element, state *SplitPaneState, opts SplitPaneOptions, lo, hi, u float32, resize func(float32), toggle func()) { +func splitPaneKeys(c *ui.Context, div ui.Element, state *SplitPaneState, opts SplitPaneOptions, lo, hi, u float32, resize func(float32), toggle func()) { back, forth := ui.KeyLeft, ui.KeyRight if opts.Vertical { back, forth = ui.KeyUp, ui.KeyDown diff --git a/layout/stack.go b/layout/stack.go index dcada6d..54e7d64 100644 --- a/layout/stack.go +++ b/layout/stack.go @@ -31,7 +31,7 @@ type StackOptions struct { } // Stack arranges children in a row or column. A child not built takes no room and no gap. -func Stack(c *ui.Context, opts StackOptions, children func()) *ui.Element { +func Stack(c *ui.Context, opts StackOptions, children func()) ui.Element { if children == nil { panic("mujica: Stack needs children") } diff --git a/layout/status_bar.go b/layout/status_bar.go index d73decd..a84f7e2 100644 --- a/layout/status_bar.go +++ b/layout/status_bar.go @@ -47,7 +47,7 @@ type StatusBarOptions struct { // StatusBarResult is a StatusBar and its event in this frame. type StatusBarResult struct { - Element *ui.Element + Element ui.Element // Clicked is the ID of the action item clicked, or empty. Clicked string } @@ -86,7 +86,7 @@ func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult { return hasFocus } group := func(key string, items []StatusItem) { - ui.Row(c).Key(key).Gap(gap).Shrink(0).Dividers(theme.BorderWidth, k.Border).Children(func() { + ui.Row(c.Key(key)).Gap(gap).Shrink(0).Dividers(theme.BorderWidth, k.Border).Children(func() { for _, it := range items { if !hidden[it.ID] && item(it) { *focused = it.ID @@ -96,7 +96,7 @@ func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult { } e.Children(func() { group("status_bar.left", opts.Left) - ui.Spacer(c).Key("status_bar.spacer") + ui.Spacer(c.Key("status_bar.spacer")) if len(hidden) > 0 { more := statusBarMore(c, len(hidden)) if wasFocused != "" && hidden[wasFocused] { @@ -108,7 +108,7 @@ func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult { slog.Debug("mujica: status_bar hidden items", "open", *open) } if *open { - ui.PopoverBase(c, more, open, func(panel *ui.Element) { + ui.PopoverBase(c, more, open, func(panel ui.Element) { core.OverlayStyle(c, panel, core.OverlayEnter(c, panel)) panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit()).Gap(d.Unit()).AlignItems(ui.Stretch) for _, it := range statusBarAll(opts) { @@ -126,9 +126,9 @@ func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult { } // statusBarMore is the "+N" button that opens the hidden items. -func statusBarMore(c *ui.Context, n int) *ui.Element { +func statusBarMore(c *ui.Context, n int) ui.Element { k := core.Tokens(c) - b := ui.ButtonBase(c).Key("status_bar.more").MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize). + b := ui.ButtonBase(c.Key("status_bar.more")).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize). PaddingX(core.Density(c).Unit()).Radius(theme.ControlRadius).TextColor(k.Text). Label(fmt.Sprintf(core.Msg(c, "status_bar.hidden"), n)) internal.Ring(b, k.Focus, theme.ControlRadius) @@ -248,9 +248,9 @@ func statusBarItem(c *ui.Context, it StatusItem) (clicked, focused bool) { default: panic("mujica: invalid StatusItemTone") } - var e *ui.Element + var e ui.Element if it.Action { - e = ui.ButtonBase(c).Key(it.ID).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Radius(theme.ControlRadius) + e = ui.ButtonBase(c.Key(it.ID)).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Radius(theme.ControlRadius) internal.Ring(e, k.Focus, theme.ControlRadius) if e.Pressed() { e.Background(k.Selection) @@ -258,7 +258,7 @@ func statusBarItem(c *ui.Context, it StatusItem) (clicked, focused bool) { e.Background(k.SurfaceHover) } } else { - e = ui.Row(c).Key(it.ID).MinHeight(theme.MinHitSize) + e = ui.Row(c.Key(it.ID)).MinHeight(theme.MinHitSize) } e.PaddingX(d.Unit()).Gap(d.Unit()).TextColor(fg).Shrink(0) if it.Label != "" { diff --git a/layout/title_bar.go b/layout/title_bar.go index ada0d9d..419e3fe 100644 --- a/layout/title_bar.go +++ b/layout/title_bar.go @@ -16,7 +16,7 @@ type TitleBarOptions struct { } // TitleBar is a window title row that drags the window and clears the system buttons. -func TitleBar(c *ui.Context, title string, opts TitleBarOptions) *ui.Element { +func TitleBar(c *ui.Context, title string, opts TitleBarOptions) ui.Element { k := core.Tokens(c) d := core.Density(c) bar := c.TitleBar() diff --git a/media/audio_player.go b/media/audio_player.go index 06d2794..6638623 100644 --- a/media/audio_player.go +++ b/media/audio_player.go @@ -27,7 +27,7 @@ type AudioPlayerOptions struct { } // AudioPlayer is a play, pause and seek shell. -func AudioPlayer(c *ui.Context, st AudioState, opts AudioPlayerOptions) *ui.Element { +func AudioPlayer(c *ui.Context, st AudioState, opts AudioPlayerOptions) ui.Element { if opts.OnPlay == nil || opts.OnPause == nil || opts.OnSeek == nil { panic("mujica: AudioPlayer needs OnPlay, OnPause and OnSeek") } @@ -63,7 +63,7 @@ func AudioPlayer(c *ui.Context, st AudioState, opts AudioPlayerOptions) *ui.Elem } // mediaPlayToggle is the play/pause button; it calls the matching callback. -func mediaPlayToggle(c *ui.Context, playing bool, onPlay, onPause func()) *ui.Element { +func mediaPlayToggle(c *ui.Context, playing bool, onPlay, onPause func()) ui.Element { icon, label := core.MediaPlay, core.Msg(c, "media_controls.play") if playing { icon, label = core.MediaPause, core.Msg(c, "media_controls.pause") diff --git a/media/audio_spectrum.go b/media/audio_spectrum.go index 2783775..21d5f36 100644 --- a/media/audio_spectrum.go +++ b/media/audio_spectrum.go @@ -29,7 +29,7 @@ type AudioSpectrumOptions struct { type spectrumKey struct{} // AudioSpectrum draws caller bands as bars; it never animates itself. -func AudioSpectrum(c *ui.Context, bands []float32, opts AudioSpectrumOptions) *ui.Element { +func AudioSpectrum(c *ui.Context, bands []float32, opts AudioSpectrumOptions) ui.Element { for _, b := range bands { if b < 0 || b > 1 || math.IsNaN(float64(b)) { panic("mujica: AudioSpectrum band outside 0..1") @@ -99,7 +99,7 @@ func spectrumReadout(name string, v float32) string { } // chartCursor moves *cur by arrows, Home and End. -func chartCursor(e *ui.Element, cur *int, n int) (moved, ok bool) { +func chartCursor(e ui.Element, cur *int, n int) (moved, ok bool) { if e.IsDisabled() || n == 0 { return false, false } @@ -120,7 +120,7 @@ func chartCursor(e *ui.Element, cur *int, n int) (moved, ok bool) { } // mediaChartState fills e with a loading, error or empty state. -func mediaChartState(c *ui.Context, e *ui.Element, status data.DataStatus, errText string, empty bool, emptyKey string) bool { +func mediaChartState(c *ui.Context, e ui.Element, status data.DataStatus, errText string, empty bool, emptyKey string) bool { k := core.Tokens(c) switch { case status == data.DataLoading: diff --git a/media/audio_waveform.go b/media/audio_waveform.go index a235cd6..1b0256b 100644 --- a/media/audio_waveform.go +++ b/media/audio_waveform.go @@ -127,7 +127,7 @@ func waveformBucket(samples []float32, cols int) []float32 { } // waveformInput seeks and selects; true means the selection changed. -func waveformInput(c *ui.Context, e *ui.Element, r ui.Rect, opts AudioWaveformOptions) bool { +func waveformInput(c *ui.Context, e ui.Element, r ui.Rect, opts AudioWaveformOptions) bool { at := func(x float32) time.Duration { f := max(0, min(1, (x-r.X)/max(1, r.W))) return time.Duration(float64(f) * float64(opts.Duration)).Round(time.Millisecond) diff --git a/media/image_annotator.go b/media/image_annotator.go index c45e990..29d3679 100644 --- a/media/image_annotator.go +++ b/media/image_annotator.go @@ -185,7 +185,7 @@ func annotatorPreview(s *annotatorState) []Annotation { } // annotatorPointer draws shapes, selects by click, drags the selection. -func annotatorPointer(stage *ui.Element, s *annotatorState, notes *[]Annotation, r ui.Rect, size image.Point, scale, ox, oy float32) { +func annotatorPointer(stage ui.Element, s *annotatorState, notes *[]Annotation, r ui.Rect, size image.Point, scale, ox, oy float32) { _, _, _ = stage.Dragged() px, py, _ := stage.PointerPosition() pt := image.Pt(int(math.Round(float64((px+r.X-ox)/scale))), int(math.Round(float64((py+r.Y-oy)/scale)))) @@ -218,7 +218,7 @@ func annotatorPointer(stage *ui.Element, s *annotatorState, notes *[]Annotation, } // annotatorKeys: [ ] select, arrows move, Delete removes, Escape clears. -func annotatorKeys(stage *ui.Element, s *annotatorState, notes *[]Annotation, size image.Point) { +func annotatorKeys(stage ui.Element, s *annotatorState, notes *[]Annotation, size image.Point) { n := len(*notes) step := max(1, size.X/100) move := func(dx, dy int) { diff --git a/media/image_compare.go b/media/image_compare.go index c85c08c..d76c346 100644 --- a/media/image_compare.go +++ b/media/image_compare.go @@ -37,7 +37,7 @@ func ImageCompare(c *ui.Context, before, after ui.ImageSource, split *float64, o } g, _ := input.SliderSetup(0, 1, 0.01, nil, false, c) old := *split - var s *ui.Element + var s ui.Element var next float64 ui.Box(c).Disabled(opts.Disabled).Children(func() { s, next, _ = input.SliderControl(c, old, g, 0, 1) diff --git a/media/image_cropper.go b/media/image_cropper.go index c6df0cb..e1e24c7 100644 --- a/media/image_cropper.go +++ b/media/image_cropper.go @@ -210,7 +210,7 @@ func cropperRotate(cr *ImageCrop, size image.Point, d int, aspect float64) { } // cropperPointer moves the frame, or resizes it by a corner. -func cropperPointer(stage *ui.Element, cr *ImageCrop, r ui.Rect, size image.Point, scale, ox, oy float32, aspect float64) { +func cropperPointer(stage ui.Element, cr *ImageCrop, r ui.Rect, size image.Point, scale, ox, oy float32, aspect float64) { d := ui.Local(stage, cropperDrag{}, func() cropperDrag { return cropperDrag{} }) dx, dy, ok := stage.Dragged() if !stage.Pressed() { diff --git a/media/image_cropper_geom.go b/media/image_cropper_geom.go index 78ec86b..609c329 100644 --- a/media/image_cropper_geom.go +++ b/media/image_cropper_geom.go @@ -48,7 +48,7 @@ func cropperClampSize(w, h int, room image.Point, aspect float64) (int, int) { } // cropperKeys: arrows move, Shift+arrows resize, 1% per press. -func cropperKeys(stage *ui.Element, cr *ImageCrop, size image.Point, aspect float64) { +func cropperKeys(stage ui.Element, cr *ImageCrop, size image.Point, aspect float64) { step := max(1, size.X/100) r := cr.Rect move := func(dx, dy int) { cr.Rect = cropperDragRect(r, 1, dx, dy, size, aspect) } diff --git a/media/lightbox.go b/media/lightbox.go index b9c6672..815fc96 100644 --- a/media/lightbox.go +++ b/media/lightbox.go @@ -28,7 +28,7 @@ func Lightbox(c *ui.Context, open *bool, index *int, images []data.ViewerImage) u := core.Density(c).Unit() w, h := c.Size() light := ui.Hex("#F2EBDD") - v.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + v.Element = ui.DialogBase(c, open, func(back, panel ui.Element) { back.Background(ui.RGBA(0, 0, 0, 0.92)) panel.Size(w, h).Padding(u * 4).Gap(u * 3).TextColor(light).Label(core.Msg(c, "lightbox.title")). Opacity(core.OverlayEnter(c, panel)) @@ -79,7 +79,7 @@ func Lightbox(c *ui.Context, open *bool, index *int, images []data.ViewerImage) } // lightboxButton is a 40 DIP icon button on the backdrop. -func lightboxButton(c *ui.Context, icon, key string, disabled bool) *ui.Element { +func lightboxButton(c *ui.Context, icon, key string, disabled bool) ui.Element { light := ui.Hex("#F2EBDD") b := core.IconAction(c, icons.Must(icon), core.Msg(c, key)).Size(40, 40).TextColor(light).Disabled(disabled) // The theme's focus color is too dark on the backdrop. diff --git a/media/media_controls.go b/media/media_controls.go index 16118a6..7283326 100644 --- a/media/media_controls.go +++ b/media/media_controls.go @@ -39,7 +39,7 @@ type MediaControlsOptions struct { } // MediaControls are previous, play, next, loop and shuffle buttons. -func MediaControls(c *ui.Context, st MediaControlsState, opts MediaControlsOptions) *ui.Element { +func MediaControls(c *ui.Context, st MediaControlsState, opts MediaControlsOptions) ui.Element { if opts.OnPrevious == nil || opts.OnPlay == nil || opts.OnPause == nil || opts.OnNext == nil || opts.OnLoop == nil || opts.OnShuffle == nil { panic("mujica: MediaControls needs every callback") } diff --git a/media/mic_level_meter.go b/media/mic_level_meter.go index 1786b6f..cc9ebd0 100644 --- a/media/mic_level_meter.go +++ b/media/mic_level_meter.go @@ -27,7 +27,7 @@ type micPeak struct { } // MicLevelMeter shows level as segments, held peak and dB. -func MicLevelMeter(c *ui.Context, level float32, opts MicLevelMeterOptions) *ui.Element { +func MicLevelMeter(c *ui.Context, level float32, opts MicLevelMeterOptions) ui.Element { if level < 0 || level > 1 || math.IsNaN(float64(level)) || opts.Hold < 0 { panic("mujica: MicLevelMeter needs level in 0..1 and Hold >= 0") } diff --git a/media/mic_level_meter_test.go b/media/mic_level_meter_test.go index 127ec0f..84b99d7 100644 --- a/media/mic_level_meter_test.go +++ b/media/mic_level_meter_test.go @@ -12,10 +12,10 @@ import ( func TestMicLevelMeterPeakHold(t *testing.T) { level := float32(1) - var e *ui.Element + var desc string tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) - e = MicLevelMeter(c, level, MicLevelMeterOptions{Hold: 40 * time.Millisecond}) + desc = micTestDescription(MicLevelMeter(c, level, MicLevelMeterOptions{Hold: 40 * time.Millisecond})) }, 400, 100) if !tt.HasText("0 dB") { t.Fatalf("texts %v", tt.Texts()) @@ -28,18 +28,17 @@ func TestMicLevelMeterPeakHold(t *testing.T) { if r, ok := tt.Find("Microphone level"); !ok || r.W == 0 { t.Fatal("meter not named") } - if !strings.Contains(micTestDescription(e), "peak 0 dB") { + if !strings.Contains(desc, "peak 0 dB") { t.Fatal("peak not held") } - time.Sleep(60 * time.Millisecond) - tt.Frame() - if !strings.Contains(micTestDescription(e), "peak -20 dB") { + tt.Advance(60 * time.Millisecond) + if !strings.Contains(desc, "peak -20 dB") { t.Fatal("peak did not fall after the hold") } } // micTestDescription rebuilds the meter's spoken value from its state. -func micTestDescription(e *ui.Element) string { +func micTestDescription(e ui.Element) string { pk := *ui.Local(e, micPeak{}, func() micPeak { return micPeak{} }) return "peak " + micDB(pk.level) } diff --git a/media/playlist.go b/media/playlist.go index 3e323e5..7fd6a4c 100644 --- a/media/playlist.go +++ b/media/playlist.go @@ -30,7 +30,7 @@ type PlaylistOptions struct { // PlaylistResult is the playlist's element and this frame's events. type PlaylistResult struct { - Element *ui.Element + Element ui.Element // Played is the ID chosen by Enter or double click. Played string // Removed is the ID taken out of *items. diff --git a/media/screen_recorder_controls.go b/media/screen_recorder_controls.go index 1d8aa4d..7fe83fc 100644 --- a/media/screen_recorder_controls.go +++ b/media/screen_recorder_controls.go @@ -34,7 +34,7 @@ type ScreenRecorderControlsOptions struct { } // ScreenRecorderControls start, pause, resume and stop a recording. -func ScreenRecorderControls(c *ui.Context, st RecorderState, opts ScreenRecorderControlsOptions) *ui.Element { +func ScreenRecorderControls(c *ui.Context, st RecorderState, opts ScreenRecorderControlsOptions) ui.Element { if opts.OnStart == nil || opts.OnPause == nil || opts.OnResume == nil || opts.OnStop == nil { panic("mujica: ScreenRecorderControls needs every callback") } diff --git a/media/subtitle_editor.go b/media/subtitle_editor.go index 9338071..b67195d 100644 --- a/media/subtitle_editor.go +++ b/media/subtitle_editor.go @@ -139,7 +139,7 @@ func subtitleRow(c *ui.Context, k theme.Tokens, cues *[]SubtitleCue, s *subtitle u := core.Density(c).Unit() q := &(*cues)[i] changed, removed := false, false - row := ui.Column(c).Key(i).Absolute().Left(0).Right(0).Top(float32(i)*rowH).Height(rowH).Padding(u*1.5, u*2).Gap(2). + row := ui.Column(c.Key(i)).Absolute().Left(0).Right(0).Top(float32(i)*rowH).Height(rowH).Padding(u*1.5, u*2).Gap(2). BorderWidth(0, 0, 1, 0).BorderColor(k.Border) row.Children(func() { ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { diff --git a/media/video_player.go b/media/video_player.go index 7221bc9..57bf33e 100644 --- a/media/video_player.go +++ b/media/video_player.go @@ -42,7 +42,7 @@ type VideoPlayerOptions struct { var VideoBlack = ui.Hex("#0E0C0D") // VideoPlayer draws the caller's frame and reports controls through callbacks. -func VideoPlayer(c *ui.Context, st VideoState, opts VideoPlayerOptions) *ui.Element { +func VideoPlayer(c *ui.Context, st VideoState, opts VideoPlayerOptions) ui.Element { if opts.OnPlay == nil || opts.OnPause == nil || opts.OnSeek == nil { panic("mujica: VideoPlayer needs OnPlay, OnPause and OnSeek") } @@ -106,7 +106,7 @@ func videoStage(c *ui.Context, k theme.Tokens, st VideoState, opts VideoPlayerOp }) } -func videoKeys(stage *ui.Element, st VideoState, opts VideoPlayerOptions) { +func videoKeys(stage ui.Element, st VideoState, opts VideoPlayerOptions) { seek := func(d time.Duration) { to := min(max(0, st.Position+d), st.Duration) slog.Debug("mujica: video_player seek", "pos", to) @@ -153,7 +153,7 @@ func videoButtons(c *ui.Context, st VideoState, opts VideoPlayerOptions) { } // MediaToggleAction is an iconAction that shows when it is on. -func MediaToggleAction(c *ui.Context, icon *ui.SVG, label string, on bool) *ui.Element { +func MediaToggleAction(c *ui.Context, icon *ui.SVG, label string, on bool) ui.Element { k := core.Tokens(c) b := core.IconAction(c, icon, label).Role(ui.RoleToggleButton).Selected(on) if on { diff --git a/media/video_scrubber.go b/media/video_scrubber.go index 9bd1e79..d27d580 100644 --- a/media/video_scrubber.go +++ b/media/video_scrubber.go @@ -51,7 +51,7 @@ func VideoScrubber(c *ui.Context, pos *time.Duration, opts VideoScrubberOptions) dur := opts.Duration.Seconds() g, h := input.SliderSetup(0, dur, min(step.Seconds(), dur), nil, false, c) old := *pos - var s *ui.Element + var s ui.Element var next float64 wrap := ui.Box(c).MinWidth(120).Shrink(0).Disabled(opts.Disabled) wrap.Children(func() { diff --git a/media/video_thumbnail_strip.go b/media/video_thumbnail_strip.go index e69f0c4..b5c5492 100644 --- a/media/video_thumbnail_strip.go +++ b/media/video_thumbnail_strip.go @@ -94,7 +94,7 @@ func VideoThumbnailStrip(c *ui.Context, current *int, frames []StripFrame, opts // stripCell draws one frame and reports a click on it. func stripCell(c *ui.Context, k theme.Tokens, f StripFrame, i int, on bool, fw, fh float32) bool { - cell := ui.Box(c).Key(i).Size(fw, fh).Shrink(0).Radius(theme.ControlRadius).Clip().Background(VideoBlack). + cell := ui.Box(c.Key(i)).Size(fw, fh).Shrink(0).Radius(theme.ControlRadius).Clip().Background(VideoBlack). Cursor(ui.CursorPointer).AlignSelf(ui.Center) edge, width := k.Border, theme.BorderWidth if on { diff --git a/messaging/call_controls.go b/messaging/call_controls.go index ebeb3cd..1673c25 100644 --- a/messaging/call_controls.go +++ b/messaging/call_controls.go @@ -66,7 +66,7 @@ func (k callKey) String() string { } // CallControls provides call actions and window shortcuts. -func CallControls(c *ui.Context, st CallState, opts CallControlsOptions) *ui.Element { +func CallControls(c *ui.Context, st CallState, opts CallControlsOptions) ui.Element { if opts.OnMute == nil || opts.OnCamera == nil || opts.OnShare == nil || opts.OnHangUp == nil { panic("mujica: CallControls needs every callback") } diff --git a/messaging/channel_list.go b/messaging/channel_list.go index 41390b1..1adfe21 100644 --- a/messaging/channel_list.go +++ b/messaging/channel_list.go @@ -175,7 +175,7 @@ func channelRow(c *ui.Context, k theme.Tokens, ch Channel, on bool) bool { if ch.Unread > 0 { label = fmt.Sprintf(core.Msg(c, "channel_list.unread"), ch.Name, ch.Unread) } - b := ui.ButtonBase(c).Key(ch.ID).MinHeight(d.ControlHeight()).Justify(ui.Start).Gap(u*2).Padding(0, u*2, 0, u*5). + b := ui.ButtonBase(c.Key(ch.ID)).MinHeight(d.ControlHeight()).Justify(ui.Start).Gap(u*2).Padding(0, u*2, 0, u*5). Radius(theme.ControlRadius).Label(label).Role(ui.RoleTreeItem).Selected(on).Background(ui.Transparent). TextColor(k.Text).Transition(core.ColorFade) internal.Ring(b, k.Focus, theme.ControlRadius) diff --git a/messaging/chat_message.go b/messaging/chat_message.go index 3fcf1a0..d21581e 100644 --- a/messaging/chat_message.go +++ b/messaging/chat_message.go @@ -43,7 +43,7 @@ func ChatContinues(prev, cur ChatEntry) bool { } // ChatMessage displays text, timestamps, edits and interaction slots. -func ChatMessage(c *ui.Context, m ChatEntry, opts ChatMessageOptions) *ui.Element { +func ChatMessage(c *ui.Context, m ChatEntry, opts ChatMessageOptions) ui.Element { if m.Author == "" { panic("mujica: ChatMessage needs an Author") } diff --git a/messaging/emoji_picker.go b/messaging/emoji_picker.go index ff109c7..89a379e 100644 --- a/messaging/emoji_picker.go +++ b/messaging/emoji_picker.go @@ -26,7 +26,7 @@ type EmojiPickerOptions struct { // EmojiResult exposes the element and this frame’s chosen emoji. type EmojiResult struct { - Element *ui.Element + Element ui.Element // Picked contains the chosen emoji, or an empty string. Picked string } @@ -139,7 +139,7 @@ func emojiTabs(c *ui.Context, k theme.Tokens, tab *int, recent bool) { *tab = min(*tab, len(tabs)-1) ui.Row(c).FocusGroup(ui.Horizontal).Role(ui.RoleTabList).BorderWidth(0, 0, 1, 0).BorderColor(k.Border).Children(func() { for i, t := range tabs { - b := ui.ButtonBase(c).Key(t.key).Size(32, 32).Radius(theme.ControlRadius).Role(ui.RoleTab).Label(core.Msg(c, t.key)). + b := ui.ButtonBase(c.Key(t.key)).Size(32, 32).Radius(theme.ControlRadius).Role(ui.RoleTab).Label(core.Msg(c, t.key)). Selected(i == *tab).Background(ui.Transparent).Tooltip(core.Msg(c, t.key)) internal.Ring(b, k.Focus, theme.ControlRadius) switch { @@ -160,7 +160,7 @@ func emojiToneRow(c *ui.Context, k theme.Tokens, tone *int) { ui.Row(c).Gap(2).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleRadioGroup).Label(core.Msg(c, "emoji_picker.tone")).Children(func() { ui.Text(c, core.Msg(c, "emoji_picker.tone")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Margin(0, 6, 0, 0) for i := range emojiTones { - b := ui.ButtonBase(c).Key(i).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Role(ui.RoleRadio). + b := ui.ButtonBase(c.Key(i)).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Role(ui.RoleRadio). Label(fmt.Sprintf(core.Msg(c, "emoji_picker.toneN"), i)).Selected(i == *tone).Background(ui.Transparent) internal.Ring(b, k.Focus, theme.ControlRadius) if i == *tone { @@ -179,7 +179,7 @@ func emojiToneRow(c *ui.Context, k theme.Tokens, tone *int) { func emojiCell(c *ui.Context, k theme.Tokens, e emoji, tone int) string { s := emojiToned(e, tone) - b := ui.ButtonBase(c).Key(e.char).Size(36, 36).Radius(theme.ControlRadius).Label(emojiLabel(c, e)).Tooltip(emojiLabel(c, e)).Transition(core.ColorFade) + b := ui.ButtonBase(c.Key(e.char)).Size(36, 36).Radius(theme.ControlRadius).Label(emojiLabel(c, e)).Tooltip(emojiLabel(c, e)).Transition(core.ColorFade) internal.Ring(b, k.Focus, theme.ControlRadius) if b.Hovered() || b.Focused() { b.Background(k.SurfaceHover) diff --git a/messaging/incoming_call_dialog.go b/messaging/incoming_call_dialog.go index 53e3505..79be07b 100644 --- a/messaging/incoming_call_dialog.go +++ b/messaging/incoming_call_dialog.go @@ -49,7 +49,7 @@ func IncomingCallDialog(c *ui.Context, open *bool, call IncomingCall, opts Incom u := core.Density(c).Unit() title := fmt.Sprintf(core.Msg(c, "incoming_call_dialog.title"), call.Caller) shown, accepted, video, declined, backClicked := true, false, false, false, false - panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) { + panel := ui.DialogBase(c, &shown, func(back, panel ui.Element) { backClicked = back.Clicked() // a click outside neither answers nor refuses p := core.OverlayEnter(c, panel) back.Background(overlay.OverlayScrim(k).Alpha(p)).Padding(16) @@ -68,9 +68,9 @@ func IncomingCallDialog(c *ui.Context, open *bool, call IncomingCall, opts Incom decline := input.Button(c, core.Msg(c, "incoming_call_dialog.decline"), input.ButtonOptions{Variant: input.Danger, Icon: msgHangUp, Label: core.Msg(c, "incoming_call_dialog.decline")}) accept := input.Button(c, core.Msg(c, "incoming_call_dialog.accept"), input.ButtonOptions{Icon: msgPhone, Label: core.Msg(c, "incoming_call_dialog.accept")}) if opts.FocusAccept { - accept.AutoFocus() + core.AutoFocus(c, accept) } else { - decline.AutoFocus() + core.AutoFocus(c, decline) } declined, accepted = decline.Clicked(), accept.Clicked() if call.Video && input.Button(c, core.Msg(c, "incoming_call_dialog.acceptVideo"), input.ButtonOptions{Variant: input.Secondary, Icon: core.MediaCamera, Label: core.Msg(c, "incoming_call_dialog.acceptVideo")}).Clicked() { diff --git a/messaging/mail_composer.go b/messaging/mail_composer.go index 7a86a0f..024ba30 100644 --- a/messaging/mail_composer.go +++ b/messaging/mail_composer.go @@ -35,7 +35,7 @@ type MailComposerOptions struct { // MailComposerResult is the composer's element and this frame's events. type MailComposerResult struct { - Element *ui.Element + Element ui.Element // Sent requests sending; Discarded requests discarding the draft. Sent, Discarded bool } @@ -178,7 +178,7 @@ func composerAttachments(c *ui.Context, k theme.Tokens, d *MailDraft, disabled b ui.Row(c).Gap(core.Density(c).Unit() * 2).Wrap().Children(func() { for i, a := range d.Attachments { var v display.TagView - ui.Box(c).Key(fmt.Sprint(i, a.Name)).Children(func() { + ui.Box(c.Key(fmt.Sprint(i, a.Name))).Children(func() { v = display.Tag(c, a.Name, display.TagOptions{Icon: "file", Closable: true, Disabled: disabled}) }) if v.Closed { diff --git a/messaging/mail_list.go b/messaging/mail_list.go index 41dffa6..467b5fd 100644 --- a/messaging/mail_list.go +++ b/messaging/mail_list.go @@ -31,7 +31,7 @@ type MailListOptions struct { // MailListResult is a MailList's element and this frame's events. type MailListResult struct { - Element *ui.Element + Element ui.Element changed bool // Opened contains the ID activated by Enter or double-click. Opened string diff --git a/messaging/mail_reader.go b/messaging/mail_reader.go index a100545..3b9584b 100644 --- a/messaging/mail_reader.go +++ b/messaging/mail_reader.go @@ -42,7 +42,7 @@ type Mail struct { // MailReaderResult is a MailReader's element and this frame's events. type MailReaderResult struct { - Element *ui.Element + Element ui.Element // Link contains the activated URL, or an empty string. Link string // Opened and Downloaded are attachment indexes, -1 for none. @@ -122,7 +122,7 @@ func mailPlainBody(c *ui.Context, open *map[string]bool, body string) { j++ } chunk := strings.Join(lines[i:j], "\n") - ui.Box(c).Key(i).Children(func() { + ui.Box(c.Key(i)).Children(func() { if quoted { // Fold state is kept per quote text. o := (*open)[chunk] diff --git a/messaging/member_list.go b/messaging/member_list.go index 041c4a8..d57c089 100644 --- a/messaging/member_list.go +++ b/messaging/member_list.go @@ -51,7 +51,7 @@ type memberRows struct { // MemberListResult is a MemberList's element and this frame's events. type MemberListResult struct { - Element *ui.Element + Element ui.Element changed bool // Opened contains the ID activated by Enter or double-click. Opened string diff --git a/messaging/online_status.go b/messaging/online_status.go index b2025a4..7118287 100644 --- a/messaging/online_status.go +++ b/messaging/online_status.go @@ -43,7 +43,7 @@ type OnlineStatusOptions struct { } // OnlineStatus shows presence as a distinct shape plus its name. -func OnlineStatus(c *ui.Context, p PresenceStatus, opts OnlineStatusOptions) *ui.Element { +func OnlineStatus(c *ui.Context, p PresenceStatus, opts OnlineStatusOptions) ui.Element { name := core.Msg(c, p.key()) if opts.Size < 0 { panic("mujica: OnlineStatus Size must not be negative") diff --git a/messaging/pinned_messages.go b/messaging/pinned_messages.go index 264cb74..4301a21 100644 --- a/messaging/pinned_messages.go +++ b/messaging/pinned_messages.go @@ -13,7 +13,7 @@ import ( // PinnedMessagesResult is the list's element and this frame's events. type PinnedMessagesResult struct { - Element *ui.Element + Element ui.Element // Jump requests navigation; Unpinned requests removing a pin. Jump, Unpinned string } diff --git a/messaging/read_receipt.go b/messaging/read_receipt.go index e5f52e7..f7c80cd 100644 --- a/messaging/read_receipt.go +++ b/messaging/read_receipt.go @@ -30,7 +30,7 @@ type ReadReceiptOptions struct { } // ReadReceipt displays delivery marks, text and reader avatars. -func ReadReceipt(c *ui.Context, state ReceiptState, opts ReadReceiptOptions) *ui.Element { +func ReadReceipt(c *ui.Context, state ReceiptState, opts ReadReceiptOptions) ui.Element { if state < ReceiptSent || state > ReceiptRead || opts.Max < 0 { panic(fmt.Sprintf("mujica: bad ReadReceipt state %d or Max %d", state, opts.Max)) } @@ -62,7 +62,7 @@ func ReadReceipt(c *ui.Context, state ReceiptState, opts ReadReceiptOptions) *ui } ui.Row(c).Children(func() { for i, r := range opts.Readers[:min(limit, len(opts.Readers))] { - cell := ui.Box(c).Key(i).Padding(1).Radius(9).Background(k.Background) + cell := ui.Box(c.Key(i)).Padding(1).Radius(9).Background(k.Background) if i > 0 { cell.Margin(0, 0, 0, -5) } diff --git a/messaging/recipient_input.go b/messaging/recipient_input.go index f732735..7ae63d2 100644 --- a/messaging/recipient_input.go +++ b/messaging/recipient_input.go @@ -124,7 +124,7 @@ func RecipientInput(c *ui.Context, recipients *[]string, opts RecipientInputOpti panel.Children(func() { ui.Scroll(c).Shrink(1).Padding(4).Children(func() { for i, ct := range s.matches { - row := ui.ButtonBase(c).Key(ct.Email).MinHeight(core.Density(c).ControlHeight()).Justify(ui.Start).Gap(8).Padding(0, 8). + row := ui.ButtonBase(c.Key(ct.Email)).MinHeight(core.Density(c).ControlHeight()).Justify(ui.Start).Gap(8).Padding(0, 8). Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(recipientFormat(ct)).FocusRing(false) if i == s.hi || row.Hovered() { row.Background(k.Selection) diff --git a/messaging/snooze_picker.go b/messaging/snooze_picker.go index 0c30a21..593a065 100644 --- a/messaging/snooze_picker.go +++ b/messaging/snooze_picker.go @@ -105,7 +105,7 @@ func SnoozePicker(c *ui.Context, until *time.Time, opts SnoozePickerOptions) cor return b.Clicked() && !b.IsDisabled() } for _, ch := range SnoozeChoices(now, morning) { - ui.Box(c).Key(ch.Key).Children(func() { + ui.Box(c.Key(ch.Key)).Children(func() { if row(core.Msg(c, ch.Key), snoozeWhen(c, now, ch.At), until.Equal(ch.At)) { *until, s.custom = ch.At, false } diff --git a/messaging/status_setter.go b/messaging/status_setter.go index 7f8d70f..9329626 100644 --- a/messaging/status_setter.go +++ b/messaging/status_setter.go @@ -99,7 +99,7 @@ func StatusSetter(c *ui.Context, st *CustomStatus, opts StatusSetterOptions) cor for i, o := range statusClear { options[i] = input.SelectOption[int]{Value: i, Label: core.Msg(c, o.key)} } - ui.Box(c).Key(live).Children(func() { + ui.Box(c.Key(live)).Children(func() { if input.Select(c, &s.clear, options, input.SelectOptions{Label: core.Msg(c, "status_setter.clearAfter"), Placeholder: core.MessagingDate(c, st.Until.In(loc), "status_setter.until"), Disabled: !live}).Changed() { st.Until = statusUntil(c.Now().In(loc), statusClear[s.clear].after) } diff --git a/messaging/thread_panel.go b/messaging/thread_panel.go index 575a359..e580618 100644 --- a/messaging/thread_panel.go +++ b/messaging/thread_panel.go @@ -22,7 +22,7 @@ type ThreadPanelOptions struct { // ThreadPanelResult is the panel's element and this frame's events. type ThreadPanelResult struct { - Element *ui.Element + Element ui.Element // Sent is a reply the user sent; empty is none. Sent string } diff --git a/messaging/unread_divider.go b/messaging/unread_divider.go index 720e671..efc4a34 100644 --- a/messaging/unread_divider.go +++ b/messaging/unread_divider.go @@ -10,7 +10,7 @@ import ( ) // UnreadDivider marks where unread messages begin; count zero shows "New". -func UnreadDivider(c *ui.Context, count int) *ui.Element { +func UnreadDivider(c *ui.Context, count int) ui.Element { if count < 0 { panic("mujica: UnreadDivider count must not be negative") } diff --git a/messaging/user_profile_card.go b/messaging/user_profile_card.go index dc36349..84e085f 100644 --- a/messaging/user_profile_card.go +++ b/messaging/user_profile_card.go @@ -31,7 +31,7 @@ type UserProfileCardOptions struct { } // UserProfileCard displays identity, presence, role and action slots. -func UserProfileCard(c *ui.Context, prof UserProfile, opts UserProfileCardOptions) *ui.Element { +func UserProfileCard(c *ui.Context, prof UserProfile, opts UserProfileCardOptions) ui.Element { if prof.Name == "" { panic("mujica: UserProfileCard needs a Name") } diff --git a/messaging/video_call_grid.go b/messaging/video_call_grid.go index 04d997b..a0aefbf 100644 --- a/messaging/video_call_grid.go +++ b/messaging/video_call_grid.go @@ -31,7 +31,7 @@ type VideoCallGridOptions struct { } // VideoCallGrid displays participant frames and speaker marks. -func VideoCallGrid(c *ui.Context, people []CallParticipant, opts VideoCallGridOptions) *ui.Element { +func VideoCallGrid(c *ui.Context, people []CallParticipant, opts VideoCallGridOptions) ui.Element { if opts.Columns < 0 { panic("mujica: VideoCallGrid Columns must not be negative") } @@ -78,7 +78,7 @@ func callTile(c *ui.Context, k theme.Tokens, p CallParticipant) { if len(notes) > 0 { label += ", " + strings.Join(notes, ", ") } - tile := ui.Box(c).Key(p.ID).AspectRatio(16.0 / 9).Radius(theme.CardRadius).Clip().Background(media.VideoBlack).Center(). + tile := ui.Box(c.Key(p.ID)).AspectRatio(16.0 / 9).Radius(theme.CardRadius).Clip().Background(media.VideoBlack).Center(). Focusable().Role(ui.RoleImage).Label(label) internal.Ring(tile, k.Focus, theme.CardRadius) light := ui.Hex("#F2EBDD") diff --git a/navigation/breadcrumb.go b/navigation/breadcrumb.go index 7651952..b980a72 100644 --- a/navigation/breadcrumb.go +++ b/navigation/breadcrumb.go @@ -29,7 +29,7 @@ type BreadcrumbOptions struct { // BreadcrumbResult is the built Breadcrumb. type BreadcrumbResult struct { - Element *ui.Element + Element ui.Element chosen int } @@ -135,14 +135,14 @@ func breadcrumbMore(c *ui.Context, hidden []BreadcrumbItem, choose func(int)) { *open = !*open } b.Children(func() { ui.Icon(c, icons.Must("ellipsis")).Size(16, 16) }) - ui.PopoverBase(c, b, open, func(panel *ui.Element) { + ui.PopoverBase(c, b, open, func(panel ui.Element) { core.OverlayStyle(c, panel, core.OverlayEnter(c, panel)) panel.Margin(u, 0, 0, 0).Padding(u).Gap(u * 0.5).MinWidth(160).MaxWidth(320) for j, it := range hidden { item := ui.ButtonBase(c).Role(ui.RoleLink).Justify(ui.Start).Gap(u*2).Padding(u*1.5, u*2). Radius(theme.ControlRadius).MinHeight(core.Density(c).ControlHeight() - u*2).Label(it.Label).TextColor(k.Text) if j == 0 { - item.AutoFocus() + core.AutoFocus(c, item) } internal.Ring(item, k.Focus, theme.ControlRadius) if item.Hovered() { diff --git a/navigation/command_palette.go b/navigation/command_palette.go index fbe6dd0..5d0bb9e 100644 --- a/navigation/command_palette.go +++ b/navigation/command_palette.go @@ -33,7 +33,7 @@ type CommandPaletteOptions struct { // CommandPaletteResult is the built CommandPalette; Element is nil while closed. type CommandPaletteResult struct { - Element *ui.Element + Element ui.Element chosen string } @@ -79,7 +79,7 @@ func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandP k := core.Tokens(c) d := core.Density(c) u := d.Unit() - res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + res.Element = ui.DialogBase(c, open, func(back, panel ui.Element) { ww, wh := c.Size() p := core.OverlayEnter(c, panel) back.Background(ui.RGBA(0, 0, 0, 0.4*p)).Justify(ui.Start) @@ -87,10 +87,10 @@ func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandP panel.Margin(wh*0.12, 0, 0, 0).Width(560).MaxWidth(ww - 32).MaxHeight(wh * 0.76). AlignItems(ui.Stretch).Label(core.Msg(c, "command_palette.label")) found := commandPaletteFilter(commands, st.query) - var in *ui.Element + var in ui.Element ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*3, u*4).Children(func() { ui.Icon(c, icons.Must("search")).Size(16, 16).TextColor(k.TextMuted) - in = ui.TextInputBase(c, &st.query).FillWidth().AutoFocus().Label(core.Msg(c, "command_palette.search")) + in = core.AutoFocus(c, ui.TextInputBase(c, &st.query).FillWidth()).Label(core.Msg(c, "command_palette.search")) }) ui.Box(c).Height(1).Background(k.Ornament).Role(ui.RoleNone) if in.Changed() { diff --git a/navigation/menubar.go b/navigation/menubar.go index 6581667..d0afc71 100644 --- a/navigation/menubar.go +++ b/navigation/menubar.go @@ -57,7 +57,7 @@ type MenubarOptions struct { // MenubarResult is the built Menubar. type MenubarResult struct { - Element *ui.Element + Element ui.Element chosen string } @@ -167,7 +167,7 @@ func (m *menubarRun) current() string { return "" } -func (m *menubarRun) title(i int, label string) *ui.Element { +func (m *menubarRun) title(i int, label string) ui.Element { c, st := m.c, m.st k := core.Tokens(c) d := core.Density(c) @@ -211,7 +211,7 @@ func (m *menubarRun) title(i int, label string) *ui.Element { } // keys drives an open menu from the focused title. -func (m *menubarRun) keys(t *ui.Element) { +func (m *menubarRun) keys(t ui.Element) { st := m.st l := len(st.hl) - 1 items := m.items(l) diff --git a/navigation/menubar_panel.go b/navigation/menubar_panel.go index e3ce546..4b2a87f 100644 --- a/navigation/menubar_panel.go +++ b/navigation/menubar_panel.go @@ -50,7 +50,7 @@ func (m *menubarRun) overlay(titles []ui.Rect) { var prev ui.Rect for l := 0; l < len(st.hl) && st.open >= 0; l++ { panel, anchor := m.panel(l, x, y, prev) - if anchor == nil || l+1 >= len(st.hl) { + if !anchor.Valid() || l+1 >= len(st.hl) { break } a := anchor.Bounds() @@ -61,7 +61,7 @@ func (m *menubarRun) overlay(titles []ui.Rect) { } // panel builds level l at (x, y) and returns it with its highlighted row. -func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *ui.Element) { +func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (ui.Element, ui.Element) { c, st := m.c, m.st k := core.Tokens(c) u := core.Density(c).Unit() @@ -84,7 +84,7 @@ func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *u if panel.Clicked() || panel.Pressed() { m.focus(st.open) } - var anchor *ui.Element + var anchor ui.Element panel.Children(func() { for j, it := range items { if it.Kind == MenubarSeparator { @@ -92,6 +92,9 @@ func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *u continue } row := m.item(it, st.hl[l] == j) + if it.Disabled { + row.PassThrough() // its press reaches the panel, which keeps the title focused + } if st.hl[l] == j { anchor = row if st.reveal && l == len(st.hl)-1 { @@ -126,7 +129,7 @@ func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *u return panel, anchor } -func (m *menubarRun) item(it MenubarItem, hl bool) *ui.Element { +func (m *menubarRun) item(it MenubarItem, hl bool) ui.Element { c := m.c k := core.Tokens(c) d := core.Density(c) diff --git a/navigation/navigation_menu.go b/navigation/navigation_menu.go index 0352b85..da06b2b 100644 --- a/navigation/navigation_menu.go +++ b/navigation/navigation_menu.go @@ -31,7 +31,7 @@ type NavigationMenuOptions struct { } // NavigationMenu shows links pushing pages on router; the router's path marks the current one. -func NavigationMenu(c *ui.Context, router *ui.Router, links []NavigationLink, opts NavigationMenuOptions) *ui.Element { +func NavigationMenu(c *ui.Context, router *ui.Router, links []NavigationLink, opts NavigationMenuOptions) ui.Element { if router == nil { panic("mujica: NavigationMenu needs a router") } @@ -69,7 +69,7 @@ func NavigationMenu(c *ui.Context, router *ui.Router, links []NavigationLink, op return row } -func navigationMenuHistory(c *ui.Context, b *ui.Element, name string) { +func navigationMenuHistory(c *ui.Context, b ui.Element, name string) { k := core.Tokens(c) d := core.Density(c) b.Label(name).MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()).Radius(theme.ControlRadius).Background(k.Surface) @@ -147,7 +147,7 @@ func navigationMenuTop(c *ui.Context, l NavigationLink, path string, push func(s } else if b.Shortcut(0, ui.KeyDown) { *open = true } - ui.PopoverBase(c, b, open, func(panel *ui.Element) { + ui.PopoverBase(c, b, open, func(panel ui.Element) { core.OverlayStyle(c, panel, core.OverlayEnter(c, panel)) // Capped to the room beside the anchor; the links scroll inside. _, wh := c.Size() @@ -189,13 +189,13 @@ func navigationMenuPanel(c *ui.Context, items []NavigationLink, path string, ope list(items) } -func navigationMenuItem(c *ui.Context, l NavigationLink, on, first bool) *ui.Element { +func navigationMenuItem(c *ui.Context, l NavigationLink, on, first bool) ui.Element { k := core.Tokens(c) u := core.Density(c).Unit() e := ui.ButtonBase(c).Role(ui.RoleLink).Justify(ui.Start).AlignItems(ui.Start).Gap(u*3). Padding(u*2, u*3).Radius(theme.ControlRadius).Label(l.Label).TextColor(k.Text).Cursor(ui.CursorPointer) if first { - e.AutoFocus() + core.AutoFocus(c, e) } internal.Ring(e, k.Focus, theme.ControlRadius) switch { diff --git a/navigation/navigation_review_test.go b/navigation/navigation_review_test.go index 4ae226d..95db3bd 100644 --- a/navigation/navigation_review_test.go +++ b/navigation/navigation_review_test.go @@ -36,7 +36,6 @@ func TestNavigationPopupsFadeIn(t *testing.T) { CommandPalette(c, &open, commandPaletteSample(), CommandPaletteOptions{}) }}, } { - opened := time.Now() tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{Mode: core.Light}) ui.Column(c).Fill().Padding(16).AlignItems(ui.Start).Children(func() { tc.view(c) }) @@ -51,12 +50,11 @@ func TestNavigationPopupsFadeIn(t *testing.T) { t.Fatalf("%s: no %q", tc.name, tc.sample) } x, y := int(r.X-3), int(r.Y+r.H/2) + // The Tester clock stands still, so a quarter of the way in is exact. + tt.Advance(theme.PopupDuration / 4) early := tt.Image().RGBAAt(x, y) - mid := time.Since(opened) < theme.PopupDuration/2 - time.Sleep(200 * time.Millisecond) - tt.Frame() - // A slow run may finish the fade before the first sample. - if late := tt.Image().RGBAAt(x, y); mid && early == late { + tt.Advance(200 * time.Millisecond) + if late := tt.Image().RGBAAt(x, y); early == late { t.Errorf("%s: popup appeared at once (%v)", tc.name, late) } } diff --git a/navigation/pagination.go b/navigation/pagination.go index 153bed3..8a37e1d 100644 --- a/navigation/pagination.go +++ b/navigation/pagination.go @@ -126,7 +126,7 @@ func paginationItems(cur, pages, sib int) []int { return append(out, pages) } -func paginationButton(c *ui.Context, text string, icon *ui.SVG, name string, on, disabled bool) *ui.Element { +func paginationButton(c *ui.Context, text string, icon *ui.SVG, name string, on, disabled bool) ui.Element { k := core.Tokens(c) h := core.Density(c).ControlHeight() b := ui.ButtonBase(c).MinHeight(h).MinWidth(h).Padding(0, core.Density(c).Unit()*2).Radius(theme.ControlRadius). @@ -175,7 +175,7 @@ func paginationSizes(c *ui.Context, page, pageSize *int, sizes []int, res *core. ui.Text(c, fmt.Sprintf(core.Msg(c, "pagination.per_page"), *pageSize)).SingleLine() ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted) }) - sel.Popup(func(panel *ui.Element) { + sel.Popup(func(panel ui.Element) { core.OverlayStyle(c, panel, core.OverlayEnter(c, panel)) panel.Margin(u, 0, 0, 0).Padding(u) for _, s := range sizes { @@ -211,7 +211,7 @@ func paginationJump(c *ui.Context, pages int, set func(int)) { Radius(theme.ControlRadius).Background(k.Surface) text := ui.Local(box, "text", func() string { return "" }) bad := ui.Local(box, "bad", func() bool { return false }) - var in *ui.Element + var in ui.Element box.Children(func() { in = ui.TextInputBase(c, text).FillWidth().Label(core.Msg(c, "pagination.field")) }) diff --git a/navigation/segmented_control.go b/navigation/segmented_control.go index 24db984..b81eec5 100644 --- a/navigation/segmented_control.go +++ b/navigation/segmented_control.go @@ -28,7 +28,7 @@ type SegmentedControlOptions struct { } // SegmentedControl chooses one of segments into *selected; Changed reports a new choice. -func SegmentedControl(c *ui.Context, selected *int, segments []Segment, opts SegmentedControlOptions) *ui.Element { +func SegmentedControl(c *ui.Context, selected *int, segments []Segment, opts SegmentedControlOptions) ui.Element { if len(segments) == 0 { panic("mujica: SegmentedControl needs at least one segment") } diff --git a/navigation/steps.go b/navigation/steps.go index df70e4e..e854e7a 100644 --- a/navigation/steps.go +++ b/navigation/steps.go @@ -86,7 +86,7 @@ func Steps(c *ui.Context, current *int, steps []Step, opts StepsOptions) core.Ch func stepsItem(c *ui.Context, i, n int, s Step, st stepsState, vertical, tail, doneLine bool) bool { k := core.Tokens(c) u := core.Density(c).Unit() - var e *ui.Element + var e ui.Element if s.Clickable { e = ui.ButtonBase(c).Cursor(ui.CursorPointer) internal.Ring(e, k.Focus, theme.ControlRadius) diff --git a/navigation/tabs.go b/navigation/tabs.go index 6cbbf3d..ea7ba58 100644 --- a/navigation/tabs.go +++ b/navigation/tabs.go @@ -65,7 +65,7 @@ func Tabs(c *ui.Context, selected *int, tabs []Tab, opts TabsOptions) core.Choic } res := core.ChoiceResult{Element: root} root.Children(func() { - var strip *ui.Element + var strip ui.Element if opts.Vertical { strip = ui.Scroll(c).Shrink(0).Padding(pad) } else { @@ -87,11 +87,11 @@ func Tabs(c *ui.Context, selected *int, tabs []Tab, opts TabsOptions) core.Choic if opts.Label != "" { list.Label(opts.Label) } - built := make([]*ui.Element, len(tabs)) + built := make([]ui.Element, len(tabs)) list.Children(func() { n := 0 for i, t := range tabs { - var tab *ui.Element + var tab ui.Element if t.Disabled { tab = ui.Row(c).Shrink(0).Role(ui.RoleTab).Disabled(true) } else { @@ -121,7 +121,7 @@ func Tabs(c *ui.Context, selected *int, tabs []Tab, opts TabsOptions) core.Choic return res } -func TabsStyle(c *ui.Context, tab *ui.Element, t Tab, on, vertical bool) { +func TabsStyle(c *ui.Context, tab ui.Element, t Tab, on, vertical bool) { k := core.Tokens(c) d := core.Density(c) u := d.Unit() diff --git a/navigation/tabs_test.go b/navigation/tabs_test.go index 232eab6..1d3890c 100644 --- a/navigation/tabs_test.go +++ b/navigation/tabs_test.go @@ -186,8 +186,7 @@ func TestTabsStateColorsEase(t *testing.T) { if got := tt.Image().RGBAAt(x, y); got == hover { t.Fatal("hover color applied at once") } - time.Sleep(theme.StateDuration + 40*time.Millisecond) - tt.Frame() + tt.Advance(theme.StateDuration + 40*time.Millisecond) if got := tt.Image().RGBAAt(x, y); got != hover { t.Fatalf("hover color %v after the transition, want %v", got, hover) } diff --git a/navigation/toolbar.go b/navigation/toolbar.go index 54fd0e2..86cee4b 100644 --- a/navigation/toolbar.go +++ b/navigation/toolbar.go @@ -15,7 +15,7 @@ type ToolbarOptions struct { } // Toolbar is one Tab stop of actions; those that do not fit go to an overflow menu. -func Toolbar(c *ui.Context, opts ToolbarOptions, fn func()) *ui.Element { +func Toolbar(c *ui.Context, opts ToolbarOptions, fn func()) ui.Element { k := core.Tokens(c) tb := ui.Toolbar(c, fn).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius). MinHeight(core.Density(c).ControlHeight() + 8) @@ -34,7 +34,7 @@ type ToolbarButtonOptions struct { } // ToolbarButton is an action of a Toolbar; label names it and its overflow menu item. -func ToolbarButton(c *ui.Context, label string, opts ToolbarButtonOptions) *ui.Element { +func ToolbarButton(c *ui.Context, label string, opts ToolbarButtonOptions) ui.Element { if label == "" { panic("mujica: ToolbarButton needs a label") } @@ -70,7 +70,7 @@ func ToolbarButton(c *ui.Context, label string, opts ToolbarButtonOptions) *ui.E } // ToolbarGroup keeps related actions together; the overflow menu sets groups apart. -func ToolbarGroup(c *ui.Context, fn func()) *ui.Element { +func ToolbarGroup(c *ui.Context, fn func()) ui.Element { return ui.Row(c).AlignItems(ui.Center).Gap(core.Density(c).Unit() * 0.5).Shrink(0).Role(ui.RoleGroup).Children(fn) } diff --git a/overlay/alert_dialog.go b/overlay/alert_dialog.go index 9fc9ba4..885e45d 100644 --- a/overlay/alert_dialog.go +++ b/overlay/alert_dialog.go @@ -55,7 +55,7 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog cancelLabel = core.Msg(c, "alert_dialog.cancel") } shown, backClicked := true, false - panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) { + panel := ui.DialogBase(c, &shown, func(back, panel ui.Element) { backClicked = back.Clicked() st := ui.Local(panel, "mujica.alert", func() alertDialogState { return alertDialogState{} }) p := core.OverlayEnter(c, panel) @@ -77,7 +77,7 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog } unlocked := opts.ConfirmText == "" || st.typed == opts.ConfirmText dialogActions(c, func() { - if input.Button(c, cancelLabel, input.ButtonOptions{Variant: input.Secondary}).AutoFocus().Clicked() { + if core.AutoFocus(c, input.Button(c, cancelLabel, input.ButtonOptions{Variant: input.Secondary})).Clicked() { res.Canceled = true } if input.Button(c, confirmLabel, input.ButtonOptions{Variant: input.Danger, Disabled: !unlocked}).Clicked() { diff --git a/overlay/context_menu.go b/overlay/context_menu.go index d1d05bd..3a95cbd 100644 --- a/overlay/context_menu.go +++ b/overlay/context_menu.go @@ -13,13 +13,13 @@ type ContextMenuOptions struct { // contextMenuState is where the menu opened, relative to the target. type contextMenuState struct { - open bool - x, y float32 + open, fresh bool // fresh: opened by a press the menu has not yet seen + x, y float32 } // ContextMenu gives the container target an in-window menu at the pointer or below the focus. -func ContextMenu(c *ui.Context, target *ui.Element, opts ContextMenuOptions, build func(m *PopupMenu)) *ui.Element { - if target == nil { +func ContextMenu(c *ui.Context, target ui.Element, opts ContextMenuOptions, build func(m *PopupMenu)) ui.Element { + if !target.Valid() { panic("mujica: ContextMenu needs a target") } if build == nil { @@ -34,7 +34,7 @@ func ContextMenu(c *ui.Context, target *ui.Element, opts ContextMenuOptions, bui if ev.Kind != ui.InputPointerDown || ev.Button != 1 { return false } - st.open, st.x, st.y = true, ev.X, ev.Y + st.open, st.fresh, st.x, st.y = true, true, ev.X, ev.Y slog.Debug("mujica: context_menu open", "by", "pointer") return true }) @@ -45,10 +45,15 @@ func ContextMenu(c *ui.Context, target *ui.Element, opts ContextMenuOptions, bui if !st.open { return target } - var anchor *ui.Element + var anchor ui.Element target.Children(func() { - anchor = ui.Box(c).Key("mujica.contextmenu.anchor").Absolute().Left(st.x).Top(st.y).Size(1, 1).PassThrough() + anchor = ui.Box(c.Key("mujica.contextmenu.anchor")).Absolute().Left(st.x).Top(st.y).Size(1, 1).PassThrough() }) - PopupMenuOpen(c, anchor, &st.open, build) + panel := PopupMenuOpen(c, anchor, &st.open, build) + // The opening press fell outside the new panel; it must not dismiss it. + if st.fresh && panel.Valid() && panel.Dismissed() { + st.open = true + } + st.fresh = false return target } diff --git a/overlay/dialog.go b/overlay/dialog.go index 9ee4836..7583fa5 100644 --- a/overlay/dialog.go +++ b/overlay/dialog.go @@ -30,19 +30,19 @@ type DialogOptions struct { } // Dialog shows a modal dialog while *open is true, trapping and then restoring the focus. -func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.Element { +func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) ui.Element { if opts.Title == "" { panic("mujica: Dialog needs a Title") } site, focus := core.OverlaySite(c) if !*open { focus.Closed(c, site) - return nil + return ui.Element{} } core.OverlayLayer(c, open) k := core.Tokens(c) shown, backClicked, closed := true, false, false - panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) { + panel := ui.DialogBase(c, &shown, func(back, panel ui.Element) { backClicked = back.Clicked() p := core.OverlayEnter(c, panel) back.Background(OverlayScrim(k).Alpha(p)).Padding(16) @@ -81,7 +81,7 @@ func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.E } // DialogPanel styles a centered dialog panel at entrance progress p. -func DialogPanel(c *ui.Context, panel *ui.Element, width float32, decorated bool, p float32) { +func DialogPanel(c *ui.Context, panel ui.Element, width float32, decorated bool, p float32) { k := core.Tokens(c) u := core.Density(c).Unit() if width == 0 { diff --git a/overlay/drawer.go b/overlay/drawer.go index 54d9ca5..c1199f9 100644 --- a/overlay/drawer.go +++ b/overlay/drawer.go @@ -32,7 +32,7 @@ type DrawerOptions struct { } // Drawer shows a panel along a window edge while *open is true; Escape closes it. -func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.Element { +func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) ui.Element { if opts.Title == "" { panic("mujica: Drawer needs a Title") } @@ -45,12 +45,12 @@ func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.E site, focus := core.OverlaySite(c) if !*open { focus.Closed(c, site) - return nil + return ui.Element{} } k := core.Tokens(c) size := drawerSize(c, opts) closed := false - body := func(panel *ui.Element, p float32) { + body := func(panel ui.Element, p float32) { drawerPanel(c, panel, opts.Side, size, p) panel.Label(opts.Title) closed = dialogHeader(c, opts.Title, false, true, "drawer.close") @@ -61,7 +61,7 @@ func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.E dialogActions(c, opts.Actions) } } - var panel *ui.Element + var panel ui.Element escaped := false if opts.NonModal { ui.Overlay(c, func() { @@ -74,7 +74,7 @@ func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.E } else { core.OverlayLayer(c, open) shown := true - panel = ui.DialogBase(c, &shown, func(back, panel *ui.Element) { + panel = ui.DialogBase(c, &shown, func(back, panel ui.Element) { p := core.OverlayEnter(c, panel) back.Background(OverlayScrim(k).Alpha(p)) drawerBackdrop(back, opts.Side) @@ -107,7 +107,7 @@ func drawerSize(c *ui.Context, opts DrawerOptions) float32 { func drawerHorizontal(s DrawerSide) bool { return s == DrawerLeft || s == DrawerRight } // drawerBackdrop lines the panel up against its edge inside a modal backdrop. -func drawerBackdrop(back *ui.Element, s DrawerSide) { +func drawerBackdrop(back ui.Element, s DrawerSide) { if drawerHorizontal(s) { back.Row() } @@ -118,7 +118,7 @@ func drawerBackdrop(back *ui.Element, s DrawerSide) { } // drawerPlace puts a non-modal panel against its edge, shown by size. -func drawerPlace(c *ui.Context, panel *ui.Element, s DrawerSide, size float32) { +func drawerPlace(c *ui.Context, panel ui.Element, s DrawerSide, size float32) { w, h := c.Size() switch s { case DrawerRight: @@ -133,7 +133,7 @@ func drawerPlace(c *ui.Context, panel *ui.Element, s DrawerSide, size float32) { } // drawerPanel styles the panel at entrance progress p, sliding in from its edge. -func drawerPanel(c *ui.Context, panel *ui.Element, s DrawerSide, size, p float32) { +func drawerPanel(c *ui.Context, panel ui.Element, s DrawerSide, size, p float32) { k := core.Tokens(c) u := core.Density(c).Unit() off := -(1 - p) * size diff --git a/overlay/drawer_test.go b/overlay/drawer_test.go index 59e30ba..b7edcac 100644 --- a/overlay/drawer_test.go +++ b/overlay/drawer_test.go @@ -28,7 +28,7 @@ func (a *drawerApp) view(c *ui.Context) { }) if p := Drawer(c, &a.open, a.opts, func() { input.Button(c, "Apply", input.ButtonOptions{}) - }); p != nil { + }); p.Valid() { a.panel = p.Bounds() } } diff --git a/overlay/dropdown_menu.go b/overlay/dropdown_menu.go index a27d00b..5c6feac 100644 --- a/overlay/dropdown_menu.go +++ b/overlay/dropdown_menu.go @@ -16,7 +16,7 @@ type DropdownMenuOptions struct { } // DropdownMenu is a button opening an in-window menu that build fills; it returns the button. -func DropdownMenu(c *ui.Context, label string, opts DropdownMenuOptions, build func(m *PopupMenu)) *ui.Element { +func DropdownMenu(c *ui.Context, label string, opts DropdownMenuOptions, build func(m *PopupMenu)) ui.Element { if build == nil { panic("mujica: DropdownMenu needs a build function") } diff --git a/overlay/hover_card.go b/overlay/hover_card.go index 8574e16..4b5f400 100644 --- a/overlay/hover_card.go +++ b/overlay/hover_card.go @@ -28,8 +28,8 @@ type hoverCardState struct { } // HoverCard previews content by a hovered or focused anchor; nil while hidden. -func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content func()) *ui.Element { - if anchor == nil { +func HoverCard(c *ui.Context, anchor ui.Element, opts HoverCardOptions, content func()) ui.Element { + if !anchor.Valid() { panic("mujica: HoverCard needs an anchor") } if opts.OpenDelay < 0 || opts.CloseDelay < 0 { @@ -56,7 +56,7 @@ func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content st.dismissed = false } if st.dismissed { - return nil + return ui.Element{} } now := c.Now() if hot { @@ -67,7 +67,7 @@ func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content } if wait := openDelay - now.Sub(st.enterAt); wait > 0 { c.After(wait) - return nil + return ui.Element{} } slog.Debug("mujica: hover_card open", "keyboard", keyboard) st.open = true @@ -75,7 +75,7 @@ func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content } else { st.enterAt = time.Time{} if !st.open { - return nil + return ui.Element{} } if st.leaveAt.IsZero() { st.leaveAt = now @@ -85,7 +85,7 @@ func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content } else { slog.Debug("mujica: hover_card close") st.open, st.focusIn, st.leaveAt = false, false, time.Time{} - return nil + return ui.Element{} } } width := opts.Width @@ -95,7 +95,7 @@ func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content core.OverlayLayer(c, st) // A popover keeps the card in the anchor's focus order and modal scope, even inside a Dialog. shown := true - panel := ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) { + panel := ui.PopoverBase(c, anchor, &shown, func(panel ui.Element) { core.OverlayStyle(c, panel, OverlayAnchored(c, panel, b)) panel.Margin(6, 0, 0, 0).Width(PopoverWide(c, width)).Padding(core.Density(c).Unit() * 4).Gap(core.Density(c).Unit() * 2). Label(opts.Label).MaxHeight(PopoverRoom(c, b)) diff --git a/overlay/hover_card_test.go b/overlay/hover_card_test.go index 25d9cba..ebccfbb 100644 --- a/overlay/hover_card_test.go +++ b/overlay/hover_card_test.go @@ -48,8 +48,7 @@ func hoverCardShown(tt *ui.Tester) func() bool { // hoverCardSettle waits past both delays so any pending open or close has happened. func hoverCardSettle(tt *ui.Tester) { - time.Sleep(hoverCardTestDelay + 30*time.Millisecond) - tt.Frame() + tt.Advance(hoverCardTestDelay + 30*time.Millisecond) } func TestHoverCardPointerCanEnterCard(t *testing.T) { diff --git a/overlay/overlay.go b/overlay/overlay.go index adcbacd..3f8743c 100644 --- a/overlay/overlay.go +++ b/overlay/overlay.go @@ -38,7 +38,7 @@ type OverlayOptions struct { } // Overlay shows content in a layer above the window while *open is true; Escape closes it. -func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui.Element { +func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) ui.Element { if opts.Dim && !opts.Modal { panic("mujica: Overlay Dim requires Modal") } @@ -48,7 +48,7 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui site, focus := core.OverlaySite(c) if !*open { focus.Closed(c, site) - return nil + return ui.Element{} } k := core.Tokens(c) inset := opts.Inset @@ -60,7 +60,7 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui core.OverlayLayer(c, open) var backClicked bool shown := true - panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) { + panel := ui.DialogBase(c, &shown, func(back, panel ui.Element) { backClicked = back.Clicked() p := core.OverlayEnter(c, panel) back.Padding(inset).Justify(overlayAlign(fy)).AlignItems(overlayAlign(fx)) @@ -79,7 +79,7 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui } return panel } - var panel *ui.Element + var panel ui.Element ui.Overlay(c, func() { w, h := c.Size() panel = ui.Column(c).Absolute().Role(ui.RoleDialog).Label(opts.Label).Padding(core.Density(c).Unit() * 4). @@ -143,7 +143,7 @@ func OverlayScrim(k theme.Tokens) ui.Color { } // OverlayAnchored is overlayEnter, held at 0 until the anchor b has been laid out. -func OverlayAnchored(c *ui.Context, panel *ui.Element, b ui.Rect) float32 { +func OverlayAnchored(c *ui.Context, panel ui.Element, b ui.Rect) float32 { if b.W == 0 && b.H == 0 { c.AnimationFrame() return 0 diff --git a/overlay/overlay_fit_test.go b/overlay/overlay_fit_test.go index 27745ed..db9a25d 100644 --- a/overlay/overlay_fit_test.go +++ b/overlay/overlay_fit_test.go @@ -58,7 +58,7 @@ func TestOverlayPanelFitsWindow(t *testing.T) { if p := Overlay(c, &open, OverlayOptions{Modal: modal}, func() { ui.Text(c, strings.Repeat(overlayLongText, 6)) input.Button(c, "Done", input.ButtonOptions{}) - }); p != nil { + }); p.Valid() { panel = p.Bounds() } }, 600, 400) diff --git a/overlay/overlay_snapshot_test.go b/overlay/overlay_snapshot_test.go index 9407a55..8953a89 100644 --- a/overlay/overlay_snapshot_test.go +++ b/overlay/overlay_snapshot_test.go @@ -13,8 +13,7 @@ func overlayUntil(tt *ui.Tester, cond func() bool) bool { if time.Now().After(end) { return false } - time.Sleep(2 * time.Millisecond) - tt.Frame() + tt.Advance(2 * time.Millisecond) } return true } diff --git a/overlay/overlay_test.go b/overlay/overlay_test.go index 23d34dd..5d22008 100644 --- a/overlay/overlay_test.go +++ b/overlay/overlay_test.go @@ -33,7 +33,7 @@ func (a *overlayApp) view(c *ui.Context) { if input.Button(c, "Done", input.ButtonOptions{}).Clicked() { a.open = false } - }); p != nil { + }); p.Valid() { a.panel = p.Bounds() } } @@ -178,7 +178,7 @@ func overlayCases() []overlayCase { tt.Move(r.X+4, r.Y+4) } leave := func(tt *ui.Tester) { tt.Move(590, 390) } - anchor := func(c *ui.Context, open *bool) *ui.Element { + anchor := func(c *ui.Context, open *bool) ui.Element { b := input.Button(c, "Anchor", input.ButtonOptions{}) if b.Clicked() { *open = true diff --git a/overlay/popconfirm.go b/overlay/popconfirm.go index 5ef1cc3..e83aa6a 100644 --- a/overlay/popconfirm.go +++ b/overlay/popconfirm.go @@ -28,8 +28,8 @@ type PopconfirmResult struct { } // Popconfirm asks for a light confirmation by anchor; use AlertDialog for high-risk actions. -func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOptions) PopconfirmResult { - if anchor == nil { +func Popconfirm(c *ui.Context, anchor ui.Element, open *bool, opts PopconfirmOptions) PopconfirmResult { + if !anchor.Valid() { panic("mujica: Popconfirm needs an anchor") } if opts.Title == "" { @@ -54,7 +54,7 @@ func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOp } b := anchor.Bounds() shown := true - ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) { + ui.PopoverBase(c, anchor, &shown, func(panel ui.Element) { core.OverlayStyle(c, panel, OverlayAnchored(c, panel, b)) panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 3).MaxWidth(PopoverWide(c, 320)).MaxHeight(PopoverRoom(c, b)).Role(ui.RoleAlertDialog). Label(opts.Title).Description(opts.Description) @@ -64,14 +64,14 @@ func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOp // Danger focuses Cancel, so a second Enter never runs the destructive action. cb := input.Button(c, cancel, input.ButtonOptions{Variant: input.Secondary}) if opts.Danger { - cb.AutoFocus() + core.AutoFocus(c, cb) } if cb.Clicked() { res.Canceled = true } ok := input.Button(c, confirm, input.ButtonOptions{Variant: variant}) if !opts.Danger { - ok.AutoFocus() + core.AutoFocus(c, ok) } if ok.Clicked() { res.Confirmed = true diff --git a/overlay/popover.go b/overlay/popover.go index 4744b17..ec9d636 100644 --- a/overlay/popover.go +++ b/overlay/popover.go @@ -20,12 +20,12 @@ type PopoverOptions struct { } // Popover shows content below anchor, or above it, inside the window while *open is true. -func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions, content func()) *ui.Element { - if anchor == nil { +func Popover(c *ui.Context, anchor ui.Element, open *bool, opts PopoverOptions, content func()) ui.Element { + if !anchor.Valid() { panic("mujica: Popover needs an anchor") } if !*open { - return nil + return ui.Element{} } core.OverlayLayer(c, open) u := core.Density(c).Unit() @@ -34,7 +34,7 @@ func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions, if opts.Title != "" { label = opts.Title } - panel := ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { + panel := ui.PopoverBase(c, anchor, open, func(panel ui.Element) { core.OverlayStyle(c, panel, OverlayAnchored(c, panel, b)) panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 2).MinWidth(PopoverWide(c, b.W)).MaxHeight(PopoverRoom(c, b)).Label(label) if opts.Width > 0 { diff --git a/overlay/popover_test.go b/overlay/popover_test.go index 922179f..7b4f64d 100644 --- a/overlay/popover_test.go +++ b/overlay/popover_test.go @@ -33,7 +33,7 @@ func (a *popoverApp) view(c *ui.Context) { a.applied++ a.open = false } - }); p != nil { + }); p.Valid() { a.panel = p.Bounds() } }) @@ -90,7 +90,7 @@ func TestPopoverFitsWindow(t *testing.T) { if p := Popover(c, b, &open, PopoverOptions{Width: 1000}, func() { ui.Text(c, strings.Repeat("Long row of guests. ", 200)) input.Button(c, "Bottom", input.ButtonOptions{}) - }); p != nil { + }); p.Valid() { panel = p.Bounds() } }) @@ -110,7 +110,7 @@ func TestPopoverNeedsAnchor(t *testing.T) { open := true ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) - Popover(c, nil, &open, PopoverOptions{}, nil) + Popover(c, ui.Element{}, &open, PopoverOptions{}, nil) }, 200, 200) } diff --git a/overlay/popup_menu.go b/overlay/popup_menu.go index 7745916..63b72d7 100644 --- a/overlay/popup_menu.go +++ b/overlay/popup_menu.go @@ -39,7 +39,7 @@ type popupMenuLevel struct { } type popupMenuSub struct { - item *ui.Element + item ui.Element seq int build func(m *PopupMenu) } @@ -59,7 +59,7 @@ func (m *PopupMenu) Item(label string, opts PopupMenuItemOptions) bool { // Check adds an item toggling *checked and reports whether it changed. func (m *PopupMenu) Check(label string, checked *bool, opts PopupMenuItemOptions) bool { // The base toggles as it is built, so a disabled wrapper comes first. - var base *ui.Element + var base ui.Element ui.Column(m.c).Shrink(0).Disabled(opts.Disabled).Children(func() { base = ui.CheckboxBase(m.c, checked) }) it := m.row(base, opts.Disabled, false) m.content(it, label, *checked, false) @@ -109,7 +109,7 @@ func (m *PopupMenu) Separator() { } // row styles an item element and runs the hover, focus and highlight logic. -func (m *PopupMenu) row(it *ui.Element, disabled, sub bool) *ui.Element { +func (m *PopupMenu) row(it ui.Element, disabled, sub bool) ui.Element { c := m.c k := core.Tokens(c) d := core.Density(c) @@ -123,7 +123,7 @@ func (m *PopupMenu) row(it *ui.Element, disabled, sub bool) *ui.Element { return it } if m.autofocus { - it.AutoFocus() + core.AutoFocus(c, it) m.autofocus = false } hovered := it.Hovered() @@ -146,7 +146,7 @@ func (m *PopupMenu) row(it *ui.Element, disabled, sub bool) *ui.Element { } // content lays out the check column, label and submenu chevron of an item. -func (m *PopupMenu) content(it *ui.Element, label string, checked, sub bool) { +func (m *PopupMenu) content(it ui.Element, label string, checked, sub bool) { c := m.c k := core.Tokens(c) it.Children(func() { @@ -162,14 +162,14 @@ func (m *PopupMenu) content(it *ui.Element, label string, checked, sub bool) { } // PopupMenuOpen shows the menu build fills below anchor while *open is true. -func PopupMenuOpen(c *ui.Context, anchor *ui.Element, open *bool, build func(m *PopupMenu)) *ui.Element { +func PopupMenuOpen(c *ui.Context, anchor ui.Element, open *bool, build func(m *PopupMenu)) ui.Element { if !*open { - return nil + return ui.Element{} } core.OverlayLayer(c, open) b := anchor.Bounds() root := &popupMenuRoot{} - panel := ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { + panel := ui.PopoverBase(c, anchor, open, func(panel ui.Element) { core.OverlayStyle(c, panel, OverlayAnchored(c, panel, b)) panel.Margin(4, 0, 0, 0).MinWidth(PopoverWide(c, max(b.W, 200))).MaxWidth(PopoverWide(c, 360)).MaxHeight(PopoverRoom(c, b)) popupMenuLevelBuild(c, panel, root, build, true) @@ -188,7 +188,7 @@ func PopupMenuOpen(c *ui.Context, anchor *ui.Element, open *bool, build func(m * } // popupMenuLevelBuild builds one level's items in outer, then its open submenu. -func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot, build func(m *PopupMenu), autofocus bool) { +func popupMenuLevelBuild(c *ui.Context, outer ui.Element, root *popupMenuRoot, build func(m *PopupMenu), autofocus bool) { u := core.Density(c).Unit() // The padding leaves room for the items' focus rings inside the clipping scroll. items := ui.Scroll(c).Shrink(1).Padding(u + theme.FocusGap + theme.FocusWidth).FocusGroup(ui.Vertical) @@ -204,11 +204,11 @@ func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot, } // popupMenuSubmenu builds a submenu beside its item, flipped to stay in the window. -func popupMenuSubmenu(c *ui.Context, outer *ui.Element, root *popupMenuRoot, lv *popupMenuLevel, s popupMenuSub) { +func popupMenuSubmenu(c *ui.Context, outer ui.Element, root *popupMenuRoot, lv *popupMenuLevel, s popupMenuSub) { u := core.Density(c).Unit() ob, ib := outer.Bounds(), s.item.Bounds() ww, wh := c.Size() - box := ui.Column(c).Absolute().Key(s.seq).MinWidth(min(180, ww-8)).MaxWidth(min(360, ww-8)) + box := ui.Column(c.Key(s.seq)).Absolute().MinWidth(min(180, ww-8)).MaxWidth(min(360, ww-8)) sb := box.Bounds() left, top := ib.X+ib.W+u-ob.X, ib.Y-u-ob.Y if ob.X+left+sb.W > ww-4 { @@ -232,7 +232,7 @@ func popupMenuSubmenu(c *ui.Context, outer *ui.Element, root *popupMenuRoot, lv } // popupMenuTrigger styles a menu trigger button with a trailing chevron. -func popupMenuTrigger(c *ui.Context, b *ui.Element, label string, icon *ui.SVG, open bool) { +func popupMenuTrigger(c *ui.Context, b ui.Element, label string, icon *ui.SVG, open bool) { k := core.Tokens(c) d := core.Density(c) b.MinHeight(d.ControlHeight()).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*3, 0, d.Unit()*4). diff --git a/overlay/split_button.go b/overlay/split_button.go index 1a32cf6..746b021 100644 --- a/overlay/split_button.go +++ b/overlay/split_button.go @@ -59,7 +59,7 @@ func SplitButton(c *ui.Context, label string, opts SplitButtonOptions, build fun ui.Box(c).Width(theme.BorderWidth).Background(k.OnAccent.Alpha(0.45)).Role(ui.RoleNone) } more := ui.ButtonBase(c).Role(ui.RoleMenuButton).Label(menuName).MinHeight(d.ControlHeight()).Width(theme.MinHitSize+d.Unit()). - Radius(0, theme.ControlRadius, theme.ControlRadius, 0).Tooltip(menuName) + Radius(0, theme.ControlRadius, theme.ControlRadius, 0) splitButtonLook(c, k, more, opts.Variant, *open) if opts.Variant == input.Secondary { more.BorderWidth(theme.BorderWidth, theme.BorderWidth, theme.BorderWidth, 0) @@ -69,6 +69,10 @@ func SplitButton(c *ui.Context, label string, opts SplitButtonOptions, build fun *open = true slog.Debug("mujica: split_button open") } + // A tip coming and going ahead of the open menu would re-key its items. + if !*open { + more.Tooltip(menuName) + } PopupMenuOpen(c, group, open, build) }) if clicked { @@ -78,7 +82,7 @@ func SplitButton(c *ui.Context, label string, opts SplitButtonOptions, build fun } // splitButtonLook colors one half as a Button of variant v would be. -func splitButtonLook(c *ui.Context, k theme.Tokens, b *ui.Element, v input.ButtonVariant, open bool) { +func splitButtonLook(c *ui.Context, k theme.Tokens, b ui.Element, v input.ButtonVariant, open bool) { internal.Ring(b, k.Focus, theme.ControlRadius) bg, fg, border := input.ButtonColors(k, v, b.Hovered() && !b.IsDisabled(), b.Pressed() || open) b.Background(bg).TextColor(fg).Transition(core.ColorFade) diff --git a/overlay/tooltip.go b/overlay/tooltip.go index 664a97e..0182f4e 100644 --- a/overlay/tooltip.go +++ b/overlay/tooltip.go @@ -25,8 +25,8 @@ type TooltipState struct { const tooltipMaxWidth = 240 // Tooltip shows a non-interactive tip by anchor after hover or keyboard focus rests on it for Delay. -func Tooltip(c *ui.Context, anchor *ui.Element, text string, opts TooltipOptions) *ui.Element { - if anchor == nil { +func Tooltip(c *ui.Context, anchor ui.Element, text string, opts TooltipOptions) ui.Element { + if !anchor.Valid() { panic("mujica: Tooltip needs an anchor") } if opts.Delay < 0 { diff --git a/overlay/tooltip_test.go b/overlay/tooltip_test.go index 46d9f59..744f868 100644 --- a/overlay/tooltip_test.go +++ b/overlay/tooltip_test.go @@ -66,8 +66,7 @@ func TestTooltipFocusAndEscape(t *testing.T) { if tt.HasText("Share with the court") { t.Fatal("Escape did not hide the tip") } - time.Sleep(tooltipTestDelay + 30*time.Millisecond) - tt.Frame() + tt.Advance(tooltipTestDelay + 30*time.Millisecond) if tt.HasText("Share with the court") { t.Fatal("tip came back while still focused") } diff --git a/project/assignee_picker.go b/project/assignee_picker.go index aad4e21..4071445 100644 --- a/project/assignee_picker.go +++ b/project/assignee_picker.go @@ -74,7 +74,7 @@ func AssigneePicker(c *ui.Context, ids *[]string, members []ProjectMember, opts st.shown = nil } before := *ids - var trigger *ui.Element + var trigger ui.Element row.Children(func() { if len(*ids) > 0 { items := make([]display.AvatarItem, min(3, len(*ids))) @@ -100,7 +100,7 @@ func AssigneePicker(c *ui.Context, ids *[]string, members []ProjectMember, opts } input.MultiSelectTags(c, ids, options, input.MultiSelectOptions{MaxTags: 2, Disabled: trigger.IsDisabled(), Placeholder: core.Msg(c, "assignee_picker.placeholder")}) }) - ui.PopoverBase(c, trigger, &st.open, func(panel *ui.Element) { + ui.PopoverBase(c, trigger, &st.open, func(panel ui.Element) { input.SelectPanel(c, panel) ww, wh := c.Size() panel.MinWidth(min(ww-8, trigger.Bounds().W)).MaxWidth(max(1, ww-8)).MaxHeight(max(1, wh-8)) @@ -147,7 +147,7 @@ func AssigneePicker(c *ui.Context, ids *[]string, members []ProjectMember, opts } else if st.count > 0 { mark = internal.Mixed } - var all *ui.Element + var all ui.Element ui.Row(c).MinHeight(d.ControlHeight()).Children(func() { all = input.MultiSelectCheck(c, k, &on, mark, core.Msg(c, "multi_select.all"), full && st.count == 0) }) @@ -176,7 +176,7 @@ func AssigneePicker(c *ui.Context, ids *[]string, members []ProjectMember, opts if selected { state = internal.Checked } - var cb *ui.Element + var cb ui.Element ui.Row(c).MinHeight(d.ControlHeight()).Children(func() { cb = input.MultiSelectCheck(c, k, &selected, state, o.Label, full && !selected) }) if st.focus == o.Value && !cb.IsDisabled() { cb.Focus() diff --git a/project/issue_card.go b/project/issue_card.go index a2a970a..3305567 100644 --- a/project/issue_card.go +++ b/project/issue_card.go @@ -23,7 +23,7 @@ type IssueCardOptions struct { } // IssueCard displays task fields in a selectable card. -func IssueCard(c *ui.Context, task Task, opts IssueCardOptions) *ui.Element { +func IssueCard(c *ui.Context, task Task, opts IssueCardOptions) ui.Element { projectCheckPriority(task.Priority) st := projectStatus(opts.Statuses, task.Status) k := core.Tokens(c) diff --git a/project/issue_id_badge_test.go b/project/issue_id_badge_test.go index 3289bba..98779f9 100644 --- a/project/issue_id_badge_test.go +++ b/project/issue_id_badge_test.go @@ -54,8 +54,7 @@ func TestIssueIdBadgeCopiedFades(t *testing.T) { if !tt.HasText("Copied") { t.Fatal("no Copied note") } - time.Sleep(issueCopiedFor + 50*time.Millisecond) - tt.Frame() + tt.Advance(issueCopiedFor + 50*time.Millisecond) if tt.HasText("Copied") { t.Fatal("Copied should go away") } diff --git a/project/kanban_board.go b/project/kanban_board.go index 051c1a5..382b506 100644 --- a/project/kanban_board.go +++ b/project/kanban_board.go @@ -39,7 +39,7 @@ type KanbanMove struct { // KanbanResult is the board and what the user did. type KanbanResult struct { - Element *ui.Element + Element ui.Element move *KanbanMove opened string } @@ -183,7 +183,7 @@ func kanbanColumn(c *ui.Context, st *kanbanState, ci int, col KanbanColumn, cols status := projectStatus(opts.Statuses, col.Status) over := col.WIP > 0 && len(idx) > col.WIP full := col.WIP > 0 && len(idx) >= col.WIP - box := ui.Column(c).Key(col.Status).Width(d.ControlHeight()*7.5).Shrink(0).Gap(d.Unit()*2).Padding(d.Unit()*2). + box := ui.Column(c.Key(col.Status)).Width(d.ControlHeight()*7.5).Shrink(0).Gap(d.Unit()*2).Padding(d.Unit()*2). Radius(theme.CardRadius).Background(k.Background).Border(theme.BorderWidth, k.Border).Role(ui.RoleGroup).Label(status.Name) if id, ok := ui.Drop[kanbanDrag](box); ok && !box.IsDisabled() { move(string(id), col.Status, "", "drag") diff --git a/project/kanban_board_test.go b/project/kanban_board_test.go index 4daae04..90ef3c2 100644 --- a/project/kanban_board_test.go +++ b/project/kanban_board_test.go @@ -166,8 +166,8 @@ func TestKanbanBoardForeignDrop(t *testing.T) { tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}) ui.Row(c).Height(400).Children(func() { - ui.Box(c).Key("left").Width(300).Height(400).Children(func() { KanbanBoard(c, &left, opts) }) - ui.Box(c).Key("right").Width(300).Height(400).Children(func() { KanbanBoard(c, &right, opts) }) + ui.Box(c.Key("left")).Width(300).Height(400).Children(func() { KanbanBoard(c, &left, opts) }) + ui.Box(c.Key("right")).Width(300).Height(400).Children(func() { KanbanBoard(c, &right, opts) }) }) }, 640, 420) src, _ := tt.Find("A-1 First") diff --git a/project/label_manager.go b/project/label_manager.go index 0c7adf9..c852658 100644 --- a/project/label_manager.go +++ b/project/label_manager.go @@ -81,7 +81,7 @@ func LabelManager(c *ui.Context, labels *[]TaskLabel, selected *[]string, opts L removed := -1 ui.List(c, state, len(*labels), func(i int) { l := &(*labels)[i] - ui.Row(c).Key(l.ID).MinHeight(d.ControlHeight()).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Row(c.Key(l.ID)).MinHeight(d.ControlHeight()).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { on := cache.selected[l.ID] box := ui.CheckboxBase(c, &on).Size(theme.MinHitSize, theme.MinHitSize).Center().Label(l.Name) internal.Ring(box, k.Focus, theme.ControlRadius) @@ -146,7 +146,7 @@ func labelEditRow(c *ui.Context, s *labelManagerState, l *TaskLabel, taken func( d := core.Density(c) ui.Column(c).Grow(1).Gap(d.Unit()).Children(func() { in := input.TextInput(c, &s.name, input.TextInputOptions{Label: core.Msg(c, "label_manager.name"), Error: s.problem}) - in.Input.AutoFocus() + core.AutoFocus(c, in.Input) ui.Row(c).Gap(d.Unit()).Role(ui.RoleRadioGroup).Label(core.Msg(c, "label_manager.color")).Children(func() { for i := range 6 { col := datetime.EventColor(i) diff --git a/project/milestone_progress.go b/project/milestone_progress.go index b66862a..7885679 100644 --- a/project/milestone_progress.go +++ b/project/milestone_progress.go @@ -25,7 +25,7 @@ type MilestoneProgressOptions struct { } // MilestoneProgress shows completion counts, progress and due dates. -func MilestoneProgress(c *ui.Context, m Milestone, opts MilestoneProgressOptions) *ui.Element { +func MilestoneProgress(c *ui.Context, m Milestone, opts MilestoneProgressOptions) ui.Element { core.DatetimeCheckDate("MilestoneProgress Due", m.Due) if m.Total < 0 || m.Done < 0 || m.Done > m.Total { panic(fmt.Sprintf("mujica: MilestoneProgress %d of %d is not valid", m.Done, m.Total)) diff --git a/project/pomodoro_timer.go b/project/pomodoro_timer.go index b9201f7..7c38497 100644 --- a/project/pomodoro_timer.go +++ b/project/pomodoro_timer.go @@ -64,7 +64,7 @@ type PomodoroTimerOptions struct { // PomodoroResult is the timer and what changed this frame. type PomodoroResult struct { - Element *ui.Element + Element ui.Element changed bool finished PomodoroPhase done bool diff --git a/project/priority_indicator.go b/project/priority_indicator.go index 465b046..b8468fc 100644 --- a/project/priority_indicator.go +++ b/project/priority_indicator.go @@ -23,7 +23,7 @@ func PriorityName(c *ui.Context, p Priority) string { } // PriorityIndicator displays urgency with shapes and localized text. -func PriorityIndicator(c *ui.Context, p Priority, opts PriorityIndicatorOptions) *ui.Element { +func PriorityIndicator(c *ui.Context, p Priority, opts PriorityIndicatorOptions) ui.Element { name := PriorityName(c, p) k := core.Tokens(c) d := core.Density(c) diff --git a/project/projects.go b/project/projects.go index a91b0df..f48873d 100644 --- a/project/projects.go +++ b/project/projects.go @@ -75,7 +75,7 @@ type WorkflowStatus struct { // ProjectResult exposes the element and task events. type ProjectResult struct { - Element *ui.Element + Element ui.Element changed string opened string } @@ -139,7 +139,7 @@ func projectDueText(c *ui.Context, due, today core.Date, done bool) (string, boo } // projectBar draws a named completion bar. -func projectBar(c *ui.Context, frac float64, label string) *ui.Element { +func projectBar(c *ui.Context, frac float64, label string) ui.Element { k := core.Tokens(c) return ui.Box(c).Height(6).FillWidth().Role(ui.RoleProgress).Label(label).Description(fmt.Sprintf("%d%%", int(frac*100))). Draw(func(p *ui.Painter, r ui.Rect) { diff --git a/project/status_select.go b/project/status_select.go index e822e1a..38d1117 100644 --- a/project/status_select.go +++ b/project/status_select.go @@ -41,7 +41,7 @@ func StatusSelect(c *ui.Context, value *string, statuses []WorkflowStatus, opts k := core.Tokens(c) old := *value pick := old - var s *ui.SelectParts[string] + var s ui.SelectParts[string] root := ui.Row(c).Shrink(0).Disabled(opts.Disabled) epoch := ui.Local(root, "status-epoch", func() struct { disabled bool @@ -57,7 +57,7 @@ func StatusSelect(c *ui.Context, value *string, statuses []WorkflowStatus, opts epoch.generation++ } root.Children(func() { - ui.Row(c).Key(epoch.generation).Children(func() { s = ui.SelectBase(c, &pick) }) + ui.Row(c.Key(epoch.generation)).Children(func() { s = ui.SelectBase(c, &pick) }) }) tr := s.Trigger label := opts.Label @@ -70,7 +70,7 @@ func StatusSelect(c *ui.Context, value *string, statuses []WorkflowStatus, opts ui.Text(c, cur.Name).SingleLine().Grow(1).Shrink(1).TextColor(k.Text) }) if !tr.IsDisabled() { - s.Popup(func(panel *ui.Element) { + s.Popup(func(panel ui.Element) { input.SelectPanel(c, panel) _, wh := c.Size() ui.Scroll(c).MaxHeight(min(320, wh/2)).Children(func() { diff --git a/project/subtask_list.go b/project/subtask_list.go index 253c672..897e85c 100644 --- a/project/subtask_list.go +++ b/project/subtask_list.go @@ -88,7 +88,7 @@ func SubtaskList(c *ui.Context, subs *[]Subtask, opts SubtaskListOptions) core.C removed := -1 ui.List(c, state, len(*subs), func(i int) { s := &(*subs)[i] - row := ui.Row(c).Key(s.ID).MinHeight(d.ControlHeight()).Gap(d.Unit() * 2).AlignItems(ui.Center).Role(ui.RoleListItem) + row := ui.Row(c.Key(s.ID)).MinHeight(d.ControlHeight()).Gap(d.Unit() * 2).AlignItems(ui.Center).Role(ui.RoleListItem) if id, ok := ui.Drop[subtaskDrag](row); ok { if from := slices.IndexFunc(*subs, func(o Subtask) bool { return o.ID == string(id) }); from >= 0 { move(from, i, "drag") diff --git a/project/task_detail_panel.go b/project/task_detail_panel.go index 68314bd..d2ee4b6 100644 --- a/project/task_detail_panel.go +++ b/project/task_detail_panel.go @@ -29,7 +29,7 @@ type TaskDetailPanelOptions struct { // TaskDetailResult exposes the panel, edits and close events. type TaskDetailResult struct { - Element *ui.Element + Element ui.Element changed bool closed bool } diff --git a/project/task_item.go b/project/task_item.go index 75dfcdf..27f2503 100644 --- a/project/task_item.go +++ b/project/task_item.go @@ -53,7 +53,7 @@ func TaskItem(c *ui.Context, task *Task, opts TaskItemOptions) ProjectResult { if edit.on && !row.IsDisabled() { in := input.TextInput(c, &edit.draft, input.TextInputOptions{Label: core.Msg(c, "task_item.rename")}) in.Element.Grow(1) - in.Input.AutoFocus() + core.AutoFocus(c, in.Input) switch { case in.Submitted() && strings.TrimSpace(edit.draft) != "": if t := strings.TrimSpace(edit.draft); t != task.Title { diff --git a/project/workload_view.go b/project/workload_view.go index 0ec4558..0dff1fd 100644 --- a/project/workload_view.go +++ b/project/workload_view.go @@ -30,7 +30,7 @@ type WorkloadViewOptions struct { } // WorkloadView virtualizes member loads with capacity and overload marks. -func WorkloadView(c *ui.Context, loads []Workload, opts WorkloadViewOptions) *ui.Element { +func WorkloadView(c *ui.Context, loads []Workload, opts WorkloadViewOptions) ui.Element { k := core.Tokens(c) d := core.Density(c) unit := opts.Unit