From a756948154b7c5ec83620ea8e7ba505c34822064 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sat, 3 Oct 2026 22:48:02 +0300 Subject: [PATCH 01/13] Add horizontal and two-axis scrolling with vlang/gui-style scroll modes --- README.md | 35 +++ appkit/ui_macos_darwin.v | 148 +++++++++++- appkit/ui_macos_geometry_darwin.h | 11 + appkit/ui_macos_test.v | 44 +++- .../horizontal_scroll_test.v | 39 ++++ examples/horizontal_scroll/main.v | 151 ++++++++++++ ui/ui.v | 52 +++++ ui/ui_globals_immediate.c.v | 6 + ui/ui_immediate.c.v | 144 ++++++++++-- ui/ui_scroll_immediate.c.v | 127 +++++++++- ui/ui_scroll_immediate_test.v | 179 +++++++++++++++ ui/ui_test.v | 19 ++ ui/vml.v | 3 + ui/vml_test.v | 17 ++ uikit/ui_ios.v | 27 ++- windows/native_helpers_windows.h | 99 +++++--- windows/ui_windows.v | 216 +++++++++++++----- 17 files changed, 1185 insertions(+), 132 deletions(-) create mode 100644 examples/horizontal_scroll/horizontal_scroll_test.v create mode 100644 examples/horizontal_scroll/main.v diff --git a/README.md b/README.md index 38f9220..6d4a427 100644 --- a/README.md +++ b/README.md @@ -719,6 +719,39 @@ drawn in the same text still work. `ClickCounter` and `text_word_range` cover double-click word selection, and `text_column_at_x` maps an x offset to a column given a prefix-width function. +## Scrolling + +A `Scroll` element scrolls over its children, vertically unless told otherwise. +`scroll_mode`, named after vlang/gui's setting, chooses the axes: +`vertical_only` (the default), `horizontal_only`, or `both`. The content is as +wide as its rightmost child reaches and as tall as its lowest, and whatever +overflows along an axis the mode leaves out is clipped. + +```v +ui2.scroll_with_mode('columns', frame, 0xffffff, .horizontal_only, columns) +``` + +```vml +Scroll { + id: columns + scroll_mode: horizontal_only +} +``` + +The horizontal scroller runs along the bottom edge. A trackpad or a tilt wheel +scrolls sideways directly, and Shift turns a plain wheel sideways. Scroll +elements nest: a gesture along an axis the element under the pointer does not +scroll moves the nearest enclosing one that does, so a strip of columns that +each scroll vertically still scrolls sideways with the pointer over a column. + +`scroll_offset(id)` and `scroll_horizontal_offset(id)` read the position, +`scroll_to_offset`, `scroll_to_horizontal_offset` and `scroll_to_rect` set it, +and `on_scroll` reports a change along either axis. An offset asked for before +the element exists is taken up when it is first laid out. To scroll to content +the event being handled has just added, call `refresh()` first so the element +has its new range. The iOS backend scrolls the same modes but does not expose +these functions. + ## Fonts and text sizes `TextStyle.size` is in points. Win32 and the Linux desktops resolve a point at @@ -920,6 +953,8 @@ Typed-VML ports from `v-ui` include: scrollable multiline text areas. - `v run examples/scrollview/main.v` — two independently scrollable read-only text panes with generated content. +- `v run examples/horizontal_scroll/main.v` — a column browser whose strip of + columns scrolls sideways while each column scrolls its own rows. - `v run examples/box_layout_with_textbox/main.v` — fixed and proportional box layout with an editable multiline text area. - `v run examples/files_dropped/main.v` — collect dropped files and plain text diff --git a/appkit/ui_macos_darwin.v b/appkit/ui_macos_darwin.v index 0028b68..2b32132 100644 --- a/appkit/ui_macos_darwin.v +++ b/appkit/ui_macos_darwin.v @@ -25,6 +25,8 @@ struct C.ui2_macos_rect { fn C.ui2_macos_msg_rect_rect(obj voidptr, sel voidptr, rect C.ui2_macos_rect) C.ui2_macos_rect +fn C.ui2_macos_msg_super_void_id(obj voidptr, superclass voidptr, sel voidptr, arg voidptr) + const ns_window_style_titled = u64(1) const ns_window_style_closable = u64(2) const ns_window_style_miniaturizable = u64(4) @@ -121,6 +123,8 @@ mut: control_change_ids map[u64]string // NSTextField pointer -> change event id observed map[u64]bool // clip views we already observe for scroll changes pending_scroll map[string]f64 // Scroll element id -> offset to apply once it exists + pending_scroll_x map[string]f64 // the same, for the sideways axis + scroll_forwarding map[u64]bool // NSScrollView pointer -> the gesture under way belongs to an enclosing Scroll run_config RunConfig screenshot_pending bool screenshot_captured bool @@ -149,6 +153,8 @@ const runtime_state_singleton = &RuntimeState{ textview_action_ids: map[u64]string{} scroll_ids: map[u64]string{} pending_scroll: map[string]f64{} + pending_scroll_x: map[string]f64{} + scroll_forwarding: map[u64]bool{} pointer_ids: map[u64]string{} pointer_clickable: map[u64]bool{} pointer_buttons: map[u64]bool{} @@ -342,6 +348,15 @@ pub fn scroll_offset(id string) f64 { return r.y } +// scroll_horizontal_offset returns how far a Scroll element is scrolled sideways. It +// stays zero for one whose scroll_mode leaves the horizontal axis out. +pub fn scroll_horizontal_offset(id string) f64 { + st := state() + scrollv := st.views[id] or { return 0 } + r := macos.msg_rect(scrollv, 'documentVisibleRect') + return r.x +} + // scroll_to_rect scrolls a Scroll element so the given document rect is visible. pub fn scroll_to_rect(id string, x f64, y f64, width f64, height f64) { st := state() @@ -366,20 +381,37 @@ pub fn scroll_to_offset(id string, offset f64) { apply_scroll_offset(mut st, id) } +// scroll_to_horizontal_offset is scroll_to_offset for the sideways axis, with the same +// promise: an offset asked for before the element exists is kept until it does. +pub fn scroll_to_horizontal_offset(id string, offset f64) { + mut st := state() + if id.len == 0 { + return + } + st.pending_scroll_x[id] = offset + apply_scroll_offset(mut st, id) +} + fn apply_scroll_offset(mut st RuntimeState, id string) { - offset := st.pending_scroll[id] or { return } + if id !in st.pending_scroll && id !in st.pending_scroll_x { + return + } scrollv := st.views[id] or { return } doc := macos.msg_id(scrollv, 'documentView') if native_is_nil(doc) { return } clip := macos.msg_id(scrollv, 'contentView') - height := macos.msg_rect(clip, 'bounds').height - // A rect as tall as the visible area lands its top edge at the top of the view, + visible := macos.msg_rect(clip, 'bounds') + // A rect the size of the visible area lands its corner at the corner of the view, // which is the offset that was asked for. The scroll view clamps it to the - // document, so an offset past the end settles at the end. - macos.msg_void_rect(doc, 'scrollRectToVisible:', macos.rect(0, offset, 1, height)) + // document, so an offset past the end settles at the end. An axis nothing was + // asked of keeps the position it is at. + x := st.pending_scroll_x[id] or { visible.x } + y := st.pending_scroll[id] or { visible.y } + macos.msg_void_rect(doc, 'scrollRectToVisible:', macos.rect(x, y, visible.width, visible.height)) st.pending_scroll.delete(id) + st.pending_scroll_x.delete(id) } pub fn text(id string) string { @@ -838,6 +870,11 @@ fn ensure_runtime_classes() { macos.add_method(cls, 'performDragOperation:', voidptr(ui2_perform_drag_operation), 'B@:@') macos.register_class_pair(cls) } + if macos.get_class('UI2ScrollView') == unsafe { nil } { + cls := macos.allocate_class_pair(macos.get_class('NSScrollView'), 'UI2ScrollView') + macos.add_method(cls, 'scrollWheel:', voidptr(ui2_scroll_view_scroll_wheel), 'v@:@') + macos.register_class_pair(cls) + } if macos.get_class('UI2PointerView') == unsafe { nil } { cls := macos.allocate_class_pair(macos.get_class('NSView'), 'UI2PointerView') macos.add_method(cls, 'isFlipped', voidptr(ui2_view_is_flipped), 'B@:') @@ -1112,18 +1149,25 @@ fn render_element(parent NativeView, el Element, key string, mut active map[stri } doc_key := key + '/document' active[doc_key] = true - doc_h := content_height(el.children) + 16 + doc_w, doc_h := scroll_document_size(el, macos.msg_rect(clip, 'bounds').height) mut doc := st.nodes[doc_key] or { native_nil_view() } if native_is_nil(doc) { - doc = native_new_flipped_view(native_rect(0, 0, el.frame.width, doc_h), el.box) + doc = native_new_flipped_view(native_rect(0, 0, doc_w, doc_h), el.box) st.nodes[doc_key] = doc st.node_kinds[doc_key] = .view native_set_document_view(native, doc) macos.release(doc) } else { - native_set_frame(doc, native_rect(0, 0, el.frame.width, doc_h)) + native_set_frame(doc, native_rect(0, 0, doc_w, doc_h)) native_set_box_background(doc, el.box) } + // A scroller that takes room of its own comes and goes with the width of the + // document, and a strip held to the height that shows has to follow it. + macos.msg_void_u64(doc, 'setAutoresizingMask:', if el.scroll_mode == .horizontal_only { + ns_view_height_sizable + } else { + u64(0) + }) render_children(doc, el.children, doc_key, mut active) } .button { @@ -1280,7 +1324,8 @@ fn native_create_element(el Element) NativeView { native_new_view(element_rect(el.frame), el.box, element_interactive(el)) } .scroll { - native_new_scroll(element_rect(el.frame), el.box, el.persistent_scrollbars) + native_new_scroll(element_rect(el.frame), el.box, el.persistent_scrollbars, + el.scroll_mode) } .label { native_new_label(element_rect(el.frame), el.text, el.text_style.color, el.text_style.size, el.text_style.bold, el.text_style.italic, el.text_style.underline, align_value(el.text_style.align), el.text_style.lines, el.text_style.valign, label_needs_container(el)) @@ -1326,6 +1371,7 @@ fn native_update_element(native NativeView, el Element, declared_text_changed bo .scroll { native_set_frame(native, element_rect(el.frame)) native_set_scroll_background(native, el.box) + native_set_scroll_axes(native, el.scroll_mode) native_set_scrollbar_mode(native, el.persistent_scrollbars) } .label { @@ -1529,6 +1575,7 @@ fn unregister_node(key string, native NativeView, kind Kind, text_direct bool) { } } if kind == .scroll { + st.scroll_forwarding.delete(u64(voidptr(native))) clip := macos.msg_id(native, 'contentView') clip_pointer := u64(voidptr(clip)) st.scroll_ids.delete(clip_pointer) @@ -1722,6 +1769,25 @@ fn content_height(children []Element) f64 { return h } +// scroll_document_size is the size of the view a Scroll element scrolls over. An axis +// its mode scrolls covers the content; one it leaves out is held to the visible area, +// so there is nothing along it to scroll to. visible_height is the height the scroll +// view shows, which a scroller that takes room of its own makes less than the frame. +fn scroll_document_size(el Element, visible_height f64) (f64, f64) { + height := if el.scroll_mode != .horizontal_only { + content_height(el.children) + 16 + } else if visible_height > 0 { + visible_height + } else { + el.frame.height + } + if el.scroll_mode == .vertical_only { + return el.frame.width, height + } + content_width := scroll_content_width(el.children) + return if content_width > el.frame.width { content_width } else { el.frame.width }, height +} + fn assoc_handler_key() voidptr { return voidptr(macos.sel('ui2_button_handler_assoc')) } @@ -1853,14 +1919,20 @@ fn native_new_view(frame NativeRect, box BoxStyle, interactive bool) NativeView return native } -fn native_new_scroll(frame NativeRect, box BoxStyle, persistent_scrollbars bool) NativeView { - scroll_view := macos.msg_id_rect(macos.alloc('NSScrollView'), 'initWithFrame:', appkit_rect(frame)) - macos.msg_void_bool(scroll_view, 'setHasVerticalScroller:', true) +fn native_new_scroll(frame NativeRect, box BoxStyle, persistent_scrollbars bool, mode ScrollMode) NativeView { + scroll_view := macos.msg_id_rect(macos.alloc('UI2ScrollView'), 'initWithFrame:', appkit_rect(frame)) + native_set_scroll_axes(scroll_view, mode) native_set_scrollbar_mode(scroll_view, persistent_scrollbars) native_set_scroll_background(scroll_view, box) return scroll_view } +// native_set_scroll_axes gives a scroll view a scroller for each axis its mode scrolls. +fn native_set_scroll_axes(scroll NativeView, mode ScrollMode) { + macos.msg_void_bool(scroll, 'setHasVerticalScroller:', mode != .horizontal_only) + macos.msg_void_bool(scroll, 'setHasHorizontalScroller:', mode != .vertical_only) +} + fn native_set_scrollbar_mode(scroll NativeView, persistent bool) { // NSScrollerStyleLegacy is 0 and overlay is 1. macos.msg_void_i64(scroll, 'setScrollerStyle:', if persistent { i64(0) } else { i64(1) }) @@ -2942,6 +3014,58 @@ fn ui2_bounds_changed(_self voidptr, _cmd voidptr, notification voidptr) { st.scroll_handler(id) } +const ns_event_phase_began = u64(1) +const ns_view_height_sizable = u64(16) + +// A scroll view takes every wheel event it is under, including one along an axis it +// has nothing to scroll on, which leaves a strip of columns stuck whenever the pointer +// is over a column that scrolls the other way. The view a gesture starts on therefore +// decides once, from the direction the gesture sets off in, whether an enclosing +// Scroll element is the one to move, and hands it the whole gesture if so. +@[export: 'ui2_scroll_view_scroll_wheel'] +fn ui2_scroll_view_scroll_wheel(self voidptr, _cmd voidptr, event voidptr) { + mut st := state() + scroll_view := NativeView(self) + wheel := macos.Id(event) + handle := u64(self) + // A wheel click stands alone. A trackpad gesture is decided where it begins and + // keeps that answer through the momentum that follows it. + if macos.msg_u64(wheel, 'phase') == ns_event_phase_began + || (macos.msg_u64(wheel, 'phase') == 0 && macos.msg_u64(wheel, 'momentumPhase') == 0) { + dx := macos.msg_f64(wheel, 'scrollingDeltaX') + dy := macos.msg_f64(wheel, 'scrollingDeltaY') + sideways := dx * dx > dy * dy + st.scroll_forwarding[handle] = dx * dx != dy * dy + && scroll_view_passes_axis(scroll_view, sideways) + } + if st.scroll_forwarding[handle] or { false } { + next := macos.msg_id(scroll_view, 'nextResponder') + if !native_is_nil(next) { + macos.msg_void1(next, 'scrollWheel:', wheel) + } + return + } + C.ui2_macos_msg_super_void_id(self, voidptr(macos.get_class('NSScrollView')), voidptr(macos.sel('scrollWheel:')), + event) +} + +// scroll_view_passes_axis reports whether a scroll view has nothing to scroll along +// an axis that a scroll view around it does. +fn scroll_view_passes_axis(scroll NativeView, horizontal bool) bool { + scroller := if horizontal { 'hasHorizontalScroller' } else { 'hasVerticalScroller' } + if macos.msg_bool(scroll, scroller) { + return false + } + mut parent := macos.msg_id(macos.msg_id(scroll, 'superview'), 'enclosingScrollView') + for !native_is_nil(parent) { + if macos.msg_bool(parent, scroller) { + return true + } + parent = macos.msg_id(macos.msg_id(parent, 'superview'), 'enclosingScrollView') + } + return false +} + @[export: 'ui2_window_did_resize'] fn ui2_window_did_resize(_self voidptr, _cmd voidptr, _notification voidptr) { refresh() diff --git a/appkit/ui_macos_geometry_darwin.h b/appkit/ui_macos_geometry_darwin.h index 7f8426c..f0e990e 100644 --- a/appkit/ui_macos_geometry_darwin.h +++ b/appkit/ui_macos_geometry_darwin.h @@ -21,3 +21,14 @@ static inline ui2_macos_rect ui2_macos_msg_rect_rect(void* obj, void* sel, ui2_m return ((ui2_macos_rect (*)(void*, void*, ui2_macos_rect))objc_msgSend)(obj, sel, rect); #endif } + +// A class built at runtime has no `super` to send to, so reaching the method it +// overrides means naming the class the lookup should start from. objc_super is a +// receiver followed by that class; plain pointers keep this free of ownership casts. +static inline void ui2_macos_msg_super_void_id(void* obj, void* superclass, void* sel, void* arg) { + struct { + void* receiver; + void* super_class; + } target = { obj, superclass }; + ((void (*)(void*, void*, void*))objc_msgSendSuper)(&target, sel, arg); +} diff --git a/appkit/ui_macos_test.v b/appkit/ui_macos_test.v index f2e8940..32995df 100644 --- a/appkit/ui_macos_test.v +++ b/appkit/ui_macos_test.v @@ -176,6 +176,7 @@ fn test_macos_native_style_button_keeps_appkit_bezel_and_press_state() { } fn test_macos_transparent_box_controls_disable_native_backgrounds() { + ensure_runtime_classes() pool := macos.autorelease_pool_new() defer { macos.release(pool) @@ -186,7 +187,8 @@ fn test_macos_transparent_box_controls_disable_native_backgrounds() { } button_view := native_new_button(native_rect(0, 0, 96, 40), 'Clear', BoxStyle{}, 0xffffff, 15, false, false, false, 0, '', true) - scroll_view := native_new_scroll(native_rect(0, 0, 120, 80), BoxStyle{}, false) + scroll_view := native_new_scroll(native_rect(0, 0, 120, 80), BoxStyle{}, false, + .vertical_only) toggle_view := native_new_toggle_button(toggle_button( title: 'Clear toggle' box: transparent_box @@ -224,6 +226,46 @@ fn test_macos_transparent_box_controls_disable_native_backgrounds() { assert !macos.msg_bool(text_view, 'drawsBackground') } +fn test_macos_scroll_mode_selects_scrollers_and_document_size() { + ensure_runtime_classes() + pool := macos.autorelease_pool_new() + defer { + macos.release(pool) + } + columns := scroll_with_mode('test-columns', rect(0, 0, 120, 80), 0xffffff, .horizontal_only, [ + view('', rect(0, 0, 200, 80), BoxStyle{}, []), + view('', rect(200, 0, 200, 300), BoxStyle{}, []), + ]) + scroll_view := native_new_scroll(element_rect(columns.frame), columns.box, false, + columns.scroll_mode) + defer { + macos.release(scroll_view) + } + assert macos.msg_bool(scroll_view, 'hasHorizontalScroller') + assert !macos.msg_bool(scroll_view, 'hasVerticalScroller') + + // A sideways strip is as wide as its columns and no taller than what shows. + width, height := scroll_document_size(columns, 80) + assert width == 400 + assert height == 80 + both_width, both_height := scroll_document_size(Element{ + ...columns + scroll_mode: .both + }, 80) + assert both_width == 400 + assert both_height == 316 + list_width, list_height := scroll_document_size(Element{ + ...columns + scroll_mode: .vertical_only + }, 80) + assert list_width == 120 + assert list_height == 316 + + native_set_scroll_axes(scroll_view, .both) + assert macos.msg_bool(scroll_view, 'hasVerticalScroller') + assert macos.msg_bool(scroll_view, 'hasHorizontalScroller') +} + fn test_macos_text_field_uses_native_bezel_without_layer_mask() { pool := macos.autorelease_pool_new() defer { diff --git a/examples/horizontal_scroll/horizontal_scroll_test.v b/examples/horizontal_scroll/horizontal_scroll_test.v new file mode 100644 index 0000000..ca2920a --- /dev/null +++ b/examples/horizontal_scroll/horizontal_scroll_test.v @@ -0,0 +1,39 @@ +module main + +import ui2 + +fn test_column_browser_is_a_sideways_strip_of_vertically_scrolling_columns() { + browser := &ColumnBrowser{ + selected: [3, 2] + } + root := browser_screen(browser, ui2.rect(0, 0, 720, 440)) + ui2.validate_element_tree(root) or { panic(err) } + strip := root.children.last() + assert strip.kind == .scroll + assert strip.id == 'columns' + assert strip.scroll_mode == .horizontal_only + // A column and its divider for each chosen folder, and for the one left open. + assert strip.children.len == 6 + for index in 0 .. 3 { + column := strip.children[index * 2] + assert column.kind == .scroll + assert column.scroll_mode == .vertical_only + assert column.frame.x == index * columns_column_width + assert column.frame.height == strip.frame.height + } + assert browser.content_width() == 3 * columns_column_width + assert browser.path() == '/Documents/Pictures' +} + +fn test_opening_a_row_replaces_the_columns_to_its_right() { + mut browser := ColumnBrowser{ + selected: [3, 2, 6, 1] + } + browser.open(1, 4) + assert browser.selected == [3, 4] + assert browser.column_count() == 3 + // A row the column does not have, or a column that is not open, changes nothing. + browser.open(0, 500) + browser.open(7, 0) + assert browser.selected == [3, 4] +} diff --git a/examples/horizontal_scroll/main.v b/examples/horizontal_scroll/main.v new file mode 100644 index 0000000..29f829b --- /dev/null +++ b/examples/horizontal_scroll/main.v @@ -0,0 +1,151 @@ +module main + +import ui2 + +const columns_window_width = 720 +const columns_window_height = 440 +const columns_toolbar_height = 48 +const columns_column_width = 190 +const columns_row_height = 28 +const columns_max_depth = 12 +const columns_names = ['Applications', 'Archive', 'Desktop', 'Documents', 'Downloads', 'Library', + 'Movies', 'Music', 'Pictures', 'Projects', 'Public', 'Sites', 'Templates', 'Work'] + +// ColumnBrowser is a path through a made-up folder tree: one chosen row per +// column, with the column after the last choice listing what that folder holds. +struct ColumnBrowser { +mut: + selected []int +} + +const column_browser = &ColumnBrowser{ + selected: [3, 2, 6, 1] +} + +// folder_names lists a folder's children. The tree is generated rather than +// stored, so every folder has contents and the columns can go on opening. +fn folder_names(depth int, parent int) []string { + count := 8 + (depth * 5 + parent * 3) % 12 + mut names := []string{cap: count} + for index in 0 .. count { + names << columns_names[(index + depth * 3 + parent) % columns_names.len] + } + return names +} + +fn (browser &ColumnBrowser) column_count() int { + return browser.selected.len + 1 +} + +fn (browser &ColumnBrowser) content_width() f64 { + return f64(browser.column_count() * columns_column_width) +} + +fn (browser &ColumnBrowser) path() string { + mut parts := []string{} + for depth, row in browser.selected { + parent := if depth == 0 { 0 } else { browser.selected[depth - 1] } + parts << folder_names(depth, parent)[row] + } + return '/' + parts.join('/') +} + +// open chooses a row, which drops every column to its right and opens the +// chosen folder in a new one. +fn (mut browser ColumnBrowser) open(column int, row int) { + if column < 0 || column > browser.selected.len || column >= columns_max_depth { + return + } + parent := if column == 0 { 0 } else { browser.selected[column - 1] } + if row < 0 || row >= folder_names(column, parent).len { + return + } + browser.selected = browser.selected[..column].clone() + browser.selected << row +} + +fn column_rows(browser &ColumnBrowser, column int) []ui2.Element { + parent := if column == 0 { 0 } else { browser.selected[column - 1] } + mut rows := []ui2.Element{} + for row, name in folder_names(column, parent) { + chosen := column < browser.selected.len && browser.selected[column] == row + rows << ui2.button('open:${column}:${row}', name, ui2.rect(0, row * columns_row_height, + columns_column_width - 1, columns_row_height), ui2.BoxStyle{ + bg: if chosen { u32(0x2563eb) } else { u32(0xffffff) } + }, ui2.TextStyle{ + color: if chosen { u32(0xffffff) } else { u32(0x1e293b) } + size: 13 + align: .center + }) + } + return rows +} + +fn browser_screen(browser &ColumnBrowser, bounds ui2.Rect) ui2.Element { + pane_height := bounds.height - columns_toolbar_height + mut columns := []ui2.Element{} + for column in 0 .. browser.column_count() { + x := f64(column * columns_column_width) + // Each column scrolls its own rows; the strip they sit in scrolls sideways. + columns << ui2.scroll('column_${column}', ui2.rect(x, 0, columns_column_width - 1, + pane_height), 0xffffff, column_rows(browser, column)) + columns << ui2.view('', ui2.rect(x + columns_column_width - 1, 0, 1, pane_height), + ui2.BoxStyle{ + bg: 0xe2e8f0 + }, []) + } + button_box := ui2.BoxStyle{ + bg: 0xe2e8f0 + radius: 6 + } + button_text := ui2.TextStyle{ + color: 0x1e293b + size: 13 + align: .center + } + return ui2.screen(0xf8fafc, [ + ui2.button('first', 'First', ui2.rect(12, 10, 64, 28), button_box, button_text), + ui2.button('last', 'Last', ui2.rect(84, 10, 64, 28), button_box, button_text), + ui2.label('path', browser.path(), ui2.rect(164, 0, bounds.width - 176, columns_toolbar_height), + ui2.TextStyle{ + color: 0x475569 + size: 13 + }), + ui2.scroll_with_mode('columns', ui2.rect(0, columns_toolbar_height, bounds.width, + pane_height), 0xffffff, .horizontal_only, columns), + ]) +} + +fn build_browser() ui2.Element { + return browser_screen(column_browser, ui2.bounds()) +} + +fn handle_browser_event(event string) { + mut browser := unsafe { column_browser } + match event { + 'first' { + ui2.scroll_to_horizontal_offset('columns', 0) + } + 'last' { + ui2.scroll_to_horizontal_offset('columns', browser.content_width()) + } + else { + if !event.starts_with('open:') { + return + } + parts := event.split(':') + browser.open(parts[1].int(), parts[2].int()) + // The column just opened lies past the old end of the strip, so ask for + // the far end once the tree has it. + ui2.refresh() + ui2.scroll_to_horizontal_offset('columns', browser.content_width()) + } + } +} + +fn main() { + // Asked before the window exists: the strip opens on its deepest column. + ui2.scroll_to_horizontal_offset('columns', column_browser.content_width()) + ui2.run_window('Horizontal Scroll', columns_window_width, columns_window_height, build_browser, + handle_browser_event) +} diff --git a/ui/ui.v b/ui/ui.v index 15c192e..db7f4e9 100644 --- a/ui/ui.v +++ b/ui/ui.v @@ -213,6 +213,28 @@ pub enum VAlign { bottom } +// ScrollMode names the axes a Scroll element scrolls along, after vlang/gui's +// setting of the same name. Content past the edge of an axis the mode leaves +// out is clipped, which is why vertical_only, the behaviour Scroll always had, +// stays the default. +pub enum ScrollMode { + vertical_only + horizontal_only + both +} + +// scroll_mode reads a ScrollMode from its VML spelling. +pub fn scroll_mode(value string) !ScrollMode { + return match value { + '', 'vertical_only', 'vertical' { .vertical_only } + 'horizontal_only', 'horizontal' { .horizontal_only } + 'both' { .both } + else { + return error('unknown scroll mode `${value}`') + } + } +} + pub enum Kind { screen view @@ -365,6 +387,7 @@ pub: readonly bool // text_area: selectable but not editable disable_scroll bool // text_area: hide the internal scroll view scroller persistent_scrollbars bool // scroll: keep a legacy always-visible scroller instead of the auto-fading overlay one + scroll_mode ScrollMode // scroll: the axes the content scrolls along secure bool // text_field: native password entry clickable bool // view/image: emit pointer down/up events button_behavior bool // view: emit its ordinary action when released like a button @@ -677,6 +700,35 @@ pub fn scroll_persistent(id string, frame Rect, bg u32, children []Element) Elem } } +// scroll_with_mode is scroll along the axes mode names: sideways for a strip of +// columns wider than its pane, or both ways for a canvas larger than its viewport. +// The content is as wide as its rightmost child reaches. +pub fn scroll_with_mode(id string, frame Rect, bg u32, mode ScrollMode, children []Element) Element { + return Element{ + kind: .scroll + id: id + frame: frame + box: BoxStyle{ + bg: bg + } + children: children + scroll_mode: mode + } +} + +// scroll_content_width is how far the children of a Scroll element reach to the +// right, which is the width a sideways scroll has to cover. Hidden children take +// no room. +fn scroll_content_width(children []Element) f64 { + mut width := 0.0 + for child in children { + if !child.hidden && child.frame.x + child.frame.width > width { + width = child.frame.x + child.frame.width + } + } + return width +} + pub fn label(id string, text string, frame Rect, style TextStyle) Element { return Element{ kind: .label diff --git a/ui/ui_globals_immediate.c.v b/ui/ui_globals_immediate.c.v index 81f843c..83e6f8a 100644 --- a/ui/ui_globals_immediate.c.v +++ b/ui/ui_globals_immediate.c.v @@ -28,5 +28,11 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 __global g_scroll_order = []string{} __global g_scroll_parents = map[string]string{} __global g_scrollbar_geometries = map[string]ScrollbarGeometry{} + // The sideways axis of a Scroll element keeps the same state as the vertical + // one, in maps of its own so a pane that only scrolls vertically pays nothing. + __global g_scroll_offsets_x = map[string]f64{} + __global g_scroll_content_w = map[string]f64{} + __global g_pending_scroll_x = map[string]f64{} + __global g_scrollbar_geometries_x = map[string]ScrollbarGeometry{} __global g_text_area_layouts = map[string]TextAreaLayout{} } diff --git a/ui/ui_immediate.c.v b/ui/ui_immediate.c.v index a5b0305..135d947 100644 --- a/ui/ui_immediate.c.v +++ b/ui/ui_immediate.c.v @@ -64,6 +64,9 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 long_press_fired bool scrollbar_drag bool scrollbar_grab_y f64 + // Which scroller a scrollbar drag holds: the one along the bottom when set. + scrollbar_horizontal bool + scrollbar_grab_x f64 } struct GgApp { @@ -542,7 +545,34 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 g_pending_scroll[id] = wanted } - pub fn scroll_to_rect(id string, _x f64, y f64, _width f64, height f64) { + // scroll_horizontal_offset returns how far a Scroll element is scrolled sideways. + // It stays zero for one whose scroll_mode leaves the horizontal axis out. + pub fn scroll_horizontal_offset(id string) f64 { + return g_scroll_offsets_x[id] or { 0.0 } + } + + // scroll_to_horizontal_offset is scroll_to_offset for the sideways axis, with the + // same promise: an offset asked for before the element is laid out is kept and + // clamped once its range is known. An offset past the end of an element already on + // screen is kept as well, since the frame that lays out a column added by the + // event being handled has not been drawn yet. + pub fn scroll_to_horizontal_offset(id string, offset f64) { + if id.len == 0 { + return + } + wanted := if offset < 0 { 0.0 } else { offset } + g_pending_scroll_x.delete(id) + if id in g_scroll_viewports { + maximum := scroll_maximum_x(id) + set_scroll_offset_x(id, wanted, maximum) + if wanted <= maximum { + return + } + } + g_pending_scroll_x[id] = wanted + } + + pub fn scroll_to_rect(id string, x f64, y f64, width f64, height f64) { area := g_scroll_viewports[id] or { return } current := scroll_offset(id) mut next := current @@ -552,6 +582,15 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 next = y + height - area.height } set_scroll_offset(id, next, scroll_maximum(id)) + // A pane with no sideways range clamps this to where it already is. + current_x := scroll_horizontal_offset(id) + mut next_x := current_x + if x < current_x { + next_x = x + } else if x + width > current_x + area.width { + next_x = x + width - area.width + } + set_scroll_offset_x(id, next_x, scroll_maximum_x(id)) } pub fn clipboard_has_image() bool { @@ -723,7 +762,16 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 if menu_bar_open() { return } - handle_mouse_scroll(f64(e.mouse_x), f64(e.mouse_y), f64(e.scroll_y)) + mut delta_x := f64(e.scroll_x) + mut delta_y := f64(e.scroll_y) + // Shift turns a plain wheel sideways, for a mouse with nothing + // that reports a horizontal delta of its own. + if delta_x == 0 && e.modifiers & u32(gg.Modifier.shift) != 0 { + delta_x = delta_y + delta_y = 0 + } + handle_mouse_scroll(f64(e.mouse_x), f64(e.mouse_y), delta_y) + handle_mouse_scroll_horizontal(f64(e.mouse_x), f64(e.mouse_y), delta_x) } .mouse_leave { g_tooltip.pointer_left() @@ -830,6 +878,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 if !g_touch.down { return } + previous_x := g_touch.current_x previous_y := g_touch.current_y dx := x - g_touch.start_x dy := y - g_touch.start_y @@ -852,11 +901,12 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 return } if g_touch.scrollbar_drag { - drag_scrollbar(y) + drag_scrollbar(x, y) return } if g_touch.scroll_chain.len > 0 { - apply_scroll_chain(g_touch.scroll_chain, previous_y - y) + apply_scroll_chain(g_touch.scroll_chain, previous_y - y, false) + apply_scroll_chain(g_touch.scroll_chain, previous_x - x, true) } if target.action_id.len > 0 && target.draggable { fire_event(pointer_event_id('drag', target.action_id, x, y)) @@ -873,18 +923,33 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 if id.len == 0 { return } - apply_scroll_chain(scroll_ancestor_chain(id), -delta_y * 48) + apply_scroll_chain(scroll_ancestor_chain(id), -delta_y * 48, false) } - fn set_scroll_offset(id string, requested f64, maximum f64) { + fn handle_mouse_scroll_horizontal(x f64, y f64, delta_x f64) { + if delta_x == 0 || g_open_dropdown.len > 0 { + return + } + id := scroll_hit_test(x, y) + if id.len == 0 { + return + } + apply_scroll_chain(scroll_ancestor_chain(id), -delta_x * 48, true) + } + + fn clamped_scroll_offset(requested f64, maximum f64) f64 { max_scroll := if maximum > 0 { maximum } else { 0.0 } - next := if requested < 0 { + return if requested < 0 { 0.0 } else if requested > max_scroll { max_scroll } else { requested } + } + + fn set_scroll_offset(id string, requested f64, maximum f64) { + next := clamped_scroll_offset(requested, maximum) previous := g_scroll_offsets[id] or { 0.0 } g_scroll_offsets[id] = next if next != previous && voidptr(g_scroll_handler) != unsafe { nil } { @@ -892,6 +957,15 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 } } + fn set_scroll_offset_x(id string, requested f64, maximum f64) { + next := clamped_scroll_offset(requested, maximum) + previous := g_scroll_offsets_x[id] or { 0.0 } + g_scroll_offsets_x[id] = next + if next != previous && voidptr(g_scroll_handler) != unsafe { nil } { + g_scroll_handler(id) + } + } + fn handle_touch_up(x f64, y f64) { if !g_touch.down { return @@ -1991,6 +2065,8 @@ fn page_focused_text_area(direction int) { for id in stale_scrolls { g_scroll_offsets.delete(id) g_scroll_content_h.delete(id) + g_scroll_offsets_x.delete(id) + g_scroll_content_w.delete(id) } for id in g_text_area_layouts.keys() { if id !in g_active_fields || (g_text_kinds[id] or { Kind.screen }) != .text_area { @@ -2081,18 +2157,28 @@ fn page_focused_text_area(direction int) { frame := rect(x, y, el.frame.width, el.frame.height) draw_rect(ctx, x, y, el.frame.width, el.frame.height, el.box.bg, 0) draw_box_borders(ctx, x, y, el.frame.width, el.frame.height, el.box) + // An axis the mode leaves out has no content to cover, and so no range. + scrolls_x := el.scroll_mode != .vertical_only + scrolls_y := el.scroll_mode != .horizontal_only mut content_h := 0.0 - for child in el.children { - if !child.hidden && child.frame.y + child.frame.height > content_h { - content_h = child.frame.y + child.frame.height + if scrolls_y { + for child in el.children { + if !child.hidden && child.frame.y + child.frame.height > content_h { + content_h = child.frame.y + child.frame.height + } + } + // Include the bottom inset in both the scroll range and thumb geometry. + if content_h > 0 { + content_h += 16 } } - // Include the bottom inset in both the scroll range and thumb geometry. - if content_h > 0 { - content_h += 16 - } + content_w := if scrolls_x { scroll_content_width(el.children) } else { 0.0 } scroll_y := register_scroll_view_in_parent(el.id, scroll_parent_id, frame, clip, content_h, el.enabled, - true, el.persistent_scrollbars) + scrolls_y, el.persistent_scrollbars) + beside_vertical := scrolls_y && scrollbar_geometry(frame, content_h, scroll_y, + el.persistent_scrollbars).track.height > 0 + scroll_x := register_scroll_view_width(el.id, content_w, scrolls_x, el.persistent_scrollbars, + beside_vertical) child_scroll_parent_id := if el.id.len > 0 { el.id } else { scroll_parent_id } child_clip := intersect_rect(frame, clip) for child in el.children { @@ -2101,12 +2187,25 @@ fn page_focused_text_area(direction int) { retain_culled_scroll_state(child) continue } - render_element(ctx, child, x, y - scroll_y, child_clip, child_scroll_parent_id) + if scrolls_x { + child_screen_x := child.frame.x - scroll_x + if child_screen_x + child.frame.width < 0 || child_screen_x > el.frame.width { + retain_culled_scroll_state(child) + continue + } + } + render_element(ctx, child, x - scroll_x, y - scroll_y, child_clip, child_scroll_parent_id) } if child_clip.width > 0 && child_clip.height > 0 { apply_clip(ctx, child_clip) - draw_scrollbar(ctx, x, y, el.frame.width, el.frame.height, content_h, scroll_y, - el.persistent_scrollbars) + if scrolls_y { + draw_scrollbar(ctx, x, y, el.frame.width, el.frame.height, content_h, scroll_y, + el.persistent_scrollbars) + } + if scrolls_x { + draw_scrollbar_x(ctx, frame, content_w, scroll_x, el.persistent_scrollbars, + beside_vertical) + } } apply_clip(ctx, clip) } @@ -2946,6 +3045,15 @@ fn page_focused_text_area(direction int) { draw_rect(ctx, bar.thumb.x, bar.thumb.y, bar.thumb.width, bar.thumb.height, 0xcbd5e1, 2.5) } + fn draw_scrollbar_x(ctx &gg.Context, frame Rect, content_width f64, offset f64, persistent bool, beside_vertical bool) { + bar := scrollbar_geometry_x(frame, content_width, offset, persistent, beside_vertical) + if bar.track.width <= 0 || bar.track.height <= 0 { + return + } + draw_rect(ctx, bar.track.x, bar.track.y, bar.track.width, bar.track.height, 0xf1f5f9, 2.5) + draw_rect(ctx, bar.thumb.x, bar.thumb.y, bar.thumb.width, bar.thumb.height, 0xcbd5e1, 2.5) + } + // text_font_file resolves a declared family to the file fontstash has to // load. gg reads TextCfg.family as a path and, when it cannot read one, // returns without touching the font state at all, leaving the string drawn diff --git a/ui/ui_scroll_immediate.c.v b/ui/ui_scroll_immediate.c.v index d647998..289a06c 100644 --- a/ui/ui_scroll_immediate.c.v +++ b/ui/ui_scroll_immediate.c.v @@ -21,6 +21,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 g_scroll_order = []string{} g_scroll_parents = map[string]string{} g_scrollbar_geometries = map[string]ScrollbarGeometry{} + g_scrollbar_geometries_x = map[string]ScrollbarGeometry{} } fn scroll_maximum(id string) f64 { @@ -32,6 +33,15 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 return if content_height > frame.height { content_height - frame.height } else { 0.0 } } + fn scroll_maximum_x(id string) f64 { + frame := g_scroll_viewports[id] or { return 0.0 } + if frame.width <= 0 || frame.height <= 0 { + return 0.0 + } + content_width := g_scroll_content_w[id] or { 0.0 } + return if content_width > frame.width { content_width - frame.width } else { 0.0 } + } + fn register_scroll_view(id string, frame Rect, clip Rect, content_height f64, enabled bool, show_scrollbar bool, persistent bool) f64 { return register_scroll_view_in_parent(id, '', frame, clip, content_height, enabled, show_scrollbar, persistent) @@ -70,6 +80,30 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 return offset } + // register_scroll_view_width gives a registered pane its sideways range. It + // follows register_scroll_view_in_parent, which is what makes the viewport known, + // and a pane that does not scroll sideways passes no width, so a mode changed + // between rebuilds leaves no offset behind. + fn register_scroll_view_width(id string, content_width f64, show_scrollbar bool, persistent bool, beside_vertical bool) f64 { + if id.len == 0 { + return 0.0 + } + frame := g_scroll_viewports[id] or { return 0.0 } + g_scroll_content_w[id] = content_width + mut requested := scroll_horizontal_offset(id) + if pending := g_pending_scroll_x[id] { + requested = pending + g_pending_scroll_x.delete(id) + } + set_scroll_offset_x(id, requested, scroll_maximum_x(id)) + offset := scroll_horizontal_offset(id) + if show_scrollbar && id in g_scroll_areas { + g_scrollbar_geometries_x[id] = scrollbar_geometry_x(frame, content_width, offset, + persistent, beside_vertical) + } + return offset + } + // Rendering skips subtrees outside a scroll viewport, but those elements are // still mounted. Keep their scroll-backed state active so unmount cleanup // does not discard positions that must be restored when they re-enter view. @@ -104,7 +138,8 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 area := g_scroll_areas[id] or { continue } // A fitted child has nowhere to scroll. Let its scrollable parent // receive the wheel or drag instead of trapping the gesture here. - if scroll_maximum(id) > 0 && scroll_rect_contains(area, x, y) { + if (scroll_maximum(id) > 0 || scroll_maximum_x(id) > 0) + && scroll_rect_contains(area, x, y) { return id } } @@ -127,7 +162,9 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 // Apply a scroll delta to the innermost available pane first, then pass any // distance left at its boundary to each available ancestor. Touch input // captures this chain on pointer-down so frame culling cannot sever it. - fn apply_scroll_chain(chain []string, delta f64) { + // Each axis walks the chain on its own, so a sideways strip inside a vertical + // list hands the list every vertical distance it cannot use itself. + fn apply_scroll_chain(chain []string, delta f64, horizontal bool) { mut remaining := delta for id in chain { if math.abs(remaining) < 0.000001 { @@ -136,9 +173,40 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 if id !in g_scroll_areas { continue } - before := scroll_offset(id) - set_scroll_offset(id, before + remaining, scroll_maximum(id)) - remaining -= scroll_offset(id) - before + if horizontal { + before := scroll_horizontal_offset(id) + set_scroll_offset_x(id, before + remaining, scroll_maximum_x(id)) + remaining -= scroll_horizontal_offset(id) - before + } else { + before := scroll_offset(id) + set_scroll_offset(id, before + remaining, scroll_maximum(id)) + remaining -= scroll_offset(id) - before + } + } + } + + // scrollbar_geometry_x is the scroller along the bottom edge. Beside a vertical + // scroller it stops short of the corner the two would otherwise share. + fn scrollbar_geometry_x(frame Rect, content_width f64, offset f64, persistent bool, beside_vertical bool) ScrollbarGeometry { + if frame.width < 16 || frame.height < 12 { + return ScrollbarGeometry{} + } + maximum := if content_width > frame.width { content_width - frame.width } else { 0.0 } + if !persistent && maximum <= 0 { + return ScrollbarGeometry{} + } + corner := if beside_vertical { 9.0 } else { 0.0 } + track := rect(frame.x + 4, frame.y + frame.height - 9, frame.width - 8 - corner, 5) + if track.width <= 0 { + return ScrollbarGeometry{} + } + content := if content_width > frame.width { content_width } else { frame.width } + thumb_width := math.min(track.width, math.max(28.0, track.width * frame.width / content)) + progress := if maximum > 0 { math.max(0.0, math.min(offset, maximum)) / maximum } else { 0.0 } + return ScrollbarGeometry{ + track: track + thumb: rect(track.x + (track.width - thumb_width) * progress, track.y, thumb_width, + track.height) } } @@ -162,7 +230,19 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 } fn begin_scrollbar_drag(x f64, y f64) bool { - id := g_touch.scroll_id + // A pane's scrollers are drawn over its children, so where a column reaches + // under the scroller of the strip it sits in, the strip's is the one the + // pointer is on. Try the panes from the outside in. + for index := g_touch.scroll_chain.len - 1; index >= 0; index-- { + id := g_touch.scroll_chain[index] + if begin_horizontal_scrollbar_drag(id, x, y) || begin_vertical_scrollbar_drag(id, x, y) { + return true + } + } + return false + } + + fn begin_vertical_scrollbar_drag(id string, x f64, y f64) bool { bar := g_scrollbar_geometries[id] or { return false } // Give the narrow drawn track a slightly wider pointer target. target := rect(bar.track.x - 3, bar.track.y, 12, bar.track.height) @@ -170,18 +250,49 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 || bar.track.height <= bar.thumb.height { return false } + g_touch.scroll_id = id g_touch.scrollbar_drag = true if y >= bar.thumb.y && y < bar.thumb.y + bar.thumb.height { g_touch.scrollbar_grab_y = y - bar.thumb.y } else { g_touch.scrollbar_grab_y = bar.thumb.height / 2 - drag_scrollbar(y) + drag_scrollbar(x, y) } return true } - fn drag_scrollbar(y f64) { + fn begin_horizontal_scrollbar_drag(id string, x f64, y f64) bool { + bar := g_scrollbar_geometries_x[id] or { return false } + target := rect(bar.track.x, bar.track.y - 3, bar.track.width, 12) + if !scroll_rect_contains(target, x, y) || scroll_maximum_x(id) <= 0 + || bar.track.width <= bar.thumb.width { + return false + } + g_touch.scroll_id = id + g_touch.scrollbar_drag = true + g_touch.scrollbar_horizontal = true + if x >= bar.thumb.x && x < bar.thumb.x + bar.thumb.width { + g_touch.scrollbar_grab_x = x - bar.thumb.x + } else { + g_touch.scrollbar_grab_x = bar.thumb.width / 2 + drag_scrollbar(x, y) + } + return true + } + + fn drag_scrollbar(x f64, y f64) { id := g_touch.scroll_id + if g_touch.scrollbar_horizontal { + bar := g_scrollbar_geometries_x[id] or { return } + travel := bar.track.width - bar.thumb.width + if travel <= 0 { + return + } + maximum := scroll_maximum_x(id) + set_scroll_offset_x(id, (x - bar.track.x - g_touch.scrollbar_grab_x) / travel * maximum, + maximum) + return + } bar := g_scrollbar_geometries[id] or { return } travel := bar.track.height - bar.thumb.height if travel <= 0 { diff --git a/ui/ui_scroll_immediate_test.v b/ui/ui_scroll_immediate_test.v index b04ae30..59b09f0 100644 --- a/ui/ui_scroll_immediate_test.v +++ b/ui/ui_scroll_immediate_test.v @@ -11,6 +11,9 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 g_scroll_offsets = map[string]f64{} g_pending_scroll = map[string]f64{} g_scroll_content_h = map[string]f64{} + g_scroll_offsets_x = map[string]f64{} + g_pending_scroll_x = map[string]f64{} + g_scroll_content_w = map[string]f64{} g_active_scrolls = map[string]bool{} g_active_fields = map[string]bool{} g_text_values = map[string]string{} @@ -450,6 +453,182 @@ fn test_text_area_wraps_words_and_preserves_explicit_blank_lines() { // Registering learns the real range, so the stored request is brought inside it. assert scroll_offset('notes') == 300 } + + // A pane a hundred wide over four hundred of columns, with no vertical axis. + fn horizontal_scroll_test_pane() { + frame := rect(0, 0, 100, 100) + register_scroll_view('columns', frame, frame, 0, true, false, false) + register_scroll_view_width('columns', 400, true, false, false) + } + + fn test_horizontal_wheel_scrolls_a_sideways_pane_and_clamps_at_both_ends() { + reset_scroll_test_state() + horizontal_scroll_test_pane() + on_scroll(scroll_test_changed) + assert scroll_maximum('columns') == 0 + assert scroll_maximum_x('columns') == 300 + assert scroll_hit_test(50, 50) == 'columns' + handle_mouse_scroll_horizontal(50, 50, -1) + assert scroll_horizontal_offset('columns') == 48 + // A plain wheel finds no vertical range here to move. + handle_mouse_scroll(50, 50, -1) + assert scroll_offset('columns') == 0 + assert scroll_horizontal_offset('columns') == 48 + handle_mouse_scroll_horizontal(50, 50, -1000) + assert scroll_horizontal_offset('columns') == 300 + handle_mouse_scroll_horizontal(50, 50, -1000) + handle_mouse_scroll_horizontal(50, 50, 1000) + assert scroll_horizontal_offset('columns') == 0 + assert scroll_test_events == ['columns', 'columns', 'columns'] + } + + fn test_sideways_strip_leaves_vertical_distance_to_its_parent() { + reset_scroll_test_state() + clip := rect(0, 0, 300, 300) + register_scroll_view('list', clip, clip, 1200, true, true, false) + register_scroll_view_in_parent('strip', 'list', rect(20, 20, 100, 100), clip, + 0, true, false, false) + register_scroll_view_width('strip', 500, true, false, false) + handle_mouse_scroll(50, 50, -1) + assert scroll_offset('list') == 48 + assert scroll_offset('strip') == 0 + handle_mouse_scroll_horizontal(50, 50, -1) + assert scroll_horizontal_offset('strip') == 48 + assert scroll_horizontal_offset('list') == 0 + // A drag moves each axis in the pane that has room for it. + handle_touch_down(50, 80) + handle_touch_move(20, 50) + handle_touch_up(20, 50) + assert scroll_horizontal_offset('strip') == 78 + assert scroll_offset('list') == 78 + assert scroll_offset('strip') == 0 + } + + fn test_horizontal_scrollbar_geometry_runs_along_the_bottom_edge() { + frame := rect(0, 0, 100, 100) + left := scrollbar_geometry_x(frame, 1000, 0, false, false) + assert left.track == rect(4, 91, 92, 5) + assert left.thumb == rect(4, 91, 28, 5) + right := scrollbar_geometry_x(frame, 1000, 900, false, false) + assert right.thumb.x + right.thumb.width == right.track.x + right.track.width + // Beside a vertical scroller the track leaves the corner they would share. + assert scrollbar_geometry_x(frame, 1000, 0, false, true).track == rect(4, 91, 83, 5) + assert scrollbar_geometry_x(frame, 50, 0, false, false) == ScrollbarGeometry{} + persistent := scrollbar_geometry_x(frame, 50, 0, true, false) + assert persistent.track == persistent.thumb + assert scrollbar_geometry_x(rect(0, 0, 5, 5), 1000, 0, true, false) == ScrollbarGeometry{} + } + + fn test_horizontal_scrollbar_thumb_drag_and_track_click_reach_the_end() { + reset_scroll_test_state() + horizontal_scroll_test_pane() + handle_touch_down(10, 94) + assert g_touch.scrollbar_drag + assert g_touch.scrollbar_horizontal + handle_touch_move(74, 94) + handle_touch_up(74, 94) + assert scroll_horizontal_offset('columns') == 300 + assert scroll_offset('columns') == 0 + set_scroll_offset_x('columns', 0, 300) + reset_scroll_frame() + horizontal_scroll_test_pane() + handle_touch_down(90, 94) + handle_touch_up(90, 94) + assert scroll_horizontal_offset('columns') == 300 + } + + fn test_strip_scrollbar_is_reachable_over_a_column_that_scrolls_beneath_it() { + reset_scroll_test_state() + clip := rect(0, 0, 300, 100) + register_scroll_view('columns', clip, clip, 0, true, false, false) + register_scroll_view_width('columns', 900, true, false, false) + register_scroll_view_in_parent('column', 'columns', rect(0, 0, 150, 100), clip, + 400, true, true, false) + // The press lands on the column, whose strip draws its scroller over it. + handle_touch_down(20, 94) + assert g_touch.scroll_chain == ['column', 'columns'] + assert g_touch.scrollbar_drag + assert g_touch.scrollbar_horizontal + assert g_touch.scroll_id == 'columns' + handle_touch_move(120, 94) + handle_touch_up(120, 94) + assert scroll_horizontal_offset('columns') > 300 + assert scroll_offset('column') == 0 + // The column's own scroller still answers further up. + handle_touch_down(144, 20) + assert g_touch.scrollbar_drag + assert !g_touch.scrollbar_horizontal + assert g_touch.scroll_id == 'column' + handle_touch_up(144, 20) + } + + fn test_scroll_to_rect_brings_a_column_into_view_sideways() { + reset_scroll_test_state() + horizontal_scroll_test_pane() + scroll_to_rect('columns', 350, 0, 50, 10) + assert scroll_horizontal_offset('columns') == 300 + scroll_to_rect('columns', 120, 0, 50, 10) + assert scroll_horizontal_offset('columns') == 120 + assert scroll_offset('columns') == 0 + } + + fn test_scroll_to_horizontal_offset_survives_until_the_view_exists() { + reset_scroll_test_state() + // Opening a folder asks for the far end before its column has been laid out. + scroll_to_horizontal_offset('columns', 10_000) + reset_scroll_frame() + prune_unmounted_state() + horizontal_scroll_test_pane() + assert scroll_horizontal_offset('columns') == 300 + scroll_to_horizontal_offset('columns', 120) + assert scroll_horizontal_offset('columns') == 120 + scroll_to_horizontal_offset('columns', -50) + assert scroll_horizontal_offset('columns') == 0 + } + + fn test_scroll_to_horizontal_offset_reaches_a_column_added_by_the_same_event() { + reset_scroll_test_state() + horizontal_scroll_test_pane() + // The event that adds a column asks for the far end before the frame that + // lays the column out. + scroll_to_horizontal_offset('columns', 10_000) + assert scroll_horizontal_offset('columns') == 300 + reset_scroll_frame() + frame := rect(0, 0, 100, 100) + register_scroll_view('columns', frame, frame, 0, true, false, false) + assert register_scroll_view_width('columns', 600, true, false, false) == 500 + // The request is spent, so a later frame leaves a position the user moved to alone. + set_scroll_offset_x('columns', 40, scroll_maximum_x('columns')) + reset_scroll_frame() + register_scroll_view('columns', frame, frame, 0, true, false, false) + assert register_scroll_view_width('columns', 600, true, false, false) == 40 + // One inside the range is taken at once and replaces what was still waiting. + scroll_to_horizontal_offset('columns', 10_000) + scroll_to_horizontal_offset('columns', 120) + assert scroll_horizontal_offset('columns') == 120 + assert 'columns' !in g_pending_scroll_x + } + + fn test_sideways_state_goes_with_the_axis_and_with_the_pane() { + reset_scroll_test_state() + horizontal_scroll_test_pane() + scroll_to_horizontal_offset('columns', 120) + // Rebuilt without a sideways axis, the pane has nothing left to be scrolled to. + reset_scroll_frame() + frame := rect(0, 0, 100, 100) + register_scroll_view('columns', frame, frame, 0, true, false, false) + assert register_scroll_view_width('columns', 0, false, false, false) == 0 + assert 'columns' !in g_scrollbar_geometries_x + assert scroll_hit_test(50, 50) == '' + reset_scroll_frame() + horizontal_scroll_test_pane() + scroll_to_horizontal_offset('columns', 120) + g_active_scrolls = map[string]bool{} + reset_scroll_frame() + prune_unmounted_state() + assert 'columns' !in g_scroll_offsets_x + assert 'columns' !in g_scroll_content_w + } } fn test_text_area_line_ranges_follow_wrapped_source_runes() { diff --git a/ui/ui_test.v b/ui/ui_test.v index 985aa72..0aba08b 100644 --- a/ui/ui_test.v +++ b/ui/ui_test.v @@ -119,3 +119,22 @@ fn test_button_behavior_only_decorates_views_and_skips_interactive_child_labels( ]) assert composite.accessibility_label == 'Card action' } + +fn test_scroll_mode_defaults_to_vertical_and_content_width_follows_the_children() { + assert scroll('list', rect(0, 0, 100, 100), 0xffffff, []).scroll_mode == .vertical_only + columns := scroll_with_mode('columns', rect(0, 0, 100, 100), 0xffffff, .horizontal_only, [ + view('', rect(0, 0, 180, 100), BoxStyle{}, []), + view('', rect(180, 0, 180, 100), BoxStyle{}, []), + Element{ + kind: .view + frame: rect(360, 0, 180, 100) + hidden: true + }, + ]) + assert columns.kind == .scroll + assert columns.scroll_mode == .horizontal_only + assert scroll_content_width(columns.children) == 360 + assert scroll_content_width([]) == 0 + assert scroll_mode('horizontal')! == .horizontal_only + assert scroll_mode('both')! == .both +} diff --git a/ui/vml.v b/ui/vml.v index e0ea484..883859f 100644 --- a/ui/vml.v +++ b/ui/vml.v @@ -878,16 +878,19 @@ fn node_to_element_base(node &VNode, frame Rect) !Element { } 'Scroll' { children := v_children(node, local)! + mode := scroll_mode(node.prop_or('scroll_mode', ''))! if node.prop_bool('persistent') { return Element{ ...scroll_persistent(node.id, frame, v_color(node, 'background', 0xffffff), children) box: v_box(node) + scroll_mode: mode } } return Element{ ...scroll(node.id, frame, v_color(node, 'background', 0xffffff), children) box: v_box(node) + scroll_mode: mode } } 'View', 'Rectangle' { diff --git a/ui/vml_test.v b/ui/vml_test.v index ff3d53a..7e7fbfa 100644 --- a/ui/vml_test.v +++ b/ui/vml_test.v @@ -497,6 +497,23 @@ fn test_parse_submit_only_text_field() { assert !el.emit_change } +fn test_vml_scroll_mode_selects_the_scrolled_axes() { + assert element_from_vml('Scroll { id: list }', rect(0, 0, 300, 200))!.scroll_mode == .vertical_only + columns := element_from_vml('Scroll { + id: columns + scroll_mode: horizontal_only + persistent: true + }', rect(0, 0, 300, 200))! + assert columns.scroll_mode == .horizontal_only + assert columns.persistent_scrollbars + assert element_from_vml('Scroll { scroll_mode: both }', rect(0, 0, 300, 200))!.scroll_mode == .both + element_from_vml('Scroll { scroll_mode: diagonal }', rect(0, 0, 300, 200)) or { + assert err.msg().contains('unknown scroll mode `diagonal`') + return + } + assert false +} + fn test_vml_dropdown_options_persistent_scroll_and_tooltip() { source := 'Scroll { id: users diff --git a/uikit/ui_ios.v b/uikit/ui_ios.v index 1d9670c..c73c57d 100644 --- a/uikit/ui_ios.v +++ b/uikit/ui_ios.v @@ -90,6 +90,7 @@ __global g_toggle_ids = map[u64]string{} __global g_toggle_views = map[u64]View{} __global g_scroll_ids = map[string]bool{} __global g_scroll_offsets = map[string]f64{} +__global g_scroll_offsets_x = map[string]f64{} __global g_view_translation_x = map[voidptr]f64{} // ── Public API ───────────────────────────────────────────────────── @@ -505,15 +506,16 @@ fn point_message0(view View, selector string) ObjcPoint { return sender(voidptr(view), voidptr(macos.sel(selector))) } -fn scroll_content_offset_y(scroll View) f64 { - return point_message0(scroll, 'contentOffset').y +fn scroll_content_offset(scroll View) ObjcPoint { + return point_message0(scroll, 'contentOffset') } -fn set_scroll_content_offset_y(scroll View, y f64) { +fn set_scroll_content_offset(scroll View, x f64, y f64) { if scroll == unsafe { nil } { return } mut offset := point_message0(scroll, 'contentOffset') + offset.x = x offset.y = y sender := unsafe { ObjcVoidPointBoolMsg(C.objc_msgSend) } sender(voidptr(scroll), voidptr(macos.sel('setContentOffset:animated:')), offset, false) @@ -955,7 +957,9 @@ fn remember(id string, native View) { fn remember_scroll_offsets() { for id, _ in g_scroll_ids { view := g_views[id] or { continue } - g_scroll_offsets[id] = scroll_content_offset_y(view) + offset := scroll_content_offset(view) + g_scroll_offsets[id] = offset.y + g_scroll_offsets_x[id] = offset.x } } @@ -993,6 +997,7 @@ fn render_root(declared Element) { } for id in removed_scrolls { g_scroll_offsets.delete(id) + g_scroll_offsets_x.delete(id) } } @@ -1282,10 +1287,20 @@ fn render_element(parent View, el Element, key string, mut active map[string]boo content_h = bottom } } - macos.msg_void_rect(native, 'setContentSize:', macos.rect(el.frame.width, content_h + 16, 0, 0)) + // An axis the mode leaves out is held to the frame, so nothing along it scrolls. + content_w := scroll_content_width(el.children) + width := if el.scroll_mode != .vertical_only && content_w > el.frame.width { + content_w + } else { + el.frame.width + } + height := if el.scroll_mode != .horizontal_only { content_h + 16 } else { el.frame.height } + macos.msg_void_rect(native, 'setContentSize:', macos.rect(width, height, 0, 0)) + macos.msg_void_bool(native, 'setAlwaysBounceVertical:', el.scroll_mode != .horizontal_only) if el.id.len > 0 { if created && el.id in g_scroll_offsets { - set_scroll_content_offset_y(native, g_scroll_offsets[el.id]) + set_scroll_content_offset(native, g_scroll_offsets_x[el.id] or { 0.0 }, + g_scroll_offsets[el.id]) } g_scroll_ids[el.id] = true } diff --git a/windows/native_helpers_windows.h b/windows/native_helpers_windows.h index 0488d14..95ee4c4 100644 --- a/windows/native_helpers_windows.h +++ b/windows/native_helpers_windows.h @@ -248,6 +248,15 @@ static inline LRESULT ui2_win_accessible_button_object(HWND hwnd, WPARAM wparam) return object; } +// The scroll bar a wheel message moves: the horizontal one for a tilt wheel, and for a +// plain wheel turned with Shift held. A window showing that bar is the one to scroll. +static inline DWORD ui2_win_wheel_scroll_style(UINT message, WPARAM wparam) { + if (message == WM_MOUSEHWHEEL || (GET_KEYSTATE_WPARAM(wparam) & MK_SHIFT) != 0) { + return WS_HSCROLL; + } + return WS_VSCROLL; +} + static LRESULT CALLBACK ui2_win_control_subclass(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam, UINT_PTR subclass_id, DWORD_PTR reference_data) { (void)subclass_id; @@ -295,14 +304,15 @@ static LRESULT CALLBACK ui2_win_control_subclass(HWND hwnd, UINT message, WPARAM if (IsWindow(hwnd)) ui2_win_refresh_text_font(hwnd); return result; } - if (message == WM_MOUSEWHEEL) { + if (message == WM_MOUSEWHEEL || message == WM_MOUSEHWHEEL) { + DWORD bar = ui2_win_wheel_scroll_style(message, wparam); HWND parent = GetParent(hwnd); wchar_t class_name[64]; while (parent != NULL) { class_name[0] = 0; GetClassNameW(parent, class_name, 64); if (wcscmp(class_name, L"UI2Container") == 0 - && (GetWindowLongPtrW(parent, GWL_STYLE) & WS_VSCROLL) != 0) { + && (GetWindowLongPtrW(parent, GWL_STYLE) & bar) != 0) { SendMessageW(parent, message, wparam, lparam); return 0; } @@ -330,15 +340,17 @@ static LRESULT CALLBACK ui2_win_window_proc(HWND hwnd, UINT message, WPARAM wpar && ui2_win_is_accessible_button(hwnd)) { InvalidateRect(hwnd, NULL, FALSE); } - if (message == WM_MOUSEWHEEL - && (GetWindowLongPtrW(hwnd, GWL_STYLE) & WS_VSCROLL) == 0) { - HWND parent = GetParent(hwnd); - while (parent != NULL) { - if ((GetWindowLongPtrW(parent, GWL_STYLE) & WS_VSCROLL) != 0) { - SendMessageW(parent, message, wparam, lparam); - return 0; + if (message == WM_MOUSEWHEEL || message == WM_MOUSEHWHEEL) { + DWORD bar = ui2_win_wheel_scroll_style(message, wparam); + if ((GetWindowLongPtrW(hwnd, GWL_STYLE) & bar) == 0) { + HWND parent = GetParent(hwnd); + while (parent != NULL) { + if ((GetWindowLongPtrW(parent, GWL_STYLE) & bar) != 0) { + SendMessageW(parent, message, wparam, lparam); + return 0; + } + parent = GetParent(parent); } - parent = GetParent(parent); } } if (message == WM_CONTEXTMENU @@ -1530,8 +1542,15 @@ static inline void ui2_win_clear_bitmap(void *hwnd_ptr) { if (old != NULL) DeleteObject(old); } -static inline int ui2_win_set_scroll(void *hwnd_ptr, int content_height, int position) { +// Each axis of a Scroll element is kept on a standard scroll bar of its window, which +// the system shows while the content is longer than what the window shows of it. +static inline int ui2_win_scroll_bar(int horizontal) { + return horizontal ? SB_HORZ : SB_VERT; +} + +static inline int ui2_win_set_scroll(void *hwnd_ptr, int content, int position, int horizontal) { HWND hwnd = (HWND)hwnd_ptr; + int bar = ui2_win_scroll_bar(horizontal); RECT rect; GetClientRect(hwnd, &rect); SCROLLINFO info; @@ -1539,22 +1558,24 @@ static inline int ui2_win_set_scroll(void *hwnd_ptr, int content_height, int pos info.cbSize = sizeof(info); info.fMask = SIF_RANGE | SIF_PAGE | SIF_POS; info.nMin = 0; - info.nMax = content_height > 0 ? content_height - 1 : 0; - info.nPage = (UINT)(rect.bottom - rect.top); + info.nMax = content > 0 ? content - 1 : 0; + info.nPage = (UINT)(horizontal ? rect.right - rect.left : rect.bottom - rect.top); info.nPos = position; - SetScrollInfo(hwnd, SB_VERT, &info, TRUE); + SetScrollInfo(hwnd, bar, &info, TRUE); info.fMask = SIF_POS; - GetScrollInfo(hwnd, SB_VERT, &info); + GetScrollInfo(hwnd, bar, &info); return info.nPos; } -static inline int ui2_win_scroll_message(void *hwnd_ptr, uintptr_t wparam) { +// The line, page and end codes of WM_HSCROLL share their values with WM_VSCROLL's. +static inline int ui2_win_scroll_message(void *hwnd_ptr, uintptr_t wparam, int horizontal) { HWND hwnd = (HWND)hwnd_ptr; + int bar = ui2_win_scroll_bar(horizontal); SCROLLINFO info; ZeroMemory(&info, sizeof(info)); info.cbSize = sizeof(info); info.fMask = SIF_ALL; - GetScrollInfo(hwnd, SB_VERT, &info); + GetScrollInfo(hwnd, bar, &info); int position = info.nPos; switch (LOWORD(wparam)) { case SB_TOP: position = info.nMin; break; @@ -1569,55 +1590,65 @@ static inline int ui2_win_scroll_message(void *hwnd_ptr, uintptr_t wparam) { } info.fMask = SIF_POS; info.nPos = position; - SetScrollInfo(hwnd, SB_VERT, &info, TRUE); - GetScrollInfo(hwnd, SB_VERT, &info); + SetScrollInfo(hwnd, bar, &info, TRUE); + GetScrollInfo(hwnd, bar, &info); return info.nPos; } -static inline int ui2_win_scroll_wheel(void *hwnd_ptr, uintptr_t wparam) { +// A wheel rolled away from the user is the positive one and scrolls back. A tilt +// wheel reports a push to the right as positive, which scrolls on. +static inline int ui2_win_scroll_wheel(void *hwnd_ptr, uintptr_t wparam, int horizontal, int tilt) { HWND hwnd = (HWND)hwnd_ptr; + int bar = ui2_win_scroll_bar(horizontal); SCROLLINFO info; ZeroMemory(&info, sizeof(info)); info.cbSize = sizeof(info); info.fMask = SIF_ALL; - GetScrollInfo(hwnd, SB_VERT, &info); - int delta = GET_WHEEL_DELTA_WPARAM(wparam); + GetScrollInfo(hwnd, bar, &info); + int distance = (GET_WHEEL_DELTA_WPARAM(wparam) / WHEEL_DELTA) * 48; info.fMask = SIF_POS; - info.nPos -= (delta / WHEEL_DELTA) * 48; - SetScrollInfo(hwnd, SB_VERT, &info, TRUE); - GetScrollInfo(hwnd, SB_VERT, &info); + info.nPos += tilt ? distance : -distance; + SetScrollInfo(hwnd, bar, &info, TRUE); + GetScrollInfo(hwnd, bar, &info); return info.nPos; } -static inline int ui2_win_scroll_to_rect(void *hwnd_ptr, int top, int bottom) { +static inline int ui2_win_wheel_is_shifted(uintptr_t wparam) { + return (GET_KEYSTATE_WPARAM(wparam) & MK_SHIFT) != 0; +} + +// Scrolls the least distance that brings the span from start to end into view. +static inline int ui2_win_scroll_to_rect(void *hwnd_ptr, int start, int end, int horizontal) { HWND hwnd = (HWND)hwnd_ptr; + int bar = ui2_win_scroll_bar(horizontal); SCROLLINFO info; ZeroMemory(&info, sizeof(info)); info.cbSize = sizeof(info); info.fMask = SIF_ALL; - GetScrollInfo(hwnd, SB_VERT, &info); + GetScrollInfo(hwnd, bar, &info); int position = info.nPos; - if (top < position) position = top; - if (bottom > position + (int)info.nPage) position = bottom - (int)info.nPage; + if (start < position) position = start; + if (end > position + (int)info.nPage) position = end - (int)info.nPage; info.fMask = SIF_POS; info.nPos = position; - SetScrollInfo(hwnd, SB_VERT, &info, TRUE); - GetScrollInfo(hwnd, SB_VERT, &info); + SetScrollInfo(hwnd, bar, &info, TRUE); + GetScrollInfo(hwnd, bar, &info); return info.nPos; } // Put a scroll bar at a position rather than scrolling the least amount that brings // a rect into view. SetScrollInfo clamps to the range the element currently has, and // reading back reports where it settled. -static inline int ui2_win_set_scroll_position(void *hwnd_ptr, int position) { +static inline int ui2_win_set_scroll_position(void *hwnd_ptr, int position, int horizontal) { HWND hwnd = (HWND)hwnd_ptr; + int bar = ui2_win_scroll_bar(horizontal); SCROLLINFO info; ZeroMemory(&info, sizeof(info)); info.cbSize = sizeof(info); info.fMask = SIF_POS; info.nPos = position; - SetScrollInfo(hwnd, SB_VERT, &info, TRUE); - GetScrollInfo(hwnd, SB_VERT, &info); + SetScrollInfo(hwnd, bar, &info, TRUE); + GetScrollInfo(hwnd, bar, &info); return info.nPos; } diff --git a/windows/ui_windows.v b/windows/ui_windows.v index 5881571..26d92d2 100644 --- a/windows/ui_windows.v +++ b/windows/ui_windows.v @@ -170,14 +170,16 @@ fn C.ui2_win_set_bitmap(hwnd voidptr, path &u16, width int, height int) voidptr fn C.ui2_win_clear_bitmap(hwnd voidptr) -fn C.ui2_win_set_scroll(hwnd voidptr, content_height int, position int) int +fn C.ui2_win_set_scroll(hwnd voidptr, content int, position int, horizontal int) int -fn C.ui2_win_scroll_message(hwnd voidptr, wparam usize) int +fn C.ui2_win_scroll_message(hwnd voidptr, wparam usize, horizontal int) int -fn C.ui2_win_scroll_wheel(hwnd voidptr, wparam usize) int +fn C.ui2_win_scroll_wheel(hwnd voidptr, wparam usize, horizontal int, tilt int) int -fn C.ui2_win_scroll_to_rect(hwnd voidptr, top int, bottom int) int -fn C.ui2_win_set_scroll_position(hwnd voidptr, position int) int +fn C.ui2_win_wheel_is_shifted(wparam usize) int + +fn C.ui2_win_scroll_to_rect(hwnd voidptr, start int, end int, horizontal int) int +fn C.ui2_win_set_scroll_position(hwnd voidptr, position int, horizontal int) int fn C.ui2_win_capture_mouse(hwnd voidptr) @@ -226,6 +228,7 @@ const win_wm_mouse_move = u32(0x0200) const win_wm_lbutton_down = u32(0x0201) const win_wm_lbutton_up = u32(0x0202) const win_wm_mouse_wheel = u32(0x020a) +const win_wm_mouse_hwheel = u32(0x020e) const win_wm_dropfiles = u32(0x0233) const win_wm_refresh = u32(0x8000 + 77) const win_wm_paint_background = u32(0x8000 + 79) @@ -298,8 +301,10 @@ mut: toggle_ids map[u64]string toggle_views map[u64]voidptr scroll_positions map[string]int + scroll_positions_x map[string]int // only the Scroll elements whose mode scrolls sideways node_label_boxed map[string]bool pending_scroll map[string]int // Scroll element id -> offset to apply when it renders + pending_scroll_x map[string]int run_config WindowsRunConfig rendering bool key_consumed bool @@ -348,8 +353,10 @@ const windows_state_singleton = &WindowsState{ toggle_ids: map[u64]string{} toggle_views: map[u64]voidptr{} scroll_positions: map[string]int{} + scroll_positions_x: map[string]int{} node_label_boxed: map[string]bool{} pending_scroll: map[string]int{} + pending_scroll_x: map[string]int{} suppress_click: map[u64]bool{} } @@ -387,7 +394,7 @@ fn windows_draws_no_background(kind Kind, box BoxStyle) bool { // // The measured rectangle is what gets remembered, so scrolling the pane it sits in // takes the label with it rather than putting it back where it was laid out. -fn windows_place_label(key string, hwnd voidptr, el Element, y_offset int) { +fn windows_place_label(key string, hwnd voidptr, el Element, x_offset int, y_offset int) { mut st := windows_state() if C.ui2_win_label_text_hwnd(hwnd) != hwnd { windows_place_held_label(hwnd, el) @@ -406,8 +413,8 @@ fn windows_place_label(key string, hwnd voidptr, el Element, y_offset int) { } } st.node_frames[key] = placed - C.ui2_win_set_widget_frame(hwnd, windows_widget_kind(el.kind), int(placed.x), int(placed.y) + - y_offset, int(placed.width), int(placed.height)) + C.ui2_win_set_widget_frame(hwnd, windows_widget_kind(el.kind), int(placed.x) + x_offset, + int(placed.y) + y_offset, int(placed.width), int(placed.height)) } // Setting a label's text has to place it again. The label sits on the rectangle the @@ -418,8 +425,10 @@ fn windows_place_label_text(id string, hwnd voidptr, value string) { key := st.view_keys[id] or { return } frame := st.label_frames[id] or { return } parent_key := st.node_parents[key] or { '' } + mut x_offset := 0 mut y_offset := 0 if (st.node_kinds[parent_key] or { Kind.view }) == .scroll { + x_offset = -(st.scroll_positions_x[parent_key] or { 0 }) y_offset = -(st.scroll_positions[parent_key] or { 0 }) } windows_place_label(key, hwnd, Element{ @@ -428,7 +437,7 @@ fn windows_place_label_text(id string, hwnd voidptr, value string) { frame: frame box: st.node_boxes[key] or { BoxStyle{} } text_style: st.node_text_styles[key] or { TextStyle{} } - }, y_offset) + }, x_offset, y_offset) } fn windows_align(align Align) int { @@ -550,10 +559,10 @@ pub fn refresh() { mut active := map[string]bool{} if root.kind == .screen { st.node_boxes[''] = root.box - windows_render_children(st.root, root.children, '', 0, mut active) + windows_render_children(st.root, root.children, '', 0, 0, mut active) } else { st.node_boxes[''] = BoxStyle{} - windows_render_element(st.root, root, reconciliation_child_key('', 0, root), '', 0, mut active) + windows_render_element(st.root, root, reconciliation_child_key('', 0, root), '', 0, 0, mut active) } windows_remove_stale(active) st.rendering = false @@ -817,6 +826,14 @@ pub fn scroll_offset(id string) f64 { return f64(st.scroll_positions[key] or { 0 }) } +// scroll_horizontal_offset returns how far a Scroll element is scrolled sideways. It +// stays zero for one whose scroll_mode leaves the horizontal axis out. +pub fn scroll_horizontal_offset(id string) f64 { + st := windows_state() + key := st.view_keys[id] or { return 0 } + return f64(st.scroll_positions_x[key] or { 0 }) +} + // scroll_to_offset puts a Scroll element at the given vertical offset. An element on // screen moves at once. One that does not exist yet has no range to clamp against, so // the request is kept and taken up the next time the element is laid out — that is @@ -831,9 +848,8 @@ pub fn scroll_to_offset(id string, offset f64) { key := st.view_keys[id] or { '' } if key.len > 0 { if hwnd := st.views[id] { - position := C.ui2_win_set_scroll_position(hwnd, wanted) - st.scroll_positions[key] = position - windows_reposition_scroll_children(key, position) + st.scroll_positions[key] = C.ui2_win_set_scroll_position(hwnd, wanted, 0) + windows_reposition_scroll_children(key) st.pending_scroll.delete(id) return } @@ -842,23 +858,50 @@ pub fn scroll_to_offset(id string, offset f64) { st.pending_scroll[id] = wanted } -pub fn scroll_to_rect(id string, _x f64, y f64, _width f64, height f64) { +// scroll_to_horizontal_offset is scroll_to_offset for the sideways axis, with the same +// promise: an offset asked for before the element exists is kept until it does. An +// element that does not scroll sideways has no horizontal bar to move. +pub fn scroll_to_horizontal_offset(id string, offset f64) { + mut st := windows_state() + if id.len == 0 { + return + } + wanted := if offset < 0 { 0 } else { int(offset) } + if (st.view_kinds[id] or { Kind.view }) == .scroll { + key := st.view_keys[id] or { '' } + if key.len > 0 { + if hwnd := st.views[id] { + if key in st.scroll_positions_x { + st.scroll_positions_x[key] = C.ui2_win_set_scroll_position(hwnd, wanted, 1) + windows_reposition_scroll_children(key) + } + st.pending_scroll_x.delete(id) + return + } + } + } + st.pending_scroll_x[id] = wanted +} + +pub fn scroll_to_rect(id string, x f64, y f64, width f64, height f64) { mut st := windows_state() key := st.view_keys[id] or { return } if (st.view_kinds[id] or { Kind.view }) != .scroll { return } hwnd := st.views[id] or { return } - position := C.ui2_win_scroll_to_rect(hwnd, int(y), int(y + height)) - st.scroll_positions[key] = position - windows_reposition_scroll_children(key, position) + st.scroll_positions[key] = C.ui2_win_scroll_to_rect(hwnd, int(y), int(y + height), 0) + if key in st.scroll_positions_x { + st.scroll_positions_x[key] = C.ui2_win_scroll_to_rect(hwnd, int(x), int(x + width), 1) + } + windows_reposition_scroll_children(key) } -fn windows_render_children(parent voidptr, children []Element, parent_key string, y_offset int, mut active map[string]bool) { +fn windows_render_children(parent voidptr, children []Element, parent_key string, x_offset int, y_offset int, mut active map[string]bool) { mut previous := voidptr(unsafe { nil }) for index, child in children { key := reconciliation_child_key(parent_key, index, child) - hwnd := windows_render_element(parent, child, key, parent_key, y_offset, mut active) + hwnd := windows_render_element(parent, child, key, parent_key, x_offset, y_offset, mut active) if hwnd != unsafe { nil } { C.ui2_win_place_after(hwnd, previous) previous = hwnd @@ -866,7 +909,7 @@ fn windows_render_children(parent voidptr, children []Element, parent_key string } } -fn windows_render_element(parent voidptr, el Element, key string, parent_key string, y_offset int, mut active map[string]bool) voidptr { +fn windows_render_element(parent voidptr, el Element, key string, parent_key string, x_offset int, y_offset int, mut active map[string]bool) voidptr { mut st := windows_state() active[key] = true structural := windows_structural_signature(el) @@ -890,7 +933,7 @@ fn windows_render_element(parent voidptr, el Element, key string, parent_key str } } if must_create { - created := windows_create_element(parent, el, y_offset) + created := windows_create_element(parent, el, x_offset, y_offset) if created == unsafe { nil } { eprintln('ui2: failed to create native Windows control `${el.id}` (${el.kind})') return hwnd @@ -947,12 +990,12 @@ fn windows_render_element(parent voidptr, el Element, key string, parent_key str ...el box: visual_box text_style: visual_text_style - }, y_offset, must_create) + }, x_offset, y_offset, must_create) if el.kind == .label { windows_place_label(key, hwnd, Element{ ...el text_style: visual_text_style - }, y_offset) + }, x_offset, y_offset) } windows_register_bindings(hwnd, el) if el.kind == .view { @@ -964,31 +1007,66 @@ fn windows_render_element(parent voidptr, el Element, key string, parent_key str } if el.children.len > 0 { if el.kind == .scroll { - content_height := windows_content_height(el.children) - mut requested := st.scroll_positions[key] or { 0 } - if el.id.len > 0 { - if pending := st.pending_scroll[el.id] { - requested = pending - st.pending_scroll.delete(el.id) - } - } - position := C.ui2_win_set_scroll(hwnd, content_height, requested) - st.scroll_positions[key] = position - windows_render_children(hwnd, el.children, key, -position, mut active) + x_position, position := windows_layout_scroll(hwnd, el, key) + windows_render_children(hwnd, el.children, key, -x_position, -position, mut active) } else { - windows_render_children(hwnd, el.children, key, 0, mut active) + windows_render_children(hwnd, el.children, key, 0, 0, mut active) } } else if el.kind == .scroll { - st.scroll_positions[key] = C.ui2_win_set_scroll(hwnd, 0, 0) + st.scroll_positions[key] = C.ui2_win_set_scroll(hwnd, 0, 0, 0) + if key in st.scroll_positions_x { + st.scroll_positions_x[key] = C.ui2_win_set_scroll(hwnd, 0, 0, 1) + } } return hwnd } -fn windows_create_element(parent voidptr, el Element, y_offset int) voidptr { +// windows_layout_scroll sets a Scroll element's bars to cover its children along the +// axes its mode scrolls, taking up a position asked for before it was laid out, and +// returns where each bar settled. The horizontal bar is left alone on an element that +// has never scrolled sideways. +fn windows_layout_scroll(hwnd voidptr, el Element, key string) (int, int) { + mut st := windows_state() + scrolls_x := el.scroll_mode != .vertical_only + content_height := if el.scroll_mode != .horizontal_only { + windows_content_height(el.children) + } else { + 0 + } + mut requested := st.scroll_positions[key] or { 0 } + mut requested_x := st.scroll_positions_x[key] or { 0 } + if el.id.len > 0 { + if pending := st.pending_scroll[el.id] { + requested = pending + st.pending_scroll.delete(el.id) + } + if pending := st.pending_scroll_x[el.id] { + requested_x = pending + st.pending_scroll_x.delete(el.id) + } + } + mut position := C.ui2_win_set_scroll(hwnd, content_height, requested, 0) + mut x_position := 0 + if scrolls_x || key in st.scroll_positions_x { + content_width := if scrolls_x { int(scroll_content_width(el.children)) } else { 0 } + x_position = C.ui2_win_set_scroll(hwnd, content_width, requested_x, 1) + // A bar that appeared or went away changed how much of the other axis shows. + position = C.ui2_win_set_scroll(hwnd, content_height, position, 0) + if scrolls_x { + st.scroll_positions_x[key] = x_position + } else { + st.scroll_positions_x.delete(key) + } + } + st.scroll_positions[key] = position + return x_position, position +} + +fn windows_create_element(parent voidptr, el Element, x_offset int, y_offset int) voidptr { wide := el.text.to_wide() boxed := label_needs_container(el) mut host := parent - mut x := int(el.frame.x) + mut x := int(el.frame.x) + x_offset mut y := int(el.frame.y) + y_offset mut container := voidptr(unsafe { nil }) if boxed { @@ -1104,9 +1182,9 @@ fn windows_button_accessibility_caption(label string) string { return label.replace('&', '&&') } -fn windows_update_element(key string, hwnd voidptr, el Element, y_offset int, created bool) { +fn windows_update_element(key string, hwnd voidptr, el Element, x_offset int, y_offset int, created bool) { mut st := windows_state() - C.ui2_win_set_widget_frame(hwnd, windows_widget_kind(el.kind), int(el.frame.x), int(el.frame.y) + y_offset, int(el.frame.width), int(el.frame.height)) + C.ui2_win_set_widget_frame(hwnd, windows_widget_kind(el.kind), int(el.frame.x) + x_offset, int(el.frame.y) + y_offset, int(el.frame.width), int(el.frame.height)) C.ui2_win_show(hwnd, windows_bool(!el.hidden)) C.ui2_win_enable(hwnd, windows_bool(el.enabled)) declared_changed := (st.node_declared_text[key] or { '' }) != el.text @@ -1436,6 +1514,7 @@ fn windows_remove_stale(active map[string]bool) { st.node_label_boxed.delete(key) st.node_text_styles.delete(key) st.scroll_positions.delete(key) + st.scroll_positions_x.delete(key) } } @@ -1453,8 +1532,10 @@ fn windows_release_all_node_resources() { } } -fn windows_reposition_scroll_children(scroll_key string, position int) { +fn windows_reposition_scroll_children(scroll_key string) { st := windows_state() + x_position := st.scroll_positions_x[scroll_key] or { 0 } + position := st.scroll_positions[scroll_key] or { 0 } for child_key, parent_key in st.node_parents { if parent_key != scroll_key { continue @@ -1462,28 +1543,50 @@ fn windows_reposition_scroll_children(scroll_key string, position int) { hwnd := st.nodes[child_key] or { continue } frame := st.node_frames[child_key] or { continue } kind := st.node_kinds[child_key] or { Kind.view } - C.ui2_win_set_widget_frame(hwnd, windows_widget_kind(kind), int(frame.x), int(frame.y) - position, int(frame.width), int(frame.height)) + C.ui2_win_set_widget_frame(hwnd, windows_widget_kind(kind), int(frame.x) - x_position, + int(frame.y) - position, int(frame.width), int(frame.height)) } C.ui2_win_invalidate(st.nodes[scroll_key] or { return }) } -fn windows_handle_scroll(hwnd voidptr, wparam usize, wheel bool) { +// How a scroll message reached a Scroll element: from one of its bars, from a wheel +// rolled back and forth, or from one tilted to the side. +enum WindowsScrollSource { + bar + wheel + tilt_wheel +} + +fn windows_handle_scroll(hwnd voidptr, wparam usize, source WindowsScrollSource, horizontal bool) { mut st := windows_state() key := st.handle_keys[windows_handle_id(hwnd)] or { return } if (st.node_kinds[key] or { Kind.view }) != .scroll { return } - old_position := st.scroll_positions[key] or { 0 } - position := if wheel { - C.ui2_win_scroll_wheel(hwnd, wparam) + // An element that does not scroll sideways has no horizontal bar to read or move. + if horizontal && key !in st.scroll_positions_x { + return + } + old_position := if horizontal { + st.scroll_positions_x[key] or { 0 } + } else { + st.scroll_positions[key] or { 0 } + } + axis := windows_bool(horizontal) + position := if source == .bar { + C.ui2_win_scroll_message(hwnd, wparam, axis) } else { - C.ui2_win_scroll_message(hwnd, wparam) + C.ui2_win_scroll_wheel(hwnd, wparam, axis, windows_bool(source == .tilt_wheel)) } if position == old_position { return } - st.scroll_positions[key] = position - windows_reposition_scroll_children(key, position) + if horizontal { + st.scroll_positions_x[key] = position + } else { + st.scroll_positions[key] = position + } + windows_reposition_scroll_children(key) id := st.scroll_ids[windows_handle_id(hwnd)] or { '' } if id.len > 0 && voidptr(st.scroll_handler) != unsafe { nil } { st.scroll_handler(id) @@ -1818,14 +1921,21 @@ fn ui2_windows_window_proc(hwnd voidptr, message u32, wparam usize, lparam isize return 0 } } - if message == win_wm_hscroll { + // A control's own bar is the control's to handle; one with no control behind + // it is the standard bar of a Scroll element. + if message == win_wm_hscroll && child != unsafe { nil } { return C.ui2_win_default_proc(hwnd, message, wparam, lparam) } - windows_handle_scroll(hwnd, wparam, false) + windows_handle_scroll(hwnd, wparam, .bar, message == win_wm_hscroll) return 0 } win_wm_mouse_wheel { - windows_handle_scroll(hwnd, wparam, true) + // Shift turns a plain wheel sideways. + windows_handle_scroll(hwnd, wparam, .wheel, C.ui2_win_wheel_is_shifted(wparam) != 0) + return 0 + } + win_wm_mouse_hwheel { + windows_handle_scroll(hwnd, wparam, .tilt_wheel, true) return 0 } win_wm_key_down { From e92c4148d732aed97507b910c2902f30a6cf927f Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sat, 3 Oct 2026 23:50:35 +0300 Subject: [PATCH 02/13] Pass scroll gestures a nested pane cannot use to the pane around it --- appkit/ui_macos_darwin.v | 31 ++++++++++++++++++++++----- appkit/ui_macos_test.v | 35 +++++++++++++++++++++++++++++++ windows/native_helpers_windows.h | 34 +++++++++++++++++++----------- windows/ui_windows.v | 36 +++++++++++++++++++++----------- 4 files changed, 107 insertions(+), 29 deletions(-) diff --git a/appkit/ui_macos_darwin.v b/appkit/ui_macos_darwin.v index 2b32132..47b285f 100644 --- a/appkit/ui_macos_darwin.v +++ b/appkit/ui_macos_darwin.v @@ -3049,16 +3049,37 @@ fn ui2_scroll_view_scroll_wheel(self voidptr, _cmd voidptr, event voidptr) { event) } -// scroll_view_passes_axis reports whether a scroll view has nothing to scroll along -// an axis that a scroll view around it does. -fn scroll_view_passes_axis(scroll NativeView, horizontal bool) bool { +// scroll_view_scrolls_axis reports whether a scroll view has anywhere to go along an +// axis: its mode scrolls that axis, and its document is longer than what shows of it. +fn scroll_view_scrolls_axis(scroll NativeView, horizontal bool) bool { scroller := if horizontal { 'hasHorizontalScroller' } else { 'hasVerticalScroller' } - if macos.msg_bool(scroll, scroller) { + if !macos.msg_bool(scroll, scroller) { + return false + } + document_view := macos.msg_id(scroll, 'documentView') + if native_is_nil(document_view) { + return false + } + document := macos.msg_rect(document_view, 'frame') + visible := macos.msg_rect(macos.msg_id(scroll, 'contentView'), 'bounds') + return if horizontal { + document.width > visible.width + 0.5 + } else { + document.height > visible.height + 0.5 + } +} + +// scroll_view_passes_axis reports whether a scroll view has nowhere to go along an +// axis that a scroll view around it does. Content that fits counts as nowhere to go, +// the same as an axis the mode leaves out, so a pane that happens not to overflow +// does not hold on to a gesture the pane around it could use. +fn scroll_view_passes_axis(scroll NativeView, horizontal bool) bool { + if scroll_view_scrolls_axis(scroll, horizontal) { return false } mut parent := macos.msg_id(macos.msg_id(scroll, 'superview'), 'enclosingScrollView') for !native_is_nil(parent) { - if macos.msg_bool(parent, scroller) { + if scroll_view_scrolls_axis(parent, horizontal) { return true } parent = macos.msg_id(macos.msg_id(parent, 'superview'), 'enclosingScrollView') diff --git a/appkit/ui_macos_test.v b/appkit/ui_macos_test.v index 32995df..7d15f50 100644 --- a/appkit/ui_macos_test.v +++ b/appkit/ui_macos_test.v @@ -266,6 +266,41 @@ fn test_macos_scroll_mode_selects_scrollers_and_document_size() { assert macos.msg_bool(scroll_view, 'hasHorizontalScroller') } +fn test_macos_scroll_gesture_passes_to_an_enclosing_view_only_without_range_of_its_own() { + ensure_runtime_classes() + pool := macos.autorelease_pool_new() + defer { + macos.release(pool) + } + outer := native_new_scroll(native_rect(0, 0, 400, 300), BoxStyle{}, false, .horizontal_only) + defer { + macos.release(outer) + } + outer_document := native_new_flipped_view(native_rect(0, 0, 1200, 300), BoxStyle{}) + native_set_document_view(outer, outer_document) + macos.release(outer_document) + inner := native_new_scroll(native_rect(0, 0, 200, 150), BoxStyle{}, false, .horizontal_only) + inner_document := native_new_flipped_view(native_rect(0, 0, 200, 150), BoxStyle{}) + native_set_document_view(inner, inner_document) + macos.release(inner_document) + native_add_subview(outer_document, inner) + macos.release(inner) + + assert scroll_view_scrolls_axis(outer, true) + assert !scroll_view_scrolls_axis(outer, false) + // Content that fits leaves the inner strip nowhere to go, so a sideways gesture + // over it belongs to the strip around it. + assert !scroll_view_scrolls_axis(inner, true) + assert scroll_view_passes_axis(inner, true) + // Nothing around it scrolls vertically, so there is no one to hand that to. + assert !scroll_view_passes_axis(inner, false) + // Once its content overflows it keeps the gesture itself. + native_set_frame(inner_document, native_rect(0, 0, 800, 150)) + assert scroll_view_scrolls_axis(inner, true) + assert !scroll_view_passes_axis(inner, true) + assert !scroll_view_passes_axis(outer, true) +} + fn test_macos_text_field_uses_native_bezel_without_layer_mask() { pool := macos.autorelease_pool_new() defer { diff --git a/windows/native_helpers_windows.h b/windows/native_helpers_windows.h index 95ee4c4..b6d4c89 100644 --- a/windows/native_helpers_windows.h +++ b/windows/native_helpers_windows.h @@ -257,6 +257,24 @@ static inline DWORD ui2_win_wheel_scroll_style(UINT message, WPARAM wparam) { return WS_VSCROLL; } +// Hands a wheel message to the nearest window above this one that shows the bar the +// wheel moves, and says whether there was one. A window with nothing to scroll that +// way passes the message on like this, and so does a Scroll element whose bar is +// already at the end the wheel is turning towards. +static inline int ui2_win_forward_wheel(void *hwnd_ptr, UINT message, uintptr_t wparam, + intptr_t lparam) { + DWORD bar = ui2_win_wheel_scroll_style(message, (WPARAM)wparam); + HWND parent = GetParent((HWND)hwnd_ptr); + while (parent != NULL) { + if ((GetWindowLongPtrW(parent, GWL_STYLE) & bar) != 0) { + SendMessageW(parent, message, (WPARAM)wparam, (LPARAM)lparam); + return 1; + } + parent = GetParent(parent); + } + return 0; +} + static LRESULT CALLBACK ui2_win_control_subclass(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam, UINT_PTR subclass_id, DWORD_PTR reference_data) { (void)subclass_id; @@ -340,18 +358,10 @@ static LRESULT CALLBACK ui2_win_window_proc(HWND hwnd, UINT message, WPARAM wpar && ui2_win_is_accessible_button(hwnd)) { InvalidateRect(hwnd, NULL, FALSE); } - if (message == WM_MOUSEWHEEL || message == WM_MOUSEHWHEEL) { - DWORD bar = ui2_win_wheel_scroll_style(message, wparam); - if ((GetWindowLongPtrW(hwnd, GWL_STYLE) & bar) == 0) { - HWND parent = GetParent(hwnd); - while (parent != NULL) { - if ((GetWindowLongPtrW(parent, GWL_STYLE) & bar) != 0) { - SendMessageW(parent, message, wparam, lparam); - return 0; - } - parent = GetParent(parent); - } - } + if ((message == WM_MOUSEWHEEL || message == WM_MOUSEHWHEEL) + && (GetWindowLongPtrW(hwnd, GWL_STYLE) & ui2_win_wheel_scroll_style(message, wparam)) == 0 + && ui2_win_forward_wheel(hwnd, message, wparam, lparam)) { + return 0; } if (message == WM_CONTEXTMENU && ui2_windows_context_menu(hwnd, GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam))) { diff --git a/windows/ui_windows.v b/windows/ui_windows.v index 26d92d2..e70bae3 100644 --- a/windows/ui_windows.v +++ b/windows/ui_windows.v @@ -178,6 +178,8 @@ fn C.ui2_win_scroll_wheel(hwnd voidptr, wparam usize, horizontal int, tilt int) fn C.ui2_win_wheel_is_shifted(wparam usize) int +fn C.ui2_win_forward_wheel(hwnd voidptr, message u32, wparam usize, lparam isize) int + fn C.ui2_win_scroll_to_rect(hwnd voidptr, start int, end int, horizontal int) int fn C.ui2_win_set_scroll_position(hwnd voidptr, position int, horizontal int) int @@ -1557,15 +1559,29 @@ enum WindowsScrollSource { tilt_wheel } -fn windows_handle_scroll(hwnd voidptr, wparam usize, source WindowsScrollSource, horizontal bool) { +// windows_handle_wheel scrolls the Scroll element a wheel message reached. One already +// at the end the wheel is turning towards leaves the message to the pane around it, +// the way the custom renderer passes the distance a pane cannot use up its chain. +fn windows_handle_wheel(hwnd voidptr, message u32, wparam usize, lparam isize) { + tilt := message == win_wm_mouse_hwheel + // Shift turns a plain wheel sideways. + horizontal := tilt || C.ui2_win_wheel_is_shifted(wparam) != 0 + source := if tilt { WindowsScrollSource.tilt_wheel } else { WindowsScrollSource.wheel } + if !windows_handle_scroll(hwnd, wparam, source, horizontal) { + C.ui2_win_forward_wheel(hwnd, message, wparam, lparam) + } +} + +// windows_handle_scroll reports whether the message moved the element. +fn windows_handle_scroll(hwnd voidptr, wparam usize, source WindowsScrollSource, horizontal bool) bool { mut st := windows_state() - key := st.handle_keys[windows_handle_id(hwnd)] or { return } + key := st.handle_keys[windows_handle_id(hwnd)] or { return false } if (st.node_kinds[key] or { Kind.view }) != .scroll { - return + return false } // An element that does not scroll sideways has no horizontal bar to read or move. if horizontal && key !in st.scroll_positions_x { - return + return false } old_position := if horizontal { st.scroll_positions_x[key] or { 0 } @@ -1579,7 +1595,7 @@ fn windows_handle_scroll(hwnd voidptr, wparam usize, source WindowsScrollSource, C.ui2_win_scroll_wheel(hwnd, wparam, axis, windows_bool(source == .tilt_wheel)) } if position == old_position { - return + return false } if horizontal { st.scroll_positions_x[key] = position @@ -1591,6 +1607,7 @@ fn windows_handle_scroll(hwnd voidptr, wparam usize, source WindowsScrollSource, if id.len > 0 && voidptr(st.scroll_handler) != unsafe { nil } { st.scroll_handler(id) } + return true } fn windows_emit_action(id string) { @@ -1929,13 +1946,8 @@ fn ui2_windows_window_proc(hwnd voidptr, message u32, wparam usize, lparam isize windows_handle_scroll(hwnd, wparam, .bar, message == win_wm_hscroll) return 0 } - win_wm_mouse_wheel { - // Shift turns a plain wheel sideways. - windows_handle_scroll(hwnd, wparam, .wheel, C.ui2_win_wheel_is_shifted(wparam) != 0) - return 0 - } - win_wm_mouse_hwheel { - windows_handle_scroll(hwnd, wparam, .tilt_wheel, true) + win_wm_mouse_wheel, win_wm_mouse_hwheel { + windows_handle_wheel(hwnd, message, wparam, lparam) return 0 } win_wm_key_down { From 9f832d5d6b8fb2806ccb32f4331c9431a7559681 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 4 Oct 2026 00:02:26 +0300 Subject: [PATCH 03/13] Size AppKit scroll documents to what shows and pass on unused wheel distance on Windows --- appkit/ui_macos_darwin.v | 85 +++++++++++++++++++++++--------- appkit/ui_macos_test.v | 44 ++++++++++++----- windows/native_helpers_windows.h | 45 ++++++++++------- windows/ui_windows.v | 83 ++++++++++++++++++------------- 4 files changed, 171 insertions(+), 86 deletions(-) diff --git a/appkit/ui_macos_darwin.v b/appkit/ui_macos_darwin.v index 47b285f..ceeed9b 100644 --- a/appkit/ui_macos_darwin.v +++ b/appkit/ui_macos_darwin.v @@ -1149,25 +1149,21 @@ fn render_element(parent NativeView, el Element, key string, mut active map[stri } doc_key := key + '/document' active[doc_key] = true - doc_w, doc_h := scroll_document_size(el, macos.msg_rect(clip, 'bounds').height) mut doc := st.nodes[doc_key] or { native_nil_view() } if native_is_nil(doc) { - doc = native_new_flipped_view(native_rect(0, 0, doc_w, doc_h), el.box) + doc = native_new_flipped_view(native_rect(0, 0, el.frame.width, el.frame.height), + el.box) st.nodes[doc_key] = doc st.node_kinds[doc_key] = .view + native_size_scroll_document(native, doc, el) native_set_document_view(native, doc) macos.release(doc) } else { - native_set_frame(doc, native_rect(0, 0, doc_w, doc_h)) native_set_box_background(doc, el.box) } - // A scroller that takes room of its own comes and goes with the width of the - // document, and a strip held to the height that shows has to follow it. - macos.msg_void_u64(doc, 'setAutoresizingMask:', if el.scroll_mode == .horizontal_only { - ns_view_height_sizable - } else { - u64(0) - }) + // Attaching a document lays the scrollers out, so what shows of a new one is + // only settled from here. + native_size_scroll_document(native, doc, el) render_children(doc, el.children, doc_key, mut active) } .button { @@ -1769,23 +1765,63 @@ fn content_height(children []Element) f64 { return h } -// scroll_document_size is the size of the view a Scroll element scrolls over. An axis -// its mode scrolls covers the content; one it leaves out is held to the visible area, -// so there is nothing along it to scroll to. visible_height is the height the scroll -// view shows, which a scroller that takes room of its own makes less than the frame. -fn scroll_document_size(el Element, visible_height f64) (f64, f64) { - height := if el.scroll_mode != .horizontal_only { - content_height(el.children) + 16 - } else if visible_height > 0 { - visible_height - } else { - el.frame.height - } +// native_size_scroll_document gives a Scroll element's document the size its content +// and what shows of it call for. The mask goes on first: sizing the document can bring +// a scroller in or take one away, which changes what shows, and a side held to that +// has to follow it rather than keep the length measured a moment earlier. +fn native_size_scroll_document(scroll NativeView, doc NativeView, el Element) { + shown := macos.msg_rect(macos.msg_id(scroll, 'contentView'), 'bounds') + document := scroll_document_size(el, shown.width, shown.height) + macos.msg_void_u64(doc, 'setAutoresizingMask:', document.autoresizing_mask()) + native_set_frame(doc, native_rect(0, 0, document.width, document.height)) +} + +// ScrollDocument is the size of the view a Scroll element scrolls over, and which of +// its sides are as long as what shows rather than as long as the content. +struct ScrollDocument { + width f64 + height f64 + follows_width bool + follows_height bool +} + +fn (document ScrollDocument) autoresizing_mask() u64 { + mut mask := u64(0) + if document.follows_width { + mask |= ns_view_width_sizable + } + if document.follows_height { + mask |= ns_view_height_sizable + } + return mask +} + +// scroll_document_size sizes the view a Scroll element scrolls over. An axis its mode +// scrolls covers the content and is never shorter than what shows; one it leaves out +// is held to what shows, so there is nothing along it to scroll to. What shows is +// measured from the scroll view rather than taken from the frame, because a scroller +// that takes room of its own makes it less, and a document sized to the frame would +// then have a range the width of that scroller. A plain vertical list keeps the +// frame's width, as it always has. +fn scroll_document_size(el Element, visible_width f64, visible_height f64) ScrollDocument { + shown_width := if visible_width > 0 { visible_width } else { el.frame.width } + shown_height := if visible_height > 0 { visible_height } else { el.frame.height } + scrolls_y := el.scroll_mode != .horizontal_only + height := if scrolls_y { content_height(el.children) + 16 } else { shown_height } if el.scroll_mode == .vertical_only { - return el.frame.width, height + return ScrollDocument{ + width: el.frame.width + height: height + } } content_width := scroll_content_width(el.children) - return if content_width > el.frame.width { content_width } else { el.frame.width }, height + fits_across := content_width <= shown_width + return ScrollDocument{ + width: if fits_across { shown_width } else { content_width } + height: height + follows_width: fits_across + follows_height: !scrolls_y + } } fn assoc_handler_key() voidptr { @@ -3015,6 +3051,7 @@ fn ui2_bounds_changed(_self voidptr, _cmd voidptr, notification voidptr) { } const ns_event_phase_began = u64(1) +const ns_view_width_sizable = u64(2) const ns_view_height_sizable = u64(16) // A scroll view takes every wheel event it is under, including one along an axis it diff --git a/appkit/ui_macos_test.v b/appkit/ui_macos_test.v index 7d15f50..2db8c8d 100644 --- a/appkit/ui_macos_test.v +++ b/appkit/ui_macos_test.v @@ -245,21 +245,43 @@ fn test_macos_scroll_mode_selects_scrollers_and_document_size() { assert !macos.msg_bool(scroll_view, 'hasVerticalScroller') // A sideways strip is as wide as its columns and no taller than what shows. - width, height := scroll_document_size(columns, 80) - assert width == 400 - assert height == 80 - both_width, both_height := scroll_document_size(Element{ + assert scroll_document_size(columns, 120, 80) == ScrollDocument{ + width: 400 + height: 80 + follows_height: true + } + canvas := Element{ ...columns scroll_mode: .both - }, 80) - assert both_width == 400 - assert both_height == 316 - list_width, list_height := scroll_document_size(Element{ + } + assert scroll_document_size(canvas, 120, 80) == ScrollDocument{ + width: 400 + height: 316 + } + // A plain vertical list keeps the width of its frame. + assert scroll_document_size(Element{ ...columns scroll_mode: .vertical_only - }, 80) - assert list_width == 120 - assert list_height == 316 + }, 105, 80) == ScrollDocument{ + width: 120 + height: 316 + } + // Content that fits across is held to the width that shows, which a scroller + // taking room of its own makes less than the frame, so no sideways range is left. + narrow := Element{ + ...canvas + children: [view('', rect(0, 0, 100, 300), BoxStyle{}, [])] + } + fitted := scroll_document_size(narrow, 105, 65) + assert fitted == ScrollDocument{ + width: 105 + height: 316 + follows_width: true + } + assert fitted.autoresizing_mask() == ns_view_width_sizable + assert scroll_document_size(columns, 120, 65).autoresizing_mask() == ns_view_height_sizable + // Before the scroll view has been laid out there is only the frame to go by. + assert scroll_document_size(narrow, 0, 0).width == 120 native_set_scroll_axes(scroll_view, .both) assert macos.msg_bool(scroll_view, 'hasVerticalScroller') diff --git a/windows/native_helpers_windows.h b/windows/native_helpers_windows.h index b6d4c89..078ac2c 100644 --- a/windows/native_helpers_windows.h +++ b/windows/native_helpers_windows.h @@ -257,22 +257,26 @@ static inline DWORD ui2_win_wheel_scroll_style(UINT message, WPARAM wparam) { return WS_VSCROLL; } -// Hands a wheel message to the nearest window above this one that shows the bar the -// wheel moves, and says whether there was one. A window with nothing to scroll that -// way passes the message on like this, and so does a Scroll element whose bar is -// already at the end the wheel is turning towards. -static inline int ui2_win_forward_wheel(void *hwnd_ptr, UINT message, uintptr_t wparam, - intptr_t lparam) { - DWORD bar = ui2_win_wheel_scroll_style(message, (WPARAM)wparam); +// The nearest window above this one that shows the bar for an axis: the pane that +// takes a scroll along it which this one has no use for. +static inline void *ui2_win_scroll_parent(void *hwnd_ptr, int horizontal) { + DWORD bar = horizontal ? WS_HSCROLL : WS_VSCROLL; HWND parent = GetParent((HWND)hwnd_ptr); while (parent != NULL) { - if ((GetWindowLongPtrW(parent, GWL_STYLE) & bar) != 0) { - SendMessageW(parent, message, (WPARAM)wparam, (LPARAM)lparam); - return 1; - } + if ((GetWindowLongPtrW(parent, GWL_STYLE) & bar) != 0) return parent; parent = GetParent(parent); } - return 0; + return NULL; +} + +// Hands a wheel message from a window with nothing to scroll that way to the nearest +// one above it that has, and says whether there was one. +static inline int ui2_win_forward_wheel(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { + HWND parent = (HWND)ui2_win_scroll_parent(hwnd, + ui2_win_wheel_scroll_style(message, wparam) == WS_HSCROLL); + if (parent == NULL) return 0; + SendMessageW(parent, message, wparam, lparam); + return 1; } static LRESULT CALLBACK ui2_win_control_subclass(HWND hwnd, UINT message, WPARAM wparam, @@ -1605,9 +1609,17 @@ static inline int ui2_win_scroll_message(void *hwnd_ptr, uintptr_t wparam, int h return info.nPos; } -// A wheel rolled away from the user is the positive one and scrolls back. A tilt -// wheel reports a push to the right as positive, which scrolls on. -static inline int ui2_win_scroll_wheel(void *hwnd_ptr, uintptr_t wparam, int horizontal, int tilt) { +// How far a wheel message asks to scroll, positive towards the end of the content. A +// wheel rolled away from the user is the positive one and scrolls back. A tilt wheel +// reports a push to the right as positive, which scrolls on. +static inline int ui2_win_wheel_distance(uintptr_t wparam, int tilt) { + int distance = (GET_WHEEL_DELTA_WPARAM(wparam) / WHEEL_DELTA) * 48; + return tilt ? distance : -distance; +} + +// Moves a scroll bar by a distance and reports where it settled, which is short of +// what was asked at either end of its range. +static inline int ui2_win_scroll_by(void *hwnd_ptr, int distance, int horizontal) { HWND hwnd = (HWND)hwnd_ptr; int bar = ui2_win_scroll_bar(horizontal); SCROLLINFO info; @@ -1615,9 +1627,8 @@ static inline int ui2_win_scroll_wheel(void *hwnd_ptr, uintptr_t wparam, int hor info.cbSize = sizeof(info); info.fMask = SIF_ALL; GetScrollInfo(hwnd, bar, &info); - int distance = (GET_WHEEL_DELTA_WPARAM(wparam) / WHEEL_DELTA) * 48; info.fMask = SIF_POS; - info.nPos += tilt ? distance : -distance; + info.nPos += distance; SetScrollInfo(hwnd, bar, &info, TRUE); GetScrollInfo(hwnd, bar, &info); return info.nPos; diff --git a/windows/ui_windows.v b/windows/ui_windows.v index e70bae3..5ad99e1 100644 --- a/windows/ui_windows.v +++ b/windows/ui_windows.v @@ -174,11 +174,13 @@ fn C.ui2_win_set_scroll(hwnd voidptr, content int, position int, horizontal int) fn C.ui2_win_scroll_message(hwnd voidptr, wparam usize, horizontal int) int -fn C.ui2_win_scroll_wheel(hwnd voidptr, wparam usize, horizontal int, tilt int) int +fn C.ui2_win_wheel_distance(wparam usize, tilt int) int -fn C.ui2_win_wheel_is_shifted(wparam usize) int +fn C.ui2_win_scroll_by(hwnd voidptr, distance int, horizontal int) int + +fn C.ui2_win_scroll_parent(hwnd voidptr, horizontal int) voidptr -fn C.ui2_win_forward_wheel(hwnd voidptr, message u32, wparam usize, lparam isize) int +fn C.ui2_win_wheel_is_shifted(wparam usize) int fn C.ui2_win_scroll_to_rect(hwnd voidptr, start int, end int, horizontal int) int fn C.ui2_win_set_scroll_position(hwnd voidptr, position int, horizontal int) int @@ -1551,51 +1553,64 @@ fn windows_reposition_scroll_children(scroll_key string) { C.ui2_win_invalidate(st.nodes[scroll_key] or { return }) } -// How a scroll message reached a Scroll element: from one of its bars, from a wheel -// rolled back and forth, or from one tilted to the side. -enum WindowsScrollSource { - bar - wheel - tilt_wheel -} - -// windows_handle_wheel scrolls the Scroll element a wheel message reached. One already -// at the end the wheel is turning towards leaves the message to the pane around it, -// the way the custom renderer passes the distance a pane cannot use up its chain. -fn windows_handle_wheel(hwnd voidptr, message u32, wparam usize, lparam isize) { +// windows_handle_wheel scrolls the Scroll element a wheel message reached, then gives +// whatever distance it could not use to each pane around it in turn, the way the +// custom renderer walks its scroll chain. A pane ten pixels from its end takes those +// ten and the pane around it the rest of the step, so nested panes scroll through a +// boundary instead of stopping short at it. +fn windows_handle_wheel(hwnd voidptr, message u32, wparam usize) { tilt := message == win_wm_mouse_hwheel // Shift turns a plain wheel sideways. horizontal := tilt || C.ui2_win_wheel_is_shifted(wparam) != 0 - source := if tilt { WindowsScrollSource.tilt_wheel } else { WindowsScrollSource.wheel } - if !windows_handle_scroll(hwnd, wparam, source, horizontal) { - C.ui2_win_forward_wheel(hwnd, message, wparam, lparam) + distance := C.ui2_win_wheel_distance(wparam, windows_bool(tilt)) + mut remaining := distance - windows_scroll_by(hwnd, distance, horizontal) + mut pane := C.ui2_win_scroll_parent(hwnd, windows_bool(horizontal)) + for remaining != 0 && pane != unsafe { nil } { + remaining -= windows_scroll_by(pane, remaining, horizontal) + pane = C.ui2_win_scroll_parent(pane, windows_bool(horizontal)) } } -// windows_handle_scroll reports whether the message moved the element. -fn windows_handle_scroll(hwnd voidptr, wparam usize, source WindowsScrollSource, horizontal bool) bool { - mut st := windows_state() - key := st.handle_keys[windows_handle_id(hwnd)] or { return false } +// windows_scroll_key is the key of the Scroll element behind a window, when it is one +// and scrolls along the axis. +fn windows_scroll_key(hwnd voidptr, horizontal bool) ?string { + st := windows_state() + key := st.handle_keys[windows_handle_id(hwnd)] or { return none } if (st.node_kinds[key] or { Kind.view }) != .scroll { - return false + return none } // An element that does not scroll sideways has no horizontal bar to read or move. if horizontal && key !in st.scroll_positions_x { - return false + return none } + return key +} + +// windows_scroll_by moves a Scroll element along an axis and returns how far it went. +fn windows_scroll_by(hwnd voidptr, distance int, horizontal bool) int { + key := windows_scroll_key(hwnd, horizontal) or { return 0 } + return windows_record_scroll(hwnd, key, C.ui2_win_scroll_by(hwnd, distance, windows_bool(horizontal)), + horizontal) +} + +// windows_handle_scroll follows one of a Scroll element's own bars. +fn windows_handle_scroll(hwnd voidptr, wparam usize, horizontal bool) { + key := windows_scroll_key(hwnd, horizontal) or { return } + windows_record_scroll(hwnd, key, C.ui2_win_scroll_message(hwnd, wparam, windows_bool(horizontal)), + horizontal) +} + +// windows_record_scroll takes up the position a Scroll element's bar settled at: it +// moves the children to match, reports the change, and returns how far that was. +fn windows_record_scroll(hwnd voidptr, key string, position int, horizontal bool) int { + mut st := windows_state() old_position := if horizontal { st.scroll_positions_x[key] or { 0 } } else { st.scroll_positions[key] or { 0 } } - axis := windows_bool(horizontal) - position := if source == .bar { - C.ui2_win_scroll_message(hwnd, wparam, axis) - } else { - C.ui2_win_scroll_wheel(hwnd, wparam, axis, windows_bool(source == .tilt_wheel)) - } if position == old_position { - return false + return 0 } if horizontal { st.scroll_positions_x[key] = position @@ -1607,7 +1622,7 @@ fn windows_handle_scroll(hwnd voidptr, wparam usize, source WindowsScrollSource, if id.len > 0 && voidptr(st.scroll_handler) != unsafe { nil } { st.scroll_handler(id) } - return true + return position - old_position } fn windows_emit_action(id string) { @@ -1943,11 +1958,11 @@ fn ui2_windows_window_proc(hwnd voidptr, message u32, wparam usize, lparam isize if message == win_wm_hscroll && child != unsafe { nil } { return C.ui2_win_default_proc(hwnd, message, wparam, lparam) } - windows_handle_scroll(hwnd, wparam, .bar, message == win_wm_hscroll) + windows_handle_scroll(hwnd, wparam, message == win_wm_hscroll) return 0 } win_wm_mouse_wheel, win_wm_mouse_hwheel { - windows_handle_wheel(hwnd, message, wparam, lparam) + windows_handle_wheel(hwnd, message, wparam) return 0 } win_wm_key_down { From dc7fae3f5b9b0064f3db15697c2b855fa4525684 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 4 Oct 2026 00:11:21 +0300 Subject: [PATCH 04/13] Settle both Windows scroll bars together so neither pages over the other's room --- windows/native_helpers_windows.h | 60 ++++++++++++++++++++++++++++---- windows/ui_windows.v | 16 ++++++--- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/windows/native_helpers_windows.h b/windows/native_helpers_windows.h index 078ac2c..3470a66 100644 --- a/windows/native_helpers_windows.h +++ b/windows/native_helpers_windows.h @@ -1562,18 +1562,14 @@ static inline int ui2_win_scroll_bar(int horizontal) { return horizontal ? SB_HORZ : SB_VERT; } -static inline int ui2_win_set_scroll(void *hwnd_ptr, int content, int position, int horizontal) { - HWND hwnd = (HWND)hwnd_ptr; - int bar = ui2_win_scroll_bar(horizontal); - RECT rect; - GetClientRect(hwnd, &rect); +static inline int ui2_win_set_scroll_page(HWND hwnd, int bar, int content, int page, int position) { SCROLLINFO info; ZeroMemory(&info, sizeof(info)); info.cbSize = sizeof(info); info.fMask = SIF_RANGE | SIF_PAGE | SIF_POS; info.nMin = 0; info.nMax = content > 0 ? content - 1 : 0; - info.nPage = (UINT)(horizontal ? rect.right - rect.left : rect.bottom - rect.top); + info.nPage = (UINT)(page > 0 ? page : 0); info.nPos = position; SetScrollInfo(hwnd, bar, &info, TRUE); info.fMask = SIF_POS; @@ -1581,6 +1577,58 @@ static inline int ui2_win_set_scroll(void *hwnd_ptr, int content, int position, return info.nPos; } +static inline int ui2_win_set_scroll(void *hwnd_ptr, int content, int position, int horizontal) { + HWND hwnd = (HWND)hwnd_ptr; + RECT rect; + GetClientRect(hwnd, &rect); + return ui2_win_set_scroll_page(hwnd, ui2_win_scroll_bar(horizontal), content, + horizontal ? rect.right - rect.left : rect.bottom - rect.top, position); +} + +// Settles how much of each axis a Scroll element shows once its bars are in place. +// Each bar takes room from the axis the other one pages over, so neither page can be +// read off the client area while the other bar may still come or go. Which bars are +// needed follows from the area the window has with neither: a vertical bar when the +// content is taller than that, a horizontal one when it is wider than what the +// vertical bar leaves, and a vertical one after all when the horizontal bar is what +// makes the content too tall. +static inline void ui2_win_scroll_pages(int width, int height, int content_width, + int content_height, int bar_width, int bar_height, int *page_width, int *page_height) { + int vertical = content_height > height; + int horizontal = content_width > width - (vertical ? bar_width : 0); + if (horizontal && !vertical) vertical = content_height > height - bar_height; + *page_width = width - (vertical ? bar_width : 0); + *page_height = height - (horizontal ? bar_height : 0); +} + +// Sets both bars of a Scroll element together, for one that scrolls sideways. Setting +// them one after the other leaves whichever went first paging over room the other bar +// has since taken, and the content under that bar out of reach. +static inline void ui2_win_set_scroll_both(void *hwnd_ptr, int content_width, int content_height, + int position_x, int position_y) { + HWND hwnd = (HWND)hwnd_ptr; + RECT rect; + // The window has no border, so its own rectangle is the client area together + // with whichever bars are showing. + GetWindowRect(hwnd, &rect); + int page_width = 0; + int page_height = 0; + ui2_win_scroll_pages(rect.right - rect.left, rect.bottom - rect.top, content_width, + content_height, GetSystemMetrics(SM_CXVSCROLL), GetSystemMetrics(SM_CYHSCROLL), + &page_width, &page_height); + ui2_win_set_scroll_page(hwnd, SB_VERT, content_height, page_height, position_y); + ui2_win_set_scroll_page(hwnd, SB_HORZ, content_width, page_width, position_x); +} + +static inline int ui2_win_scroll_position(void *hwnd_ptr, int horizontal) { + SCROLLINFO info; + ZeroMemory(&info, sizeof(info)); + info.cbSize = sizeof(info); + info.fMask = SIF_POS; + GetScrollInfo((HWND)hwnd_ptr, ui2_win_scroll_bar(horizontal), &info); + return info.nPos; +} + // The line, page and end codes of WM_HSCROLL share their values with WM_VSCROLL's. static inline int ui2_win_scroll_message(void *hwnd_ptr, uintptr_t wparam, int horizontal) { HWND hwnd = (HWND)hwnd_ptr; diff --git a/windows/ui_windows.v b/windows/ui_windows.v index 5ad99e1..5369e22 100644 --- a/windows/ui_windows.v +++ b/windows/ui_windows.v @@ -172,6 +172,10 @@ fn C.ui2_win_clear_bitmap(hwnd voidptr) fn C.ui2_win_set_scroll(hwnd voidptr, content int, position int, horizontal int) int +fn C.ui2_win_set_scroll_both(hwnd voidptr, content_width int, content_height int, position_x int, position_y int) + +fn C.ui2_win_scroll_position(hwnd voidptr, horizontal int) int + fn C.ui2_win_scroll_message(hwnd voidptr, wparam usize, horizontal int) int fn C.ui2_win_wheel_distance(wparam usize, tilt int) int @@ -1049,18 +1053,22 @@ fn windows_layout_scroll(hwnd voidptr, el Element, key string) (int, int) { st.pending_scroll_x.delete(el.id) } } - mut position := C.ui2_win_set_scroll(hwnd, content_height, requested, 0) + mut position := 0 mut x_position := 0 if scrolls_x || key in st.scroll_positions_x { + // Each bar takes room from the axis the other pages over, so the two are + // settled together rather than one from what the other happened to leave. content_width := if scrolls_x { int(scroll_content_width(el.children)) } else { 0 } - x_position = C.ui2_win_set_scroll(hwnd, content_width, requested_x, 1) - // A bar that appeared or went away changed how much of the other axis shows. - position = C.ui2_win_set_scroll(hwnd, content_height, position, 0) + C.ui2_win_set_scroll_both(hwnd, content_width, content_height, requested_x, requested) + x_position = C.ui2_win_scroll_position(hwnd, 1) + position = C.ui2_win_scroll_position(hwnd, 0) if scrolls_x { st.scroll_positions_x[key] = x_position } else { st.scroll_positions_x.delete(key) } + } else { + position = C.ui2_win_set_scroll(hwnd, content_height, requested, 0) } st.scroll_positions[key] = position return x_position, position From fb74b8fc606626e5c40526dad2bcaec8202ffecf Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 4 Oct 2026 00:18:00 +0300 Subject: [PATCH 05/13] Pass an AppKit scroll gesture on from a pane already at its end --- appkit/ui_macos_darwin.v | 50 +++++++++++++++++++++++++++++----------- appkit/ui_macos_test.v | 27 +++++++++++++++------- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/appkit/ui_macos_darwin.v b/appkit/ui_macos_darwin.v index ceeed9b..3a5038a 100644 --- a/appkit/ui_macos_darwin.v +++ b/appkit/ui_macos_darwin.v @@ -3054,11 +3054,13 @@ const ns_event_phase_began = u64(1) const ns_view_width_sizable = u64(2) const ns_view_height_sizable = u64(16) -// A scroll view takes every wheel event it is under, including one along an axis it -// has nothing to scroll on, which leaves a strip of columns stuck whenever the pointer -// is over a column that scrolls the other way. The view a gesture starts on therefore -// decides once, from the direction the gesture sets off in, whether an enclosing -// Scroll element is the one to move, and hands it the whole gesture if so. +// A scroll view takes every wheel event it is under, including one it has no way to +// follow: along an axis it does not scroll, or further in a direction it is already at +// the end of. That leaves a strip of columns stuck whenever the pointer is over a +// column that scrolls the other way, and a list stalled wherever a list inside it has +// run out. The view a gesture starts on therefore decides once, from the direction the +// gesture sets off in, whether an enclosing Scroll element is the one to move, and +// hands it the whole gesture if so. @[export: 'ui2_scroll_view_scroll_wheel'] fn ui2_scroll_view_scroll_wheel(self voidptr, _cmd voidptr, event voidptr) { mut st := state() @@ -3073,7 +3075,7 @@ fn ui2_scroll_view_scroll_wheel(self voidptr, _cmd voidptr, event voidptr) { dy := macos.msg_f64(wheel, 'scrollingDeltaY') sideways := dx * dx > dy * dy st.scroll_forwarding[handle] = dx * dx != dy * dy - && scroll_view_passes_axis(scroll_view, sideways) + && scroll_view_passes_gesture(scroll_view, sideways, if sideways { dx } else { dy }) } if st.scroll_forwarding[handle] or { false } { next := macos.msg_id(scroll_view, 'nextResponder') @@ -3106,17 +3108,39 @@ fn scroll_view_scrolls_axis(scroll NativeView, horizontal bool) bool { } } -// scroll_view_passes_axis reports whether a scroll view has nowhere to go along an -// axis that a scroll view around it does. Content that fits counts as nowhere to go, -// the same as an axis the mode leaves out, so a pane that happens not to overflow -// does not hold on to a gesture the pane around it could use. -fn scroll_view_passes_axis(scroll NativeView, horizontal bool) bool { - if scroll_view_scrolls_axis(scroll, horizontal) { +// scroll_view_can_move reports whether a scroll view can follow a wheel delta along an +// axis: it has a range there, and is not already at the end the delta pushes towards. +// A positive delta carries the content with it, back towards its start. Documents are +// flipped views, so the vertical offset grows downwards the way the horizontal one +// grows to the right. +fn scroll_view_can_move(scroll NativeView, horizontal bool, delta f64) bool { + if !scroll_view_scrolls_axis(scroll, horizontal) { + return false + } + document := macos.msg_rect(macos.msg_id(scroll, 'documentView'), 'frame') + visible := macos.msg_rect(macos.msg_id(scroll, 'contentView'), 'bounds') + offset := if horizontal { visible.x } else { visible.y } + maximum := if horizontal { + document.width - visible.width + } else { + document.height - visible.height + } + return if delta > 0 { offset > 0.5 } else { offset < maximum - 0.5 } +} + +// scroll_view_passes_gesture reports whether a gesture starting on a scroll view +// belongs to a scroll view around it: this one cannot follow it and one of those can. +// Content that fits counts as unable to follow, the same as an axis the mode leaves +// out, and so does a view already at the end the gesture heads for, so a pane does +// not hold on to a gesture the pane around it could use. With no one to take it the +// gesture stays where it began. +fn scroll_view_passes_gesture(scroll NativeView, horizontal bool, delta f64) bool { + if scroll_view_can_move(scroll, horizontal, delta) { return false } mut parent := macos.msg_id(macos.msg_id(scroll, 'superview'), 'enclosingScrollView') for !native_is_nil(parent) { - if scroll_view_scrolls_axis(parent, horizontal) { + if scroll_view_can_move(parent, horizontal, delta) { return true } parent = macos.msg_id(macos.msg_id(parent, 'superview'), 'enclosingScrollView') diff --git a/appkit/ui_macos_test.v b/appkit/ui_macos_test.v index 2db8c8d..cfa5cde 100644 --- a/appkit/ui_macos_test.v +++ b/appkit/ui_macos_test.v @@ -288,7 +288,7 @@ fn test_macos_scroll_mode_selects_scrollers_and_document_size() { assert macos.msg_bool(scroll_view, 'hasHorizontalScroller') } -fn test_macos_scroll_gesture_passes_to_an_enclosing_view_only_without_range_of_its_own() { +fn test_macos_scroll_gesture_passes_to_an_enclosing_view_only_when_it_cannot_be_followed() { ensure_runtime_classes() pool := macos.autorelease_pool_new() defer { @@ -313,14 +313,25 @@ fn test_macos_scroll_gesture_passes_to_an_enclosing_view_only_without_range_of_i // Content that fits leaves the inner strip nowhere to go, so a sideways gesture // over it belongs to the strip around it. assert !scroll_view_scrolls_axis(inner, true) - assert scroll_view_passes_axis(inner, true) - // Nothing around it scrolls vertically, so there is no one to hand that to. - assert !scroll_view_passes_axis(inner, false) - // Once its content overflows it keeps the gesture itself. + assert scroll_view_passes_gesture(inner, true, -1) + // Both strips are at their start, so no one can take a gesture heading back that + // way, and nothing here scrolls vertically at all. + assert !scroll_view_passes_gesture(inner, true, 1) + assert !scroll_view_passes_gesture(inner, false, -1) + // Once its content overflows it keeps the gesture itself... native_set_frame(inner_document, native_rect(0, 0, 800, 150)) - assert scroll_view_scrolls_axis(inner, true) - assert !scroll_view_passes_axis(inner, true) - assert !scroll_view_passes_axis(outer, true) + assert scroll_view_can_move(inner, true, -1) + assert !scroll_view_passes_gesture(inner, true, -1) + // ...until it has run out, where further the same way is the outer strip's again, + // while the way back is still its own. + macos.msg_void_point(macos.msg_id(inner, 'contentView'), 'setBoundsOrigin:', macos.point(600, + 0)) + assert !scroll_view_can_move(inner, true, -1) + assert scroll_view_passes_gesture(inner, true, -1) + assert scroll_view_can_move(inner, true, 1) + assert !scroll_view_passes_gesture(inner, true, 1) + // A view with nothing around it keeps even what it cannot use. + assert !scroll_view_passes_gesture(outer, true, 1) } fn test_macos_text_field_uses_native_bezel_without_layer_mask() { From 8f7c9948d1a9e2448d3f24c6174e58579b4a7995 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 4 Oct 2026 00:25:53 +0300 Subject: [PATCH 06/13] Keep an AppKit scroll at its start along an axis it does not scroll --- appkit/ui_macos_darwin.v | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/appkit/ui_macos_darwin.v b/appkit/ui_macos_darwin.v index 3a5038a..6a4d9b1 100644 --- a/appkit/ui_macos_darwin.v +++ b/appkit/ui_macos_darwin.v @@ -353,11 +353,16 @@ pub fn scroll_offset(id string) f64 { pub fn scroll_horizontal_offset(id string) f64 { st := state() scrollv := st.views[id] or { return 0 } + if !scroll_view_has_axis(scrollv, true) { + return 0 + } r := macos.msg_rect(scrollv, 'documentVisibleRect') return r.x } -// scroll_to_rect scrolls a Scroll element so the given document rect is visible. +// scroll_to_rect scrolls a Scroll element so the given document rect is visible. Along +// an axis the element does not scroll, the rect is taken to be what already shows from +// the start, so bringing it into view never moves the element that way. pub fn scroll_to_rect(id string, x f64, y f64, width f64, height f64) { st := state() scrollv := st.views[id] or { return } @@ -365,7 +370,23 @@ pub fn scroll_to_rect(id string, x f64, y f64, width f64, height f64) { if native_is_nil(doc) { return } - macos.msg_void_rect(doc, 'scrollRectToVisible:', macos.rect(x, y, width, height)) + visible := macos.msg_rect(macos.msg_id(scrollv, 'contentView'), 'bounds') + across := scroll_view_has_axis(scrollv, true) + down := scroll_view_has_axis(scrollv, false) + macos.msg_void_rect(doc, 'scrollRectToVisible:', macos.rect(if across { x } else { 0.0 }, + if down { y } else { 0.0 }, if across { width } else { visible.width }, if down { + height + } else { + visible.height + })) +} + +// scroll_view_has_axis reports whether a scroll view's mode scrolls an axis. One it +// leaves out can still have a range in AppKit's eyes: a scroller that takes room of +// its own leaves the document that much longer than what shows of it. That is not +// somewhere to scroll to, so no position along such an axis is reported or asked for. +fn scroll_view_has_axis(scroll NativeView, horizontal bool) bool { + return macos.msg_bool(scroll, if horizontal { 'hasHorizontalScroller' } else { 'hasVerticalScroller' }) } // scroll_to_offset puts a Scroll element at the given vertical offset. Asking for a @@ -406,9 +427,10 @@ fn apply_scroll_offset(mut st RuntimeState, id string) { // A rect the size of the visible area lands its corner at the corner of the view, // which is the offset that was asked for. The scroll view clamps it to the // document, so an offset past the end settles at the end. An axis nothing was - // asked of keeps the position it is at. - x := st.pending_scroll_x[id] or { visible.x } - y := st.pending_scroll[id] or { visible.y } + // asked of keeps the position it is at, and one the element does not scroll stays + // at its start whatever was asked. + x := if scroll_view_has_axis(scrollv, true) { st.pending_scroll_x[id] or { visible.x } } else { 0.0 } + y := if scroll_view_has_axis(scrollv, false) { st.pending_scroll[id] or { visible.y } } else { 0.0 } macos.msg_void_rect(doc, 'scrollRectToVisible:', macos.rect(x, y, visible.width, visible.height)) st.pending_scroll.delete(id) st.pending_scroll_x.delete(id) @@ -3091,8 +3113,7 @@ fn ui2_scroll_view_scroll_wheel(self voidptr, _cmd voidptr, event voidptr) { // scroll_view_scrolls_axis reports whether a scroll view has anywhere to go along an // axis: its mode scrolls that axis, and its document is longer than what shows of it. fn scroll_view_scrolls_axis(scroll NativeView, horizontal bool) bool { - scroller := if horizontal { 'hasHorizontalScroller' } else { 'hasVerticalScroller' } - if !macos.msg_bool(scroll, scroller) { + if !scroll_view_has_axis(scroll, horizontal) { return false } document_view := macos.msg_id(scroll, 'documentView') From 0d13e8c2bddedb736f7e41216a5570b5908e974e Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 4 Oct 2026 00:32:27 +0300 Subject: [PATCH 07/13] Show a Windows scroll pane's bars outright rather than rely on the range to bring them in --- windows/native_helpers_windows.h | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/windows/native_helpers_windows.h b/windows/native_helpers_windows.h index 3470a66..bf9ddb2 100644 --- a/windows/native_helpers_windows.h +++ b/windows/native_helpers_windows.h @@ -1601,6 +1601,16 @@ static inline void ui2_win_scroll_pages(int width, int height, int content_width *page_height = height - (horizontal ? bar_height : 0); } +// Makes a standard bar show exactly when it is needed. A range that outgrows its page +// brings the bar in and one that fits takes it away, so this normally finds nothing to +// do. Asking outright keeps the horizontal bar from resting on that alone: the window +// is created with the style for the vertical bar only. +static inline void ui2_win_ensure_scroll_bar(HWND hwnd, int bar, int needed) { + DWORD style = bar == SB_HORZ ? WS_HSCROLL : WS_VSCROLL; + int showing = (GetWindowLongPtrW(hwnd, GWL_STYLE) & style) != 0; + if (showing != (needed != 0)) ShowScrollBar(hwnd, bar, needed ? TRUE : FALSE); +} + // Sets both bars of a Scroll element together, for one that scrolls sideways. Setting // them one after the other leaves whichever went first paging over room the other bar // has since taken, and the content under that bar out of reach. @@ -1618,6 +1628,8 @@ static inline void ui2_win_set_scroll_both(void *hwnd_ptr, int content_width, in &page_width, &page_height); ui2_win_set_scroll_page(hwnd, SB_VERT, content_height, page_height, position_y); ui2_win_set_scroll_page(hwnd, SB_HORZ, content_width, page_width, position_x); + ui2_win_ensure_scroll_bar(hwnd, SB_VERT, content_height > page_height); + ui2_win_ensure_scroll_bar(hwnd, SB_HORZ, content_width > page_width); } static inline int ui2_win_scroll_position(void *hwnd_ptr, int horizontal) { From 0e7cfeadd575a08f176883aaffcf3a8736606947 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 4 Oct 2026 00:39:22 +0300 Subject: [PATCH 08/13] Hold a vertical AppKit scroll's document to the width that shows --- appkit/ui_macos_darwin.v | 16 +++++----------- appkit/ui_macos_test.v | 8 +++++--- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/appkit/ui_macos_darwin.v b/appkit/ui_macos_darwin.v index 6a4d9b1..1dbe5fa 100644 --- a/appkit/ui_macos_darwin.v +++ b/appkit/ui_macos_darwin.v @@ -1823,24 +1823,18 @@ fn (document ScrollDocument) autoresizing_mask() u64 { // is held to what shows, so there is nothing along it to scroll to. What shows is // measured from the scroll view rather than taken from the frame, because a scroller // that takes room of its own makes it less, and a document sized to the frame would -// then have a range the width of that scroller. A plain vertical list keeps the -// frame's width, as it always has. +// then have a range the width of that scroller: one a wheel could push the element +// into along an axis it is not meant to scroll. fn scroll_document_size(el Element, visible_width f64, visible_height f64) ScrollDocument { shown_width := if visible_width > 0 { visible_width } else { el.frame.width } shown_height := if visible_height > 0 { visible_height } else { el.frame.height } + scrolls_x := el.scroll_mode != .vertical_only scrolls_y := el.scroll_mode != .horizontal_only - height := if scrolls_y { content_height(el.children) + 16 } else { shown_height } - if el.scroll_mode == .vertical_only { - return ScrollDocument{ - width: el.frame.width - height: height - } - } - content_width := scroll_content_width(el.children) + content_width := if scrolls_x { scroll_content_width(el.children) } else { 0.0 } fits_across := content_width <= shown_width return ScrollDocument{ width: if fits_across { shown_width } else { content_width } - height: height + height: if scrolls_y { content_height(el.children) + 16 } else { shown_height } follows_width: fits_across follows_height: !scrolls_y } diff --git a/appkit/ui_macos_test.v b/appkit/ui_macos_test.v index cfa5cde..82b2621 100644 --- a/appkit/ui_macos_test.v +++ b/appkit/ui_macos_test.v @@ -258,13 +258,15 @@ fn test_macos_scroll_mode_selects_scrollers_and_document_size() { width: 400 height: 316 } - // A plain vertical list keeps the width of its frame. + // A vertical list is held to the width that shows, however far its children reach, + // so a scroller taking room of its own leaves no sideways range behind it. assert scroll_document_size(Element{ ...columns scroll_mode: .vertical_only }, 105, 80) == ScrollDocument{ - width: 120 - height: 316 + width: 105 + height: 316 + follows_width: true } // Content that fits across is held to the width that shows, which a scroller // taking room of its own makes less than the frame, so no sideways range is left. From b94caee47d6db6accc6ad26732bd17f1a777c727 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 4 Oct 2026 00:58:32 +0300 Subject: [PATCH 09/13] Keep the remainder of Windows wheel steps smaller than a notch --- windows/native_helpers_windows.h | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/windows/native_helpers_windows.h b/windows/native_helpers_windows.h index bf9ddb2..dff9826 100644 --- a/windows/native_helpers_windows.h +++ b/windows/native_helpers_windows.h @@ -1672,8 +1672,21 @@ static inline int ui2_win_scroll_message(void *hwnd_ptr, uintptr_t wparam, int h // How far a wheel message asks to scroll, positive towards the end of the content. A // wheel rolled away from the user is the positive one and scrolls back. A tilt wheel // reports a push to the right as positive, which scrolls on. +// +// A precision wheel or a touchpad reports steps smaller than WHEEL_DELTA, many to a +// notch. The step is scaled before it is divided, and what the division leaves over +// is kept for the next message, so those steps add up to the same distance a whole +// notch covers instead of each rounding away to nothing. static inline int ui2_win_wheel_distance(uintptr_t wparam, int tilt) { - int distance = (GET_WHEEL_DELTA_WPARAM(wparam) / WHEEL_DELTA) * 48; + static int leftover[2]; + int *rest = &leftover[tilt ? 1 : 0]; + int delta = GET_WHEEL_DELTA_WPARAM(wparam); + // A wheel turned back the other way starts afresh rather than working off what + // the last direction left. + if ((*rest > 0 && delta < 0) || (*rest < 0 && delta > 0)) *rest = 0; + int scaled = delta * 48 + *rest; + int distance = scaled / WHEEL_DELTA; + *rest = scaled - distance * WHEEL_DELTA; return tilt ? distance : -distance; } From b754b1a67da48651594794865c5aad31335f41d0 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 4 Oct 2026 01:05:02 +0300 Subject: [PATCH 10/13] Say why an AppKit scroll gesture stays with the view it began on --- appkit/ui_macos_darwin.v | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/appkit/ui_macos_darwin.v b/appkit/ui_macos_darwin.v index 1dbe5fa..46b31ff 100644 --- a/appkit/ui_macos_darwin.v +++ b/appkit/ui_macos_darwin.v @@ -3083,8 +3083,12 @@ fn ui2_scroll_view_scroll_wheel(self voidptr, _cmd voidptr, event voidptr) { scroll_view := NativeView(self) wheel := macos.Id(event) handle := u64(self) - // A wheel click stands alone. A trackpad gesture is decided where it begins and - // keeps that answer through the momentum that follows it. + // A wheel click stands alone and is decided each time. A trackpad gesture is decided + // where it begins and keeps that answer through the momentum that follows it, even + // once this view has run out partway: scrolling stays with the view it began on, as + // it does elsewhere on macOS. AppKit follows a gesture from its beginning to its end, + // so handing the rest of one over would give the view around this one a gesture with + // no beginning and leave this one without the end that settles its rubber band. if macos.msg_u64(wheel, 'phase') == ns_event_phase_began || (macos.msg_u64(wheel, 'phase') == 0 && macos.msg_u64(wheel, 'momentumPhase') == 0) { dx := macos.msg_f64(wheel, 'scrollingDeltaX') From c0f9e380522259b9e985688c95f0bb4fe3d2abce Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 4 Oct 2026 01:16:29 +0300 Subject: [PATCH 11/13] Keep a Windows wheel's leftover with the axis it scrolls --- windows/native_helpers_windows.h | 15 +++++++++------ windows/ui_windows.v | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/windows/native_helpers_windows.h b/windows/native_helpers_windows.h index dff9826..aab19ec 100644 --- a/windows/native_helpers_windows.h +++ b/windows/native_helpers_windows.h @@ -1676,18 +1676,21 @@ static inline int ui2_win_scroll_message(void *hwnd_ptr, uintptr_t wparam, int h // A precision wheel or a touchpad reports steps smaller than WHEEL_DELTA, many to a // notch. The step is scaled before it is divided, and what the division leaves over // is kept for the next message, so those steps add up to the same distance a whole -// notch covers instead of each rounding away to nothing. -static inline int ui2_win_wheel_distance(uintptr_t wparam, int tilt) { +// notch covers instead of each rounding away to nothing. What is left over belongs to +// the axis being scrolled, not to the kind of message: a plain wheel scrolls +// sideways with Shift held, and must not finish there a step it began vertically. +static inline int ui2_win_wheel_distance(uintptr_t wparam, int tilt, int horizontal) { static int leftover[2]; - int *rest = &leftover[tilt ? 1 : 0]; + int *rest = &leftover[horizontal ? 1 : 0]; int delta = GET_WHEEL_DELTA_WPARAM(wparam); + int step = (tilt ? delta : -delta) * 48; // A wheel turned back the other way starts afresh rather than working off what // the last direction left. - if ((*rest > 0 && delta < 0) || (*rest < 0 && delta > 0)) *rest = 0; - int scaled = delta * 48 + *rest; + if ((*rest > 0 && step < 0) || (*rest < 0 && step > 0)) *rest = 0; + int scaled = step + *rest; int distance = scaled / WHEEL_DELTA; *rest = scaled - distance * WHEEL_DELTA; - return tilt ? distance : -distance; + return distance; } // Moves a scroll bar by a distance and reports where it settled, which is short of diff --git a/windows/ui_windows.v b/windows/ui_windows.v index 5369e22..9cdae6b 100644 --- a/windows/ui_windows.v +++ b/windows/ui_windows.v @@ -178,7 +178,7 @@ fn C.ui2_win_scroll_position(hwnd voidptr, horizontal int) int fn C.ui2_win_scroll_message(hwnd voidptr, wparam usize, horizontal int) int -fn C.ui2_win_wheel_distance(wparam usize, tilt int) int +fn C.ui2_win_wheel_distance(wparam usize, tilt int, horizontal int) int fn C.ui2_win_scroll_by(hwnd voidptr, distance int, horizontal int) int @@ -1570,7 +1570,7 @@ fn windows_handle_wheel(hwnd voidptr, message u32, wparam usize) { tilt := message == win_wm_mouse_hwheel // Shift turns a plain wheel sideways. horizontal := tilt || C.ui2_win_wheel_is_shifted(wparam) != 0 - distance := C.ui2_win_wheel_distance(wparam, windows_bool(tilt)) + distance := C.ui2_win_wheel_distance(wparam, windows_bool(tilt), windows_bool(horizontal)) mut remaining := distance - windows_scroll_by(hwnd, distance, horizontal) mut pane := C.ui2_win_scroll_parent(hwnd, windows_bool(horizontal)) for remaining != 0 && pane != unsafe { nil } { From 4af7ad384dc17cd08ae04c15485a38f8c82aa658 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 4 Oct 2026 01:26:32 +0300 Subject: [PATCH 12/13] Pass the part of a wheel click an AppKit scroll has no room for to the pane around it --- appkit/ui_macos_darwin.v | 87 ++++++++++++++++++++++++++++++++++------ appkit/ui_macos_test.v | 12 ++++++ 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/appkit/ui_macos_darwin.v b/appkit/ui_macos_darwin.v index 46b31ff..8c784de 100644 --- a/appkit/ui_macos_darwin.v +++ b/appkit/ui_macos_darwin.v @@ -3089,8 +3089,9 @@ fn ui2_scroll_view_scroll_wheel(self voidptr, _cmd voidptr, event voidptr) { // it does elsewhere on macOS. AppKit follows a gesture from its beginning to its end, // so handing the rest of one over would give the view around this one a gesture with // no beginning and leave this one without the end that settles its rubber band. - if macos.msg_u64(wheel, 'phase') == ns_event_phase_began - || (macos.msg_u64(wheel, 'phase') == 0 && macos.msg_u64(wheel, 'momentumPhase') == 0) { + phase := macos.msg_u64(wheel, 'phase') + click := phase == 0 && macos.msg_u64(wheel, 'momentumPhase') == 0 + if phase == ns_event_phase_began || click { dx := macos.msg_f64(wheel, 'scrollingDeltaX') dy := macos.msg_f64(wheel, 'scrollingDeltaY') sideways := dx * dx > dy * dy @@ -3104,6 +3105,9 @@ fn ui2_scroll_view_scroll_wheel(self voidptr, _cmd voidptr, event voidptr) { } return } + if click { + scroll_view_pass_on_unused(scroll_view, wheel) + } C.ui2_macos_msg_super_void_id(self, voidptr(macos.get_class('NSScrollView')), voidptr(macos.sel('scrollWheel:')), event) } @@ -3127,14 +3131,14 @@ fn scroll_view_scrolls_axis(scroll NativeView, horizontal bool) bool { } } -// scroll_view_can_move reports whether a scroll view can follow a wheel delta along an -// axis: it has a range there, and is not already at the end the delta pushes towards. -// A positive delta carries the content with it, back towards its start. Documents are -// flipped views, so the vertical offset grows downwards the way the horizontal one -// grows to the right. -fn scroll_view_can_move(scroll NativeView, horizontal bool, delta f64) bool { +// scroll_view_room is how much of a distance along an axis a scroll view has room for: +// all of it, what is left before the end it heads for, or none. The distance is a +// change in offset, positive towards the end of the content. Documents are flipped +// views, so the vertical offset grows downwards the way the horizontal one grows to +// the right. +fn scroll_view_room(scroll NativeView, horizontal bool, distance f64) f64 { if !scroll_view_scrolls_axis(scroll, horizontal) { - return false + return 0 } document := macos.msg_rect(macos.msg_id(scroll, 'documentView'), 'frame') visible := macos.msg_rect(macos.msg_id(scroll, 'contentView'), 'bounds') @@ -3144,7 +3148,25 @@ fn scroll_view_can_move(scroll NativeView, horizontal bool, delta f64) bool { } else { document.height - visible.height } - return if delta > 0 { offset > 0.5 } else { offset < maximum - 0.5 } + room := if distance > 0 { maximum - offset } else { offset } + if room <= 0.5 { + return 0 + } + if distance > 0 { + return if distance < room { distance } else { room } + } + return if -distance < room { distance } else { -room } +} + +// scroll_view_can_move reports whether a scroll view can follow a wheel delta along an +// axis: it has a range there, and is not already at the end the delta pushes towards. +// A positive delta carries the content with it, back towards its start. +fn scroll_view_can_move(scroll NativeView, horizontal bool, delta f64) bool { + return scroll_view_room(scroll, horizontal, -delta) != 0 +} + +fn scroll_view_enclosing(scroll NativeView) NativeView { + return macos.msg_id(macos.msg_id(scroll, 'superview'), 'enclosingScrollView') } // scroll_view_passes_gesture reports whether a gesture starting on a scroll view @@ -3157,16 +3179,57 @@ fn scroll_view_passes_gesture(scroll NativeView, horizontal bool, delta f64) boo if scroll_view_can_move(scroll, horizontal, delta) { return false } - mut parent := macos.msg_id(macos.msg_id(scroll, 'superview'), 'enclosingScrollView') + mut parent := scroll_view_enclosing(scroll) for !native_is_nil(parent) { if scroll_view_can_move(parent, horizontal, delta) { return true } - parent = macos.msg_id(macos.msg_id(parent, 'superview'), 'enclosingScrollView') + parent = scroll_view_enclosing(parent) } return false } +// scroll_view_pass_on_unused gives the views around a scroll view the part of a wheel +// click that it has no room for. AppKit moves the view itself once the click is handed +// to it, as far as its end, and the rest of the step would be dropped there. How far a +// click goes is the distance the device reports, or that many of the view's own lines +// when it reports lines. Each view around it that can still move takes what it has +// room for in turn, the way the custom renderer walks its scroll chain. +fn scroll_view_pass_on_unused(scroll NativeView, wheel macos.Id) { + precise := macos.msg_bool(wheel, 'hasPreciseScrollingDeltas') + for horizontal in [true, false] { + delta := macos.msg_f64(wheel, if horizontal { 'scrollingDeltaX' } else { 'scrollingDeltaY' }) + if delta == 0 { + continue + } + line := if precise { + 1.0 + } else { + macos.msg_f64(scroll, if horizontal { 'horizontalLineScroll' } else { 'verticalLineScroll' }) + } + // A positive delta carries the content with it, back towards its start. + mut remaining := -delta * line + remaining -= scroll_view_room(scroll, horizontal, remaining) + mut parent := scroll_view_enclosing(scroll) + for remaining != 0 && !native_is_nil(parent) { + taken := scroll_view_room(parent, horizontal, remaining) + if taken != 0 { + scroll_view_move(parent, horizontal, taken) + remaining -= taken + } + parent = scroll_view_enclosing(parent) + } + } +} + +// scroll_view_move shifts a scroll view along an axis by a distance it has room for. +fn scroll_view_move(scroll NativeView, horizontal bool, distance f64) { + visible := macos.msg_rect(macos.msg_id(scroll, 'contentView'), 'bounds') + macos.msg_void_rect(macos.msg_id(scroll, 'documentView'), 'scrollRectToVisible:', macos.rect(visible.x + + if horizontal { distance } else { 0.0 }, visible.y + if horizontal { 0.0 } else { distance }, + visible.width, visible.height)) +} + @[export: 'ui2_window_did_resize'] fn ui2_window_did_resize(_self voidptr, _cmd voidptr, _notification voidptr) { refresh() diff --git a/appkit/ui_macos_test.v b/appkit/ui_macos_test.v index 82b2621..15fce15 100644 --- a/appkit/ui_macos_test.v +++ b/appkit/ui_macos_test.v @@ -334,6 +334,18 @@ fn test_macos_scroll_gesture_passes_to_an_enclosing_view_only_when_it_cannot_be_ assert !scroll_view_passes_gesture(inner, true, 1) // A view with nothing around it keeps even what it cannot use. assert !scroll_view_passes_gesture(outer, true, 1) + + // Ten short of its end, the inner strip has room for ten of a 48 point step and + // the strip around it for the other 38; the way back is all its own. + macos.msg_void_point(macos.msg_id(inner, 'contentView'), 'setBoundsOrigin:', macos.point(590, + 0)) + assert scroll_view_room(inner, true, 48) == 10 + assert scroll_view_room(outer, true, 48 - 10) == 38 + assert scroll_view_room(inner, true, -48) == -48 + assert scroll_view_room(inner, true, -1000) == -590 + // Neither has any along the axis it does not scroll, nor the outer back past its start. + assert scroll_view_room(inner, false, 48) == 0 + assert scroll_view_room(outer, true, -48) == 0 } fn test_macos_text_field_uses_native_bezel_without_layer_mask() { From 047bdced4209c49a71ad36604ee9348198278111 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 4 Oct 2026 01:35:32 +0300 Subject: [PATCH 13/13] Drop a UIKit scroll's offset along an axis its mode no longer scrolls --- ui/ui.v | 11 +++++++++++ ui/ui_test.v | 14 ++++++++++++++ uikit/ui_ios.v | 15 +++++++++++++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/ui/ui.v b/ui/ui.v index db7f4e9..9b3d35e 100644 --- a/ui/ui.v +++ b/ui/ui.v @@ -716,6 +716,17 @@ pub fn scroll_with_mode(id string, frame Rect, bg u32, mode ScrollMode, children } } +// scroll_mode_offset holds a scroll position to the axes a mode scrolls. Along an axis +// the mode leaves out the position is the start: anything else would keep the content +// shifted with no way to scroll it back. +fn scroll_mode_offset(mode ScrollMode, x f64, y f64) (f64, f64) { + return if mode == .vertical_only { 0.0 } else { x }, if mode == .horizontal_only { + 0.0 + } else { + y + } +} + // scroll_content_width is how far the children of a Scroll element reach to the // right, which is the width a sideways scroll has to cover. Hidden children take // no room. diff --git a/ui/ui_test.v b/ui/ui_test.v index 0aba08b..6e25f77 100644 --- a/ui/ui_test.v +++ b/ui/ui_test.v @@ -138,3 +138,17 @@ fn test_scroll_mode_defaults_to_vertical_and_content_width_follows_the_children( assert scroll_mode('horizontal')! == .horizontal_only assert scroll_mode('both')! == .both } + +fn test_scroll_mode_offset_keeps_only_the_axes_the_mode_scrolls() { + x, y := scroll_mode_offset(.both, 120, 80) + assert x == 120 + assert y == 80 + // A view scrolled both ways and then switched to one axis is brought back to the + // start of the other. + strip_x, strip_y := scroll_mode_offset(.horizontal_only, 120, 80) + assert strip_x == 120 + assert strip_y == 0 + list_x, list_y := scroll_mode_offset(.vertical_only, 120, 80) + assert list_x == 0 + assert list_y == 80 +} diff --git a/uikit/ui_ios.v b/uikit/ui_ios.v index c73c57d..f0f48f4 100644 --- a/uikit/ui_ios.v +++ b/uikit/ui_ios.v @@ -1297,13 +1297,24 @@ fn render_element(parent View, el Element, key string, mut active map[string]boo height := if el.scroll_mode != .horizontal_only { content_h + 16 } else { el.frame.height } macos.msg_void_rect(native, 'setContentSize:', macos.rect(width, height, 0, 0)) macos.msg_void_bool(native, 'setAlwaysBounceVertical:', el.scroll_mode != .horizontal_only) + // Where the view should be: where it is, or where it was before it was + // rebuilt. A view that is kept keeps its offset through a change of mode, and + // shrinking its content does not bring it back, so the part of either along an + // axis the mode no longer scrolls is dropped here. + current := scroll_content_offset(native) + mut wanted_x := current.x + mut wanted_y := current.y if el.id.len > 0 { if created && el.id in g_scroll_offsets { - set_scroll_content_offset(native, g_scroll_offsets_x[el.id] or { 0.0 }, - g_scroll_offsets[el.id]) + wanted_x = g_scroll_offsets_x[el.id] or { 0.0 } + wanted_y = g_scroll_offsets[el.id] } g_scroll_ids[el.id] = true } + offset_x, offset_y := scroll_mode_offset(el.scroll_mode, wanted_x, wanted_y) + if offset_x != current.x || offset_y != current.y { + set_scroll_content_offset(native, offset_x, offset_y) + } } else {} }