diff --git a/README.md b/README.md index 0911e0a..bc796a2 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,10 @@ Rectangle { } ``` +Set `transparent: true` to omit a box's fill. It also works on the root +`Screen`; a transparent screen reveals the desktop when its native host window +is configured for transparency, as demonstrated by `examples/custom_window`. + `border_width` is shorthand for all four edges; an explicitly declared edge overrides it. The equivalent V API fields live directly on `BoxStyle`. @@ -602,6 +606,9 @@ Typed-VML ports from `v-ui` include: separators, disabled and checked rows, and nested submenus. - `v run examples/tray_icon/main.v` — a status area icon whose menu sets a status, toggles a setting, and docks or hides the icon again. +- `v run examples/custom_window/main.v` — a borderless desktop widget with + ui2-drawn chrome, draggable content, and genuinely transparent space between + its rounded surfaces on the native macOS and Windows backends. Run the calculator demo with: diff --git a/appkit/ui_macos_darwin.v b/appkit/ui_macos_darwin.v index 0ac46db..7317cad 100644 --- a/appkit/ui_macos_darwin.v +++ b/appkit/ui_macos_darwin.v @@ -838,7 +838,7 @@ fn render_root(declared Element) { st.textview_ids = map[u64]string{} st.textview_action_ids = map[u64]string{} st.scroll_ids = map[u64]string{} - native_set_background(st.root_view, root.box.bg) + native_set_box_background(st.root_view, root.box) mut active := map[string]bool{} render_children(st.root_view, root.children, '', mut active) remove_stale_nodes(active) diff --git a/assets/fonts/MaterialIcons-LICENSE.txt b/assets/fonts/MaterialIcons-LICENSE.txt new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/assets/fonts/MaterialIcons-LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/assets/fonts/MaterialIcons-Regular.ttf b/assets/fonts/MaterialIcons-Regular.ttf new file mode 100644 index 0000000..9d09b0f Binary files /dev/null and b/assets/fonts/MaterialIcons-Regular.ttf differ diff --git a/assets/fonts/README.md b/assets/fonts/README.md index 6817f3f..952fbe2 100644 --- a/assets/fonts/README.md +++ b/assets/fonts/README.md @@ -3,8 +3,9 @@ Roboto 3.016, the `web/static` build from [googlefonts/roboto-classic](https://github.com/googlefonts/roboto-classic/releases/tag/v3.016), covering Latin, Greek, Cyrillic, and Vietnamese, Roboto Mono 3.001 from -[googlefonts/RobotoMono](https://github.com/googlefonts/RobotoMono), Noto Sans -Symbols 2 v2.008, the `unhinted` build from +[googlefonts/RobotoMono](https://github.com/googlefonts/RobotoMono), Material +Icons from [google/material-design-icons](https://github.com/google/material-design-icons), +Noto Sans Symbols 2 v2.008, the `unhinted` build from [notofonts/symbols](https://github.com/notofonts/symbols/releases/tag/NotoSansSymbols2-v2.008), and Noto Emoji 3.002 from [google/fonts](https://github.com/google/fonts/tree/main/ofl/notoemoji). @@ -18,7 +19,13 @@ finds its mono face by rewriting `-Regular` to `Mono-Regular` in the path it was given. It is also what an element asking for an unavailable fixed-pitch family, such as `Consolas` or the generic `monospace`, falls back to. -`NotoSansSymbols2-Regular.ttf` is not drawn with directly; it is the first face +`MaterialIcons-Regular.ttf` supplies portable equivalents for platform icon +names such as SF Symbols on a custom-rendered desktop. It is also at the front +of the fallback chain so its private-use glyphs can be drawn without depending +on a machine-installed icon font. + +`NotoSansSymbols2-Regular.ttf` is not drawn with directly; it follows Material +Icons in the fallback chain and is the first general-purpose symbol face the renderer searches when the text font has no outline for a code point. Roboto covers 927 of them, which is every letter an interface is written in and almost none of the marks it labels rows with, so a ▸ or a ✓ would otherwise come @@ -55,6 +62,9 @@ instructions, so the `hinted` build would only add bytes. For the same reason Noto Sans Symbols 2 is the `unhinted` build rather than the `googlefonts` one, which is the same outlines and twice the file. -All four families are licensed under the SIL Open Font License 1.1. They carry +Roboto, Roboto Mono, Noto Sans Symbols 2, and Noto Emoji are licensed under the +SIL Open Font License 1.1. They carry different copyright notices, so each keeps its own copy: `Roboto-OFL.txt`, `RobotoMono-OFL.txt`, `NotoSansSymbols2-OFL.txt` and `NotoEmoji-OFL.txt`. +Material Icons is licensed under Apache License 2.0, kept as +`MaterialIcons-LICENSE.txt`. diff --git a/examples/custom_window/README.md b/examples/custom_window/README.md new file mode 100644 index 0000000..ffc7fa7 --- /dev/null +++ b/examples/custom_window/README.md @@ -0,0 +1,24 @@ +# Custom window + +This example keeps the interface in ui2 while replacing the native desktop +frame with a small platform adapter: + +```sh +v run examples/custom_window +``` + +- macOS changes ui2's `NSWindow` to a borderless, non-opaque window and gives + the root `Screen` a transparent background. +- Windows changes ui2's `HWND` to a borderless layered window and color-keys + the otherwise unused `#010203` screen background. +- Linux and opt-in custom-rendered desktop builds show the same layout in a + normal window as a portable fallback. + +The header is an ordinary draggable ui2 `Rectangle`. Its pointer-down event is +handed to `performWindowDragWithEvent:` or `WM_NCLBUTTONDOWN`, so the operating +system still performs the move. The close button calls `ui2.quit()`. Everything +else—including the separated rounded surfaces and the clear space between +them—is declared in [`custom_window.qml`](custom_window.qml). + +For a resizable custom frame, keep the same pattern and add narrow draggable +ui2 regions at the edges, routing them to the platform's native resize action. diff --git a/examples/custom_window/custom_window.qml b/examples/custom_window/custom_window.qml new file mode 100644 index 0000000..d1d9681 --- /dev/null +++ b/examples/custom_window/custom_window.qml @@ -0,0 +1,73 @@ +Screen { + id: root + background: app.screen_background + transparent: app.transparent_screen + + Rectangle { + id: card_shadow + x: 16 + y: 14 + width: root.width - 32 + height: 122 + background: #020617 + corner_radius: 22 + } + + Rectangle { + id: card + x: 12 + y: 10 + width: root.width - 32 + height: 122 + background: #111827 + corner_radius: 22 + border_color: #334155 + border_width: 1 + + Rectangle { + id: window_drag + on_tap: "window_drag" + clickable: true + draggable: true + x: 0 + y: 0 + width: card.width + height: 48 + transparent: true + + Rectangle { x: 18 y: 18 width: 10 height: 10 background: #34D399 corner_radius: 5 } + Label { text: "FOCUS WIDGET" x: 38 y: 13 width: 200 height: 22 color: #E2E8F0 font_size: 12 bold: true } + Label { text: "drag anywhere on this header" x: 202 y: 14 width: card.width - 252 height: 20 align: right color: #64748B font_size: 10 } + } + + Button { id: close text: "×" on_tap: "close" x: card.width - 42 y: 10 width: 30 height: 30 background: #7F1D1D corner_radius: 15 color: #FEE2E2 font_size: 18 bold: true accessibility_label: "Close widget" } + Label { text: app.completed_label x: 20 y: 57 width: card.width - 40 height: 26 color: #F8FAFC font_size: 19 bold: true } + Label { text: app.platform_note x: 20 y: 91 width: card.width - 40 height: 18 color: #94A3B8 font_size: 10 } + } + + Rectangle { + id: action_shadow + x: root.width / 2 - 130 + y: 152 + width: 260 + height: 48 + background: #020617 + corner_radius: 24 + } + + Rectangle { + id: actions + x: root.width / 2 - 132 + y: 148 + width: 260 + height: 48 + background: #1E293B + corner_radius: 24 + border_color: #475569 + border_width: 1 + + Button { id: decrement text: "−" on_tap: "decrement" enabled: app.completed > 0 x: 8 y: 7 width: 50 height: 34 background: #334155 corner_radius: 17 color: #F8FAFC font_size: 18 bold: true accessibility_label: "Remove a completed session" } + Button { id: reset text: app.completed x: 68 y: 7 width: 120 height: 34 background: #0F172A corner_radius: 17 color: #A7F3D0 font_size: 16 bold: true accessibility_label: "Reset completed sessions" on_tap: "reset" } + Button { id: increment text: "+" on_tap: "increment" x: 198 y: 7 width: 54 height: 34 background: #047857 corner_radius: 17 color: #ECFDF5 font_size: 18 bold: true accessibility_label: "Complete a focus session" } + } +} diff --git a/examples/custom_window/custom_window_darwin.v b/examples/custom_window/custom_window_darwin.v new file mode 100644 index 0000000..7c88865 --- /dev/null +++ b/examples/custom_window/custom_window_darwin.v @@ -0,0 +1,72 @@ +module main + +import macos + +#flag darwin -framework AppKit + +const ns_window_style_borderless = u64(0) + +@[markused] +fn macos_configure_custom_window() bool { + window := macos_custom_window() + if macos_custom_window_is_nil(window) { + return false + } + // Borderless NSWindows normally refuse to become key windows. UI2 creates a + // small NSWindow subclass, so add the capability before dropping its frame. + window_class := macos.get_class('UI2Window') + if !macos_custom_window_is_nil(window_class) { + macos.add_method(window_class, 'canBecomeKey', voidptr(custom_window_can_become_key), 'B@:') + macos.add_method(window_class, 'canBecomeMainWindow', voidptr(custom_window_can_become_key), 'B@:') + } + macos.msg_void_u64(window, 'setStyleMask:', ns_window_style_borderless) + macos.msg_void_bool(window, 'setOpaque:', false) + macos.msg_void_bool(window, 'setHasShadow:', true) + macos.msg_void1(window, 'setBackgroundColor:', macos.msg_id(macos.get_class('NSColor'), 'clearColor')) + return true +} + +@[markused] +fn macos_drag_custom_window() { + window := macos_custom_window() + event := macos.msg_id(macos_custom_window_application(), 'currentEvent') + if macos_custom_window_is_nil(window) || macos_custom_window_is_nil(event) { + return + } + macos.msg_void1(window, 'performWindowDragWithEvent:', event) +} + +fn macos_custom_window() macos.Id { + app := macos_custom_window_application() + if macos_custom_window_is_nil(app) { + return macos_custom_window_nil() + } + for selector in ['keyWindow', 'mainWindow'] { + window := macos.msg_id(app, selector) + if !macos_custom_window_is_nil(window) { + return window + } + } + windows := macos.msg_id(app, 'windows') + if macos_custom_window_is_nil(windows) || macos.msg_u64(windows, 'count') == 0 { + return macos_custom_window_nil() + } + return macos.msg_id_u64(windows, 'objectAtIndex:', u64(0)) +} + +fn macos_custom_window_application() macos.Id { + return macos.msg_id(macos.get_class('NSApplication'), 'sharedApplication') +} + +fn macos_custom_window_is_nil(value macos.Id) bool { + return value == macos_custom_window_nil() +} + +fn macos_custom_window_nil() macos.Id { + return unsafe { nil } +} + +@[export: 'ui2_example_custom_window_can_become_key'] +fn custom_window_can_become_key(_self voidptr, _cmd voidptr) bool { + return true +} diff --git a/examples/custom_window/custom_window_test.v b/examples/custom_window/custom_window_test.v new file mode 100644 index 0000000..0f4cc12 --- /dev/null +++ b/examples/custom_window/custom_window_test.v @@ -0,0 +1,47 @@ +module main + +import ui2 + +fn find_custom_window_element(element ui2.Element, id string) ?ui2.Element { + if element.id == id { + return element + } + for child in element.children { + if found := find_custom_window_element(child, id) { + return found + } + } + return none +} + +fn test_custom_window_counter_never_becomes_negative() { + mut app := CustomWindowDemo{} + app.remove_completed() + assert app.completed == 0 + assert app.completed_label == 'Nothing completed yet' + app.add_completed() + assert app.completed == 1 + assert app.completed_label == '1 focus session completed' + app.add_completed() + assert app.completed_label == '2 focus sessions completed' + app.reset() + assert app.completed == 0 +} + +fn test_custom_window_qml_exposes_clear_space_and_custom_chrome() { + app := CustomWindowDemo{ + transparent_screen: true + screen_background: '#010203' + } + root := ui2.element_from_qml_model(custom_window_qml_source, app, ui2.rect(0, 0, custom_window_width, custom_window_height)) or { panic(err) } + ui2.validate_element_tree(root) or { panic(err) } + assert root.box.transparent + drag := find_custom_window_element(root, 'window_drag') or { panic('missing drag surface') } + assert drag.draggable && drag.clickable + assert drag.box.transparent + card := find_custom_window_element(root, 'card') or { panic('missing widget card') } + actions := find_custom_window_element(root, 'actions') or { panic('missing action island') } + assert card.frame.y + card.frame.height < actions.frame.y + assert card.box.radius == 22 + assert (find_custom_window_element(root, 'close') or { panic('missing close control') }).action_id == 'close' +} diff --git a/examples/custom_window/custom_window_windows.c.v b/examples/custom_window/custom_window_windows.c.v new file mode 100644 index 0000000..06f4705 --- /dev/null +++ b/examples/custom_window/custom_window_windows.c.v @@ -0,0 +1,17 @@ +module main + +#include + +#insert "@DIR/custom_window_windows.h" + +fn C.ui2_example_configure_custom_window(width int, height int) bool + +fn C.ui2_example_drag_custom_window() + +fn windows_configure_custom_window(width int, height int) bool { + return C.ui2_example_configure_custom_window(width, height) +} + +fn windows_drag_custom_window() { + C.ui2_example_drag_custom_window() +} diff --git a/examples/custom_window/custom_window_windows.h b/examples/custom_window/custom_window_windows.h new file mode 100644 index 0000000..724d67b --- /dev/null +++ b/examples/custom_window/custom_window_windows.h @@ -0,0 +1,49 @@ +#ifndef UI2_EXAMPLE_CUSTOM_WINDOW_WINDOWS_H +#define UI2_EXAMPLE_CUSTOM_WINDOW_WINDOWS_H + +#include + +static HWND ui2_example_custom_window = NULL; + +static BOOL CALLBACK ui2_example_find_custom_window(HWND window, LPARAM output) { + wchar_t class_name[32]; + DWORD process_id = 0; + GetWindowThreadProcessId(window, &process_id); + if (process_id != GetCurrentProcessId()) return TRUE; + if (GetClassNameW(window, class_name, 32) <= 0) return TRUE; + if (lstrcmpW(class_name, L"UI2Window") != 0) return TRUE; + *((HWND *)output) = window; + return FALSE; +} + +static HWND ui2_example_find_main_window(void) { + HWND window = NULL; + EnumWindows(ui2_example_find_custom_window, (LPARAM)&window); + return window; +} + +static inline bool ui2_example_configure_custom_window(int width, int height) { + HWND window = ui2_example_find_main_window(); + if (window == NULL) return false; + ui2_example_custom_window = window; + SetWindowLongPtrW(window, GWL_STYLE, WS_POPUP | WS_CLIPCHILDREN | WS_CLIPSIBLINGS); + SetWindowLongPtrW(window, GWL_EXSTYLE, + GetWindowLongPtrW(window, GWL_EXSTYLE) | WS_EX_LAYERED | WS_EX_APPWINDOW); + // ui2 paints #010203 outside the cards. The layered host removes precisely + // those pixels, leaving the independent rounded surfaces visible. + SetLayeredWindowAttributes(window, RGB(1, 2, 3), 0, LWA_COLORKEY); + SetWindowPos(window, NULL, 0, 0, width, height, + SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); + return true; +} + +static inline void ui2_example_drag_custom_window(void) { + if (ui2_example_custom_window == NULL) { + ui2_example_custom_window = ui2_example_find_main_window(); + } + if (ui2_example_custom_window == NULL) return; + ReleaseCapture(); + SendMessageW(ui2_example_custom_window, WM_NCLBUTTONDOWN, HTCAPTION, 0); +} + +#endif diff --git a/examples/custom_window/main.v b/examples/custom_window/main.v new file mode 100644 index 0000000..8904ede --- /dev/null +++ b/examples/custom_window/main.v @@ -0,0 +1,99 @@ +module main + +import ui2 + +const custom_window_width = 440 +const custom_window_height = 210 +const custom_window_qml_source = $embed_file('custom_window.qml').to_string() + +@[heap] +pub struct CustomWindowDemo { +pub mut: + completed int + completed_label string = 'Nothing completed yet' + platform_note string + transparent_screen bool + screen_background string = '#0F172A' +mut: + window_ready bool +} + +const custom_window_state = &CustomWindowDemo{} + +fn (mut app CustomWindowDemo) prepare_window() { + if !app.window_ready { + app.window_ready = configure_custom_window() + if !app.window_ready { + ui2.request_refresh() + } + } + app.transparent_screen = custom_window_uses_transparent_screen() + app.screen_background = custom_window_screen_background() + app.platform_note = custom_window_platform_note() +} + +pub fn (mut app CustomWindowDemo) add_completed() { + app.completed++ + app.update_completed_label() +} + +pub fn (mut app CustomWindowDemo) remove_completed() { + if app.completed > 0 { + app.completed-- + } + app.update_completed_label() +} + +pub fn (mut app CustomWindowDemo) reset() { + app.completed = 0 + app.update_completed_label() +} + +fn (mut app CustomWindowDemo) update_completed_label() { + app.completed_label = match app.completed { + 0 { 'Nothing completed yet' } + 1 { '1 focus session completed' } + else { '${app.completed} focus sessions completed' } + } +} + +fn custom_window_pointer(raw string) ?(string, string) { + parts := raw.split(':') + if parts.len < 3 || parts[0] != 'pointer' { + return none + } + return parts[1], parts[2] +} + +fn build_custom_window_screen() ui2.Element { + mut state := unsafe { custom_window_state } + state.prepare_window() + return ui2.element_from_qml_model(custom_window_qml_source, *state, ui2.bounds()) or { + eprintln('custom-window QML failed: ${err}') + ui2.screen(0x0f172a, []) + } +} + +fn handle_custom_window_event(event string) { + mut state := unsafe { custom_window_state } + match event { + 'increment' { state.add_completed() } + 'decrement' { state.remove_completed() } + 'reset' { state.reset() } + 'close' { + ui2.quit() + return + } + else { + phase, id := custom_window_pointer(event) or { return } + if phase == 'down' && id == 'window_drag' { + drag_custom_window() + } + } + } + ui2.refresh() +} + +fn main() { + ui2.run_window('ui2 custom window', custom_window_width, custom_window_height, build_custom_window_screen, handle_custom_window_event) +} diff --git a/examples/custom_window/platform.v b/examples/custom_window/platform.v new file mode 100644 index 0000000..f07f23b --- /dev/null +++ b/examples/custom_window/platform.v @@ -0,0 +1,44 @@ +module main + +fn configure_custom_window() bool { + $if macos && !ui2_custom_rendering ? { + return macos_configure_custom_window() + } $else $if windows && !ui2_custom_rendering ? { + return windows_configure_custom_window(custom_window_width, custom_window_height) + } $else { + return true + } +} + +fn drag_custom_window() { + $if macos && !ui2_custom_rendering ? { + macos_drag_custom_window() + } $else $if windows && !ui2_custom_rendering ? { + windows_drag_custom_window() + } +} + +fn custom_window_uses_transparent_screen() bool { + $if macos && !ui2_custom_rendering ? { + return true + } $else { + return false + } +} + +fn custom_window_screen_background() string { + $if windows && !ui2_custom_rendering ? { + // The Win32 adapter treats this otherwise-unused color as transparent. + return '#010203' + } $else { + return '#0F172A' + } +} + +fn custom_window_platform_note() string { + $if ( macos || windows ) && !ui2_custom_rendering ? { + return 'The empty area around these cards is the desktop.' + } $else { + return 'Frameless transparency is demonstrated by the native macOS and Windows backends.' + } +} diff --git a/ui/ui_custom_test.v b/ui/ui_custom_test.v index 599e550..a667b6a 100644 --- a/ui/ui_custom_test.v +++ b/ui/ui_custom_test.v @@ -60,6 +60,44 @@ $if ui2_custom_rendering ? { assert pointer_event_id('up', 'surface', 40, 50) == 'pointer:up:surface:40.0:50.0' } + fn test_custom_button_images_match_native_arrangements() { + image_only := button_image_layout(30, 28, '', 'symbol:gearshape') + assert image_only.visible + assert image_only.image == rect(6, 5, 18, 18) + assert image_only.text.width == 0 + + compact_image_only := button_image_layout(20, 17, '', 'symbol:scissors') + assert compact_image_only.image == rect(3.5, 2, 13, 13) + + tall_image_only := button_image_layout(44, 58, '', '/tmp/paste.png') + assert tall_image_only.image == rect(6, 13, 32, 32) + + compact := button_image_layout(100, 24, 'Open', '/tmp/open.png') + assert compact.image == rect(5, 5.5, 13, 13) + assert compact.text == rect(22, 0, 74, 24) + assert compact.has_title_area() + + narrow_compact := button_image_layout(26, 24, 'Open', '/tmp/open.png') + assert narrow_compact.text.width == 0 + assert !narrow_compact.has_title_area() + + tall := button_image_layout(60, 62, 'Paste', '/tmp/paste.png') + assert tall.image == rect(14, 4, 32, 32) + assert tall.text == rect(2, 39, 56, 20) + + text_only := button_image_layout(80, 28, 'Normal', '') + assert !text_only.visible + assert text_only.text == rect(0, 0, 80, 28) + } + + fn test_custom_renderer_has_portable_system_symbol_fallbacks() { + assert system_symbol_fallback('arrow.uturn.backward') == '' + assert system_symbol_fallback('gearshape') == '' + assert system_symbol_fallback('magnifyingglass') == '' + assert system_symbol_fallback('xmark') == '' + assert system_symbol_fallback('future.symbol') == '' + } + fn test_custom_slider_pointer_value_uses_range_step_and_orientation() { horizontal := HitTarget{ slider: true @@ -442,29 +480,4 @@ $if ui2_custom_rendering ? { assert !touch_is_held_inside(10, 10, 100, 30) g_touch = TouchState{} } - - // Ten pixels a character, ellipsis included, so a width is a character - // count and the expected truncation can be read off the assertion. - fn custom_test_text_width(line string) f64 { - return f64(line.runes().len) * 10 - } - - fn test_custom_text_that_fits_is_drawn_whole() { - assert fit_text_to_width('abcde', 50, custom_test_text_width) == 'abcde' - assert fit_text_to_width('abcde', 500, custom_test_text_width) == 'abcde' - assert fit_text_to_width('', 0, custom_test_text_width) == '' - } - - fn test_custom_text_wider_than_its_box_ends_in_an_ellipsis() { - // Four characters of room: three of the word plus the ellipsis. - assert fit_text_to_width('abcde', 40, custom_test_text_width) == 'abc…' - // Room for the ellipsis alone, and for less than that. - assert fit_text_to_width('abcde', 10, custom_test_text_width) == '…' - assert fit_text_to_width('abcde', 5, custom_test_text_width) == '…' - } - - fn test_custom_text_is_shortened_by_whole_runes() { - // A multi-byte rune has to be dropped as one character, not as bytes. - assert fit_text_to_width('éééé', 30, custom_test_text_width) == 'éé…' - } } diff --git a/ui/ui_font.v b/ui/ui_font.v index 23d3d56..7ef6726 100644 --- a/ui/ui_font.v +++ b/ui/ui_font.v @@ -269,8 +269,8 @@ fn font_mono_preferences() []string { // two overlap around the dingbats, and a ✔ beside a line of text should be the // text-weight one rather than the emoji. fn font_symbol_families() []string { - return ['Noto Sans Symbols 2', 'Noto Sans Symbols', 'Segoe UI Symbol', 'Apple Symbols', - 'Symbola', 'Noto Emoji'] + return ['Material Icons', 'Noto Sans Symbols 2', 'Noto Sans Symbols', 'Segoe UI Symbol', + 'Apple Symbols', 'Symbola', 'Noto Emoji'] } // font_color_emoji_families lists the faces that keep their emoji as bitmaps or @@ -279,8 +279,7 @@ fn font_symbol_families() []string { // empty, so searching one would trade the empty box for an empty space. They // are named only so the renderer does not settle on one for its text. fn font_color_emoji_families() []string { - return ['Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Twemoji Mozilla', - 'JoyPixels'] + return ['Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Twemoji Mozilla', 'JoyPixels'] } // font_symbol_fallback_families ranks every face worth looking in for a glyph diff --git a/ui/ui_font_test.v b/ui/ui_font_test.v index 0e7010f..9067d74 100644 --- a/ui/ui_font_test.v +++ b/ui/ui_font_test.v @@ -335,13 +335,15 @@ fn test_mono_fallback_reports_nothing_when_no_mono_face_exists() { // around the dingbats, and the text-weight glyph is the one to draw. fn test_the_bundled_faces_lead_the_fallback_chain() { paths := font_symbol_paths() - assert paths.len >= 2 - assert os.file_name(paths[0]) == 'NotoSansSymbols2-Regular.ttf' - assert os.file_name(paths[1]) == 'NotoEmoji-Regular.ttf' - for path in paths[..2] { + assert paths.len >= 3 + assert os.file_name(paths[0]) == 'MaterialIcons-Regular.ttf' + assert os.file_name(paths[1]) == 'NotoSansSymbols2-Regular.ttf' + assert os.file_name(paths[2]) == 'NotoEmoji-Regular.ttf' + for path in paths[..3] { font_file_metrics(path)! } dir := os.dir(paths[0]) + assert os.is_file(os.join_path(dir, 'MaterialIcons-LICENSE.txt')) assert os.is_file(os.join_path(dir, 'NotoSansSymbols2-OFL.txt')) assert os.is_file(os.join_path(dir, 'NotoEmoji-OFL.txt')) } @@ -382,11 +384,11 @@ fn test_ui2_font_symbols_is_searched_before_the_bundled_face() { } paths := font_symbol_paths() assert paths[0] == regular - assert os.file_name(paths[1]) == 'NotoSansSymbols2-Regular.ttf' + assert os.file_name(paths[1]) == 'MaterialIcons-Regular.ttf' // A path that names no file is ignored rather than searched. os.setenv('UI2_FONT_SYMBOLS', os.join_path(os.temp_dir(), 'ui2_no_such_font.ttf'), true) - assert os.file_name(font_symbol_paths()[0]) == 'NotoSansSymbols2-Regular.ttf' + assert os.file_name(font_symbol_paths()[0]) == 'MaterialIcons-Regular.ttf' } // A face made of symbols or emoji has no letters in it, so a window that settled @@ -394,17 +396,17 @@ fn test_ui2_font_symbols_is_searched_before_the_bundled_face() { // emoji faces are named for that reason alone: they are never searched for a // missing glyph, since stb_truetype cannot read a bitmap or a layered one. fn test_a_symbol_face_is_never_settled_on_for_text() { - for family in ['Noto Sans Symbols 2', 'NotoSansSymbols2-Regular', 'Noto Sans Symbols', - 'Segoe UI Symbol', 'Apple Symbols', 'Symbola', 'Noto Emoji', 'NotoEmoji-Regular', - 'Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji'] { + for family in ['Material Icons', 'MaterialIcons-Regular', 'Noto Sans Symbols 2', + 'NotoSansSymbols2-Regular', 'Noto Sans Symbols', 'Segoe UI Symbol', 'Apple Symbols', 'Symbola', + 'Noto Emoji', 'NotoEmoji-Regular', 'Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji'] { assert font_is_symbol_family(family), '${family} should read as a symbol face' } for family in ['Roboto', 'Noto Sans', 'DejaVu Sans', 'Inter', 'Arial', ''] { assert !font_is_symbol_family(family), '${family} should read as a text face' } - dir := font_test_dir('symbols', ['NotoSansSymbols2-Regular.ttf', 'NotoEmoji-Regular.ttf', - 'Roboto-Regular.ttf']) + dir := font_test_dir('symbols', ['MaterialIcons-Regular.ttf', 'NotoSansSymbols2-Regular.ttf', + 'NotoEmoji-Regular.ttf', 'Roboto-Regular.ttf']) defer { os.rmdir_all(dir) or {} } @@ -412,8 +414,8 @@ fn test_a_symbol_face_is_never_settled_on_for_text() { // check keeps the search from settling on it. assert font_fallback(font_index([dir])) == os.join_path(dir, 'Roboto-Regular.ttf') - only_symbols := font_test_dir('symbols_only', ['NotoSansSymbols2-Regular.ttf', - 'NotoEmoji-Regular.ttf']) + only_symbols := font_test_dir('symbols_only', ['MaterialIcons-Regular.ttf', + 'NotoSansSymbols2-Regular.ttf', 'NotoEmoji-Regular.ttf']) defer { os.rmdir_all(only_symbols) or {} } diff --git a/ui/ui_immediate.c.v b/ui/ui_immediate.c.v index 71a7546..79b3ffc 100644 --- a/ui/ui_immediate.c.v +++ b/ui/ui_immediate.c.v @@ -7,6 +7,7 @@ module ui2 $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2_headless ? { import fontstash import gg + import math import os import sokol.sapp import time @@ -1520,7 +1521,9 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 .image { x := el.frame.x + off_x y := el.frame.y + off_y - if !draw_cached_image(ctx, el.image_path, x, y, el.frame.width, el.frame.height, el.rotation) { + if el.image_path.trim_space().len > 0 + && !draw_cached_image(ctx, el.image_path, x, y, el.frame.width, el.frame.height, + el.rotation) { draw_rect(ctx, x, y, el.frame.width, el.frame.height, 0xe8ecef, 0) } if el.enabled && element_action_id(el).len > 0 && (el.clickable || el.draggable) { @@ -1546,7 +1549,17 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 draw_rect(ctx, x, y, el.frame.width, el.frame.height, el.box.bg, el.box.radius) } draw_box_borders(ctx, x, y, el.frame.width, el.frame.height, el.box) - draw_text_centered(ctx, el.text, x, y, el.frame.width, el.frame.height, el.text_style) + image_layout := button_image_layout(el.frame.width, el.frame.height, el.text, + el.image_path) + if image_layout.visible { + draw_button_image(ctx, el.image_path, x + image_layout.image.x, + y + image_layout.image.y, image_layout.image.width, image_layout.image.height, + el.text_style) + } + if el.text.len > 0 && image_layout.has_title_area() { + draw_text_centered(ctx, el.text, x + image_layout.text.x, y + image_layout.text.y, + image_layout.text.width, image_layout.text.height, el.text_style) + } if el.enabled { add_hit_target(HitTarget{ id: el.id @@ -1959,11 +1972,173 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 return true } + struct ButtonImageLayout { + visible bool + image Rect + text Rect + } + + fn (layout ButtonImageLayout) has_title_area() bool { + return layout.text.width > 0 && layout.text.height > 0 + } + + // button_image_layout mirrors the native desktop button arrangements: compact + // buttons put a 13-point image beside their title, while tall ribbon buttons + // put a larger image above it. Image-only title-bar buttons stay centered. + fn button_image_layout(width f64, height f64, title string, image_path string) ButtonImageLayout { + if image_path.trim_space().len == 0 { + return ButtonImageLayout{ + text: rect(0, 0, width, height) + } + } + if title.len == 0 { + // Image-only ribbon controls still use the large-button treatment when + // they are tall. Compact controls need only a two-point inset on each + // side; the previous four-point inset made 17px toolbar symbols nearly + // illegible on Windows. + size := if height >= 42 { + math.min(32.0, math.max(math.min(width - 8.0, height - 12.0), 1.0)) + } else { + math.min(18.0, math.max(math.min(width, height) - 4.0, 1.0)) + } + return ButtonImageLayout{ + visible: true + image: rect((width - size) / 2, (height - size) / 2, size, size) + text: rect(0, 0, 0, 0) + } + } + if height >= 42 { + size := math.min(32.0, math.max(math.min(width, height - 22.0) - 8.0, 1.0)) + return ButtonImageLayout{ + visible: true + image: rect((width - size) / 2, 4, size, size) + text: rect(2, height - 23, math.max(width - 4.0, 0.0), 20) + } + } + size := math.min(13.0, math.max(height - 8.0, 1.0)) + return ButtonImageLayout{ + visible: true + image: rect(5, (height - size) / 2, size, size) + text: rect(size + 9, 0, math.max(width - size - 13.0, 0.0), height) + } + } + + fn draw_button_image(ctx &gg.Context, image_path string, x f64, y f64, width f64, height f64, style TextStyle) { + if image_path.starts_with('symbol:') { + symbol := system_symbol_fallback(image_path['symbol:'.len..]) + draw_text_centered(ctx, symbol, x, y, width, height, TextStyle{ + ...style + font_family: 'Material Icons' + size: math.max(math.min(width, height) * 0.75, 8.0) + }) + return + } + if !draw_cached_image(ctx, image_path, x, y, width, height, 0) { + draw_outline(ctx, x, y, width, height, 0x94a3b8, 2) + } + } + + // SF Symbols are used as native AppKit button images. Other custom-rendered + // desktops use equivalent glyphs from the bundled Material Icons face, which + // keeps the controls recognizable without depending on a host symbol font. + fn system_symbol_fallback(name string) string { + return match name { + 'align.horizontal.center' { '\ue00f' } + 'line.3.horizontal' { '\ue5d2' } + 'text.justify' { '\ue235' } + 'align.vertical.center' { '\ue011' } + 'arrow.up.and.down', 'arrow.up.arrow.down' { '\ue8d5' } + 'arrow.up.and.down.text.horizontal' { '\ue25b' } + 'arrow.2.squarepath', 'arrow.triangle.2.circlepath' { '\ue627' } + 'arrow.left.and.right' { '\ue8d4' } + 'arrow.left.and.right.square' { '\ue933' } + 'arrow.clockwise', 'arrow.clockwise.circle' { '\ue5d5' } + 'clock.arrow.circlepath' { '\ue889' } + 'arrow.down.right.and.arrow.up.left', 'arrow.up.and.down.and.arrow.left.and.right', + 'move.3d' { '\uf1ce' } + 'arrow.down.to.line' { '\ue258' } + 'icloud.and.arrow.down' { '\ue2c0' } + 'arrow.right.to.line', 'increase.indent' { '\ue23e' } + 'arrow.uturn.backward' { '\ue166' } + 'arrow.uturn.forward' { '\ue15a' } + 'icloud.and.arrow.up' { '\ue2c3' } + 'square.and.arrow.up' { '\ue2c6' } + 'square.and.arrow.down' { '\ue161' } + 'asterisk' { '\ue83a' } + 'character.book.closed' { '\uea19' } + 'textformat', 'textformat.abc' { '\ue262' } + 'chart.bar', 'chart.bar.doc.horizontal', 'chart.bar.xaxis' { '\ue26b' } + 'crop' { '\ue3be' } + 'cube.transparent' { '\ue9fe' } + 'square.on.circle' { '\ue574' } + 'curlybraces' { '\ue86f' } + 'cursorarrow' { '\ue323' } + 'doc.badge.arrow.up' { '\ue9fc' } + 'doc.badge.plus', 'text.badge.plus' { '\ue89c' } + 'plus.rectangle' { '\ue146' } + 'plus.rectangle.on.rectangle', 'plus.square.on.square' { '\ue02e' } + 'doc.on.doc' { '\ue14d' } + 'list.bullet.clipboard' { '\ue85d' } + 'ellipsis' { '\ue5d3' } + 'eye' { '\ue8f4' } + 'eye.slash' { '\ue8f5' } + 'folder' { '\ue2c7' } + 'function' { '\ue24a' } + 'gearshape' { '\ue8b8' } + 'grid', 'rectangle.grid.2x2', 'rectangle.split.3x3', 'tablecells' { '\ue3ec' } + 'info.circle' { '\ue88e' } + 'line.vertical' { '\ue5d4' } + 'list.number' { '\ue242' } + 'number' { '\ue9ef' } + 'lock', 'lock.doc' { '\ue897' } + 'magnifyingglass', 'rectangle.and.text.magnifyingglass' { '\ue8b6' } + 'minus' { '\ue15b' } + 'minus.rectangle' { '\ue909' } + 'number.circle' { '\ue400' } + 'paintbrush' { '\ue3ae' } + 'paintpalette' { '\ue40a' } + 'pencil', 'pencil.line' { '\ue3c9' } + 'person.2' { '\ue7ef' } + 'person.crop.circle' { '\ue853' } + 'person.crop.circle.badge.plus' { '\ue7fe' } + 'photo' { '\ue410' } + 'textbox' { '\ue262' } + 'plus' { '\ue145' } + 'printer' { '\ue8ad' } + 'rectangle.3.group' { '\ue8f0' } + 'rectangle.split.1x2' { '\uf114' } + 'rectangle.split.2x1' { '\ue8f2' } + 'rectangle.split.3x1' { '\ue8ec' } + 'rectangle.bottomthird.inset.filled', 'rectangle.portrait.and.arrow.right', + 'rectangle.righthalf.inset.filled.arrow.right', + 'rectangle.topthird.inset.filled', 'sidebar.right' { '\uf114' } + 'scissors' { '\ue14e' } + 'seal' { '\uef76' } + 'slider.horizontal.3' { '\ue429' } + 'square.2.layers.3d.bottom.filled', 'square.2.layers.3d.top.filled', + 'square.stack.3d.up' { '\ue53b' } + 'star' { '\ue838' } + 'star.circle' { '\ue8d0' } + 'tag' { '\ue892' } + 'text.alignleft' { '\ue236' } + 'text.alignright' { '\ue237' } + 'text.bubble' { '\ue0cb' } + 'textformat.123' { '\ueb8d' } + 'textformat.size' { '\ue245' } + 'textformat.size.larger' { '\ueae2' } + 'trash' { '\ue872' } + 'xmark' { '\ue5cd' } + else { '\ue8fd' } + } + } + fn preload_images(el Element) { if el.hidden { return } - if el.kind == .image { + if el.kind == .image + || (el.kind == .button && el.image_path.trim_space().len > 0 + && !el.image_path.starts_with('symbol:')) { cache_image(el.image_path) } for child in el.children { @@ -2275,15 +2450,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 return t } ctx.set_text_cfg(cfg) - return fit_text_to_width(t, w, fn [ctx] (line string) f64 { - return f64(ctx.text_width_f(line)) - }) - } - - // fit_text_to_width is the search fit_text runs. It takes the measurement - // as an argument so it can be tested without a window to measure in. - fn fit_text_to_width(t string, w f64, text_width fn (string) f64) string { - if text_width(t) <= w { + if f64(ctx.text_width_f(t)) <= w { return t } runes := t.runes() @@ -2294,7 +2461,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 mut high := runes.len for kept < high { mid := (kept + high + 1) / 2 - if text_width(runes[..mid].string() + text_ellipsis) <= w { + if f64(ctx.text_width_f(runes[..mid].string() + text_ellipsis)) <= w { kept = mid } else { high = mid - 1 diff --git a/ui/vml.v b/ui/vml.v index dc90405..41923ea 100644 --- a/ui/vml.v +++ b/ui/vml.v @@ -716,7 +716,10 @@ fn node_to_element_base(node &VNode, frame Rect) !Element { local := rect(0, 0, frame.width, frame.height) match node.tag { 'Screen' { - return screen(v_color(node, 'background', 0xffffff), v_children(node, local)!) + return Element{ + ...screen(v_color(node, 'background', 0xffffff), v_children(node, local)!) + box: v_box(node) + } } 'Column' { return v_column(node, frame)! @@ -1685,6 +1688,7 @@ fn v_box(node &VNode) BoxStyle { return BoxStyle{ bg: v_color(node, 'background', 0xffffff) radius: node.prop_or('corner_radius', node.prop_or('radius', '0')).f64() + transparent: node.prop_bool('transparent') border_color: v_color(node, 'border_color', 0) border_left: node.prop_or('border_left', border_width).f64() border_top: node.prop_or('border_top', border_width).f64()