├── .builds └── linux.yml ├── .gitignore ├── LICENSE ├── README.md ├── colorpicker ├── README.md ├── color.go └── img │ └── screenshot.jpg ├── component ├── README.md ├── animation.go ├── app_bar.go ├── color.go ├── color_test.go ├── context-area.go ├── discloser.go ├── doc.go ├── grid.go ├── img │ ├── app-bar-top-contextual.png │ ├── app-bar-top.png │ ├── menu1.png │ ├── menu2.png │ ├── modal-nav.png │ └── tooltip.png ├── materials.go ├── menu.go ├── modal_layer.go ├── nav_drawer.go ├── resizer.go ├── scrim.go ├── shadow.go ├── sheet.go ├── text_field.go ├── tooltip.go ├── tooltip_desktop.go ├── tooltip_mobile.go └── truncating_label.go ├── debug └── debug.go ├── explorer ├── README.md ├── explorer.go ├── explorer_android.go ├── explorer_android.jar ├── explorer_android.java ├── explorer_ios.go ├── explorer_ios.m ├── explorer_js.go ├── explorer_linux.go ├── explorer_macos.go ├── explorer_macos.m ├── explorer_unsupported.go ├── explorer_windows.go ├── file_android.go ├── file_android.jar ├── file_android.java ├── file_darwin.go └── file_darwin.m ├── go.mod ├── go.sum ├── haptic ├── README.md ├── doc.go ├── haptic_android.go ├── haptic_default.go └── haptic_ios.go ├── markdown └── markdown.go ├── notify ├── README.md ├── android │ ├── NotificationHelper.jar │ ├── NotificationHelper.java │ └── notify_android.go ├── cmd │ └── png2bmp │ │ └── main.go ├── macos │ ├── niotify_test.go │ └── notify_macos.go ├── notification_manager.go ├── notify_android.go ├── notify_darwin.go ├── notify_dbus.go ├── notify_unsupported.go └── notify_windows.go ├── outlay ├── README.md ├── anim.go ├── doc.go ├── fan.go ├── flow.go ├── grid.go ├── grid_test.go ├── if.go ├── localeflex.go ├── localeinset.go ├── multi-list.go ├── rigid-rows.go └── spacer.go ├── pref ├── README.md ├── battery │ ├── battery.go │ ├── battery_android.go │ ├── battery_android.jar │ ├── battery_android.java │ ├── battery_js.go │ ├── battery_linux.go │ ├── battery_unsupported.go │ └── battery_windows.go ├── internal │ └── xjni │ │ └── jni_android.go ├── locale │ ├── locale.go │ ├── locale_android.go │ ├── locale_android.jar │ ├── locale_android.java │ ├── locale_js.go │ ├── locale_linux.go │ ├── locale_unsupported.go │ └── locale_windows.go └── theme │ ├── theme.go │ ├── theme_android.go │ ├── theme_android.jar │ ├── theme_android.java │ ├── theme_js.go │ ├── theme_unsupported.go │ └── theme_windows.go ├── richtext ├── README.md ├── example_richtext_test.go ├── richtext.go └── richtext_test.go ├── stroke ├── clip_test.go ├── refs │ ├── TestDashedPathFlatCapEllipse.png │ ├── TestDashedPathFlatCapZ.png │ ├── TestDashedPathFlatCapZNoDash.png │ ├── TestStrokedPathArc.png │ ├── TestStrokedPathBalloon.png │ ├── TestStrokedPathBevelFlat.png │ ├── TestStrokedPathBevelRound.png │ ├── TestStrokedPathBevelSquare.png │ ├── TestStrokedPathCoincidentControlPoint.png │ ├── TestStrokedPathFlatMiter.png │ ├── TestStrokedPathFlatMiterInf.png │ └── TestStrokedPathZeroWidth.png ├── stroke.go └── util_test.go └── styledtext ├── README.md ├── iterator.go ├── styledtext.go └── styledtext_test.go /.builds/linux.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Unlicense OR MIT 2 | image: debian/testing 3 | packages: 4 | - curl 5 | - pkg-config 6 | - libwayland-dev 7 | - libx11-dev 8 | - libx11-xcb-dev 9 | - libxkbcommon-dev 10 | - libxkbcommon-x11-dev 11 | - libgles2-mesa-dev 12 | - libegl1-mesa-dev 13 | - libffi-dev 14 | - libxcursor-dev 15 | - libxrandr-dev 16 | - libxinerama-dev 17 | - libxi-dev 18 | - libxxf86vm-dev 19 | - libvulkan-dev 20 | - wine 21 | - xvfb 22 | - xdotool 23 | - scrot 24 | - sway 25 | - grim 26 | - wine 27 | - unzip 28 | sources: 29 | - https://git.sr.ht/~whereswaldon/gio-x 30 | triggers: 31 | - action: email 32 | condition: failure 33 | to: ~whereswaldon/public-inbox@lists.sr.ht 34 | environment: 35 | GOFLAGS: -mod=readonly 36 | PATH: /home/build/sdk/go/bin:/usr/bin:/home/build/go/bin 37 | github_mirror: git@github.com:gioui/gio-x 38 | secrets: 39 | - 4d571621-ab6e-457e-a8d9-a4fab24a9794 40 | tasks: 41 | - install_go: | 42 | mkdir -p /home/build/sdk 43 | cd /home/build/sdk 44 | curl -Lso go.tar.gz https://golang.org/dl/go1.21.0.linux-amd64.tar.gz 45 | echo "d0398903a16ba2232b389fb31032ddf57cac34efda306a0eebac34f0965a0742 go.tar.gz" | sha256sum -c - 46 | tar xzf go.tar.gz 47 | - test_gio: | 48 | cd gio-x 49 | go test -race ./... 50 | - check_gofmt: | 51 | cd gio-x 52 | test -z "$(gofmt -s -l .)" 53 | - check_sign_off: | 54 | set +x -e 55 | cd gio-x 56 | for hash in $(git log -n 10 --format="%H"); do 57 | message=$(git log -1 --format=%B $hash) 58 | if [[ ! "$message" =~ "Signed-off-by: " ]]; then 59 | echo "Missing 'Signed-off-by' in commit $hash" 60 | exit 1 61 | fi 62 | done 63 | - mirror: | 64 | # mirror to github 65 | ssh-keyscan github.com > "$HOME"/.ssh/known_hosts && cd gio-x && git push --mirror "$github_mirror" || echo "failed mirroring" 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.apk 2 | *.class 3 | example/example 4 | example/example.app/Contents/MacOS/example 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This project is dual-licensed under the UNLICENSE or 2 | the MIT license with the SPDX identifier: 3 | 4 | SPDX-License-Identifier: Unlicense OR MIT 5 | 6 | You may use the project under the terms of either license. 7 | 8 | Both licenses are reproduced below. 9 | 10 | ---- 11 | The MIT License (MIT) 12 | 13 | Copyright (c) 2019 The gio authors 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy 16 | of this software and associated documentation files (the "Software"), to deal 17 | in the Software without restriction, including without limitation the rights 18 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | copies of the Software, and to permit persons to whom the Software is 20 | furnished to do so, subject to the following conditions: 21 | 22 | The above copyright notice and this permission notice shall be included in 23 | all copies or substantial portions of the Software. 24 | 25 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 31 | THE SOFTWARE. 32 | --- 33 | 34 | 35 | 36 | --- 37 | The UNLICENSE 38 | 39 | This is free and unencumbered software released into the public domain. 40 | 41 | Anyone is free to copy, modify, publish, use, compile, sell, or 42 | distribute this software, either in source code form or as a compiled 43 | binary, for any purpose, commercial or non-commercial, and by any 44 | means. 45 | 46 | In jurisdictions that recognize copyright laws, the author or authors 47 | of this software dedicate any and all copyright interest in the 48 | software to the public domain. We make this dedication for the benefit 49 | of the public at large and to the detriment of our heirs and 50 | successors. We intend this dedication to be an overt act of 51 | relinquishment in perpetuity of all present and future rights to this 52 | software under copyright law. 53 | 54 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 55 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 56 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 57 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 58 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 59 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 60 | OTHER DEALINGS IN THE SOFTWARE. 61 | 62 | For more information, please refer to 63 | --- 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## gio-x 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/gioui.org/x.svg)](https://pkg.go.dev/gioui.org/x) 4 | 5 | This repository hosts `gioui.org/x`. Two kinds of package exist in this namespace. Some are extensions that will eventually be merged into `gioui.org`'s core repository once their APIs stabilize and their value to the community is proven. The rest are extensions to Gio that are not likely to be needed by every application and require new dependencies. These will likely never be merged to the core repository, but will be maintained here. 6 | 7 | This table describes the current status of each package in `gioui.org/x`: 8 | 9 | | Name | Purpose | Intended for core? | Non-core dependencies? | API Stability | 10 | | ----------- | ------------------------------------------- | ------------------ | ---------------------- | ------------- | 11 | | colorpicker | Widgets for choosing colors | uncertain | no | unstable | 12 | | component | Material.io components | uncertain | no | unstable | 13 | | haptic | Haptic feedback for mobile devices | no | yes | unstable | 14 | | notify | Background notifications | no | yes | unstable | 15 | | outlay | Extra layouts | yes | no | unstable | 16 | | pref | Query user/device preferences | no | yes | unstable | 17 | | richtext | Printing text objects with different styles | uncertain | no | unstable | 18 | | explorer | Opening a native file dialog | uncertain | yes | unstable | 19 | | markdown | Rendering markdown text as richtext | uncertain | yes | unstable | 20 | | stroke | Complex stroked path support | no | no | unstable | 21 | 22 | ## Contributing 23 | 24 | Report bugs on the [gio issue tracker](https://todo.sr.ht/~eliasnaur/gio) with the prefix `gio-x:` in your issue title. 25 | 26 | Ask questions on the [gio discussion mailing list](https://lists.sr.ht/~eliasnaur/gio). 27 | 28 | Send patches on the [gio patches mailing list](https://lists.sr.ht/~eliasnaur/gio-patches) with the subject line prefix `[PATCH gio-x]` 29 | 30 | All patches should be Signed-off to indicate conformance with the [LICENSE](https://git.sr.ht/~whereswaldon/gio-x/tree/main/LICENSE) of this repo. 31 | 32 | ## Tags 33 | 34 | Pre-1.0 tags are provided for reference only, and do not designate releases with ongoing support. Bugfixes will not be backported to older tags. 35 | 36 | Tags follow semantic versioning. In particular, as the major version is zero: 37 | 38 | - breaking API or behavior changes will increment the *minor* version component. 39 | - non-breaking changes will increment the *patch* version component. 40 | 41 | ## Maintainers 42 | 43 | This repository is primarily maintained by Chris Waldon. 44 | 45 | ## License 46 | 47 | Dual MIT/Unlicense; same as Gio 48 | 49 | ## Support 50 | 51 | If gio provides value to you, please consider supporting one or more of its developers and maintainers: 52 | 53 | Elias Naur: 54 | https://github.com/sponsors/eliasnaur 55 | 56 | Chris Waldon: 57 | https://github.com/sponsors/whereswaldon 58 | https://liberapay.com/whereswaldon 59 | -------------------------------------------------------------------------------- /colorpicker/README.md: -------------------------------------------------------------------------------- 1 | # colorpicker 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/gioui.org/x/colorpicker.svg)](https://pkg.go.dev/gioui.org/x/colorpicker) 4 | 5 | This is a simple Gio package that provides widgets for choosing colors. 6 | 7 | ![screenshot of provided widgets](https://git.sr.ht/~whereswaldon/gio-x/blob/main/colorpicker/img/screenshot.jpg) 8 | -------------------------------------------------------------------------------- /colorpicker/img/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/colorpicker/img/screenshot.jpg -------------------------------------------------------------------------------- /component/README.md: -------------------------------------------------------------------------------- 1 | # component 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/gioui.org/x/component.svg)](https://pkg.go.dev/gioui.org/x/component) 4 | 5 | This package provides various material design components for [gio](https://gioui.org). 6 | 7 | ## State 8 | 9 | This package has no stable API, and should always be locked to a particular commit with 10 | go modules. 11 | 12 | The included components attempt to conform to the [material design specifications](https://material.io/components/) 13 | whenever possible, but they may not support unusual style tweaks or especially exotic 14 | configurations. 15 | 16 | ## Implemented Components 17 | 18 | The list of currently-Implemented components follows: 19 | 20 | ### Navigation Drawer (static and modal) 21 | 22 | The navigation drawer [specified here](https://material.io/components/navigation-drawer) is mostly implemented by the type 23 | `NavDrawer`, and the modal variant can be created with a `ModalNavDrawer`. The modal variant looks like this: 24 | 25 | ![modal navigation drawer example screenshot](https://git.sr.ht/~whereswaldon/gio-x/blob/main/component/img/modal-nav.png) 26 | 27 | Features: 28 | - Animated drawer open/close. 29 | - Navigation items respond to hovering. 30 | - Navigation selection is animated. 31 | - Navigation item icons are optional. 32 | - Content can be anchored to the bottom of the drawer for pairing with a bottom app bar. 33 | 34 | Modal features: 35 | - Swipe or touch scrim to close the drawer. 36 | 37 | Known issues: 38 | 39 | - API targets a fairly static and simplistic menu. Sub-sections with dividers are not yet supported. An API-driven way to traverse the current menu options is also not yet supported. Contributions welcome! 40 | 41 | ### App Bar (Top and Bottom) 42 | 43 | The App Bar [specified here](https://material.io/components/app-bars-top) is mostly implemented by the type 44 | `AppBar`. It looks like this: 45 | 46 | Normal state: 47 | 48 | ![modal navigation drawer example screenshot](https://git.sr.ht/~whereswaldon/gio-x/blob/main/component/img/app-bar-top.png) 49 | 50 | Contextual state: 51 | 52 | ![modal navigation drawer example screenshot](https://git.sr.ht/~whereswaldon/gio-x/blob/main/component/img/app-bar-top-contextual.png) 53 | 54 | Features: 55 | - Action buttons and overflow menu contents can be changed easily. 56 | - Overflow button disappears when no items overflow. 57 | - Overflow menu can be dismissed by touching the scrim outside of it. 58 | - Action items disapper into overflow when screen is too narrow to fit them. This is animated. 59 | - Navigation button icon is customizable, and the button is not drawn if no icon is provided. 60 | - Contextual app bar can be triggered and dismissed programatically. 61 | - Bar supports use as a top and bottom app bar (animates the overflow menu in the proper direction). 62 | 63 | Known Issues: 64 | - Compact and prominent App Bars are not yet implemented. 65 | 66 | ### Side sheet (static and modal) 67 | 68 | Side sheets ([specified here](https://material.io/components/sheets-side)) are implemented by the `Sheet` and `ModalSheet` types. 69 | 70 | Features: 71 | - Animated appear/disappear 72 | 73 | Modal features: 74 | - Swipe to close 75 | - Touch scrim to close 76 | 77 | Known Issues: 78 | - Only sheets anchored on the left are currently supported (contributions welcome!) 79 | 80 | ### Text Fields 81 | 82 | Text Fields ([specified here](https://material.io/components/text-fields)) are implemented by the `TextField` type. 83 | 84 | Features: 85 | - Animated label transition when selected 86 | - Responds to hover events 87 | - Exposes underlying gio editor 88 | 89 | Known Issues: 90 | - Icons, hint text, error text, prefix/suffix, and other features are not yet implemented. 91 | 92 | ### Dividers 93 | 94 | The `Divider` type implements [material dividers](https://material.io/components/dividers). You can customize the insets 95 | embedded in the type to change which kind of divider it is. Use the constructor 96 | functions to create nice defaults. 97 | 98 | ### Surfaces 99 | 100 | The `Surface` type is a rounded rectangle with a background color and a drop 101 | shadow. This isn't a material component per se, but is a useful building block 102 | nonetheless. 103 | 104 | ### Menu 105 | 106 | The `Menu` type defines contextual menus as described [here](https://material.io/components/menus). 107 | 108 | ![first menu example screenshot](https://git.sr.ht/~whereswaldon/gio-x/blob/main/component/img/menu1.png) 109 | 110 | Known issues: 111 | - Does not support nested submenus (yet). 112 | 113 | The `MenuItem` type provides widgets suitable for use within the Menu, though 114 | any widget can be used. Here are some `MenuItem`s in action: 115 | 116 | ![second menu example screenshot](https://git.sr.ht/~whereswaldon/gio-x/blob/main/component/img/menu2.png) 117 | 118 | ### ContextArea 119 | 120 | The `ContextArea` type is a helper type that defines an area that accepts 121 | right-clicks. This area will display a widget when clicked (anchored at the 122 | click position). The displayed widget is overlaid on other content, and is 123 | therefore useful in displaying contextual menus. 124 | 125 | Known issues: 126 | - the heuristic that ContextArea uses to attempt to avoid off-screen drawing of 127 | its contextual content can fail or backfire. Suggestions for improving this 128 | are welcome. 129 | 130 | ### Tooltips 131 | 132 | The `Tooltip`, `TipArea`, and `TipIconButtonStyle` types define a tooltip, a contextual area for displaying tooltips (on hover and long-press), and a wrapper around `material.IconButtonStyle` that provides a tooltip for the button. 133 | 134 | ![tooltip example screenshot](https://git.sr.ht/~whereswaldon/gio-x/blob/main/component/img/tooltip.png) 135 | -------------------------------------------------------------------------------- /component/color.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "image/color" 5 | ) 6 | 7 | // WithAlpha returns the input color with the new alpha value. 8 | func WithAlpha(c color.NRGBA, a uint8) color.NRGBA { 9 | return color.NRGBA{ 10 | R: c.R, 11 | G: c.G, 12 | B: c.B, 13 | A: a, 14 | } 15 | } 16 | 17 | // AlphaPalette is the set of alpha values to be applied for certain 18 | // material design states like hover, selected, etc... 19 | type AlphaPalette struct { 20 | Hover, Selected uint8 21 | } 22 | -------------------------------------------------------------------------------- /component/color_test.go: -------------------------------------------------------------------------------- 1 | package component_test 2 | 3 | import ( 4 | "image/color" 5 | "strconv" 6 | "testing" 7 | 8 | materials "gioui.org/x/component" 9 | ) 10 | 11 | type interpolationTest struct { 12 | start, end, expected color.NRGBA 13 | progress float32 14 | } 15 | 16 | func (i interpolationTest) Run(t *testing.T) { 17 | interp := materials.Interpolate(i.start, i.end, i.progress) 18 | if interp != i.expected { 19 | t.Fatalf("expected interpolation with progress %f to be %v, got %v", i.progress, i.expected, interp) 20 | } 21 | } 22 | 23 | func TestInterpolate(t *testing.T) { 24 | zero := color.NRGBA{} 25 | fives := color.NRGBA{R: 5, G: 5, B: 5, A: 5} 26 | tens := color.NRGBA{R: 10, G: 10, B: 10, A: 10} 27 | blue := color.NRGBA{R: 64, G: 80, B: 180, A: 255} 28 | black := color.NRGBA{A: 255} 29 | for i, testCase := range []interpolationTest{ 30 | { 31 | start: zero, 32 | end: tens, 33 | expected: zero, 34 | progress: 0, 35 | }, 36 | { 37 | start: zero, 38 | end: tens, 39 | expected: tens, 40 | progress: 1, 41 | }, 42 | { 43 | start: zero, 44 | end: tens, 45 | expected: fives, 46 | progress: .5, 47 | }, 48 | { 49 | start: tens, 50 | end: zero, 51 | expected: fives, 52 | progress: .5, 53 | }, 54 | { 55 | start: blue, 56 | end: black, 57 | expected: color.NRGBA{R: 32, G: 40, B: 90, A: 255}, 58 | progress: .5, 59 | }, 60 | } { 61 | t.Run(strconv.Itoa(i), testCase.Run) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /component/discloser.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "math" 7 | "time" 8 | 9 | "gioui.org/f32" 10 | "gioui.org/layout" 11 | "gioui.org/op" 12 | "gioui.org/op/clip" 13 | "gioui.org/op/paint" 14 | "gioui.org/unit" 15 | "gioui.org/widget" 16 | "gioui.org/widget/material" 17 | ) 18 | 19 | // DiscloserState holds state for a widget that can hide and reveal 20 | // content. 21 | type DiscloserState struct { 22 | VisibilityAnimation 23 | widget.Clickable 24 | } 25 | 26 | // Layout updates the state of the Discloser. 27 | func (d *DiscloserState) Layout(gtx C) D { 28 | if d.Duration == time.Duration(0) { 29 | d.Duration = time.Millisecond * 100 30 | d.State = Invisible 31 | } 32 | if d.Clicked(gtx) { 33 | d.ToggleVisibility(gtx.Now) 34 | } 35 | return D{} 36 | } 37 | 38 | // Side represents a preference for left or right. 39 | type Side bool 40 | 41 | const ( 42 | Left Side = false 43 | Right Side = true 44 | ) 45 | 46 | // DiscloserStyle defines the presentation of a discloser widget. 47 | type DiscloserStyle struct { 48 | *DiscloserState 49 | // ControlSide defines whether the control widget is drawn to the 50 | // left or right of the summary widget. 51 | ControlSide Side 52 | // Alignment dictates how the control and summary are aligned relative 53 | // to one another. 54 | Alignment layout.Alignment 55 | } 56 | 57 | // Discloser configures a discloser from the provided theme and state. 58 | func Discloser(th *material.Theme, state *DiscloserState) DiscloserStyle { 59 | return DiscloserStyle{ 60 | DiscloserState: state, 61 | Alignment: layout.Middle, 62 | } 63 | } 64 | 65 | // Layout the discloser with the provided toggle control, summary widget, and 66 | // detail widget. The toggle widget will be wrapped in a clickable area 67 | // automatically. 68 | // 69 | // The structure of the resulting discloser is: 70 | // 71 | // control | summary 72 | // ----------------- 73 | // detail 74 | // 75 | // If d.ControlSide is set to Right, the control will appear after the summary 76 | // instead of before it. 77 | func (d DiscloserStyle) Layout(gtx C, control, summary, detail layout.Widget) D { 78 | d.DiscloserState.Layout(gtx) 79 | return layout.Flex{ 80 | Axis: layout.Vertical, 81 | }.Layout(gtx, 82 | layout.Rigid(func(gtx C) D { 83 | controlWidget := func(gtx C) D { 84 | return d.Clickable.Layout(gtx, control) 85 | } 86 | return layout.Flex{ 87 | Alignment: d.Alignment, 88 | }.Layout(gtx, 89 | layout.Rigid(func(gtx C) D { 90 | if d.ControlSide == Left { 91 | return controlWidget(gtx) 92 | } 93 | return summary(gtx) 94 | }), 95 | layout.Rigid(func(gtx C) D { 96 | if d.ControlSide == Left { 97 | return summary(gtx) 98 | } 99 | return controlWidget(gtx) 100 | }), 101 | ) 102 | }), 103 | layout.Rigid(func(gtx C) D { 104 | if !d.Visible() { 105 | return D{} 106 | } 107 | if !d.Animating() { 108 | return detail(gtx) 109 | } 110 | progress := d.Revealed(gtx) 111 | macro := op.Record(gtx.Ops) 112 | dims := detail(gtx) 113 | call := macro.Stop() 114 | height := int(math.Round(float64(float32(dims.Size.Y) * progress))) 115 | dims.Size.Y = height 116 | defer clip.Rect(image.Rectangle{ 117 | Max: dims.Size, 118 | }).Push(gtx.Ops).Pop() 119 | call.Add(gtx.Ops) 120 | return dims 121 | }), 122 | ) 123 | } 124 | 125 | // DiscloserArrowStyle defines the presentation of a simple triangular 126 | // Discloser control that rotates downward as the content is revealed. 127 | type DiscloserArrowStyle struct { 128 | Color color.NRGBA 129 | Size unit.Dp 130 | State *DiscloserState 131 | Side 132 | Margin layout.Inset 133 | } 134 | 135 | // DiscloserArrow creates and configures a DiscloserArrow for use with 136 | // the provided DiscloserStyle. 137 | func DiscloserArrow(th *material.Theme, style DiscloserStyle) DiscloserArrowStyle { 138 | return DiscloserArrowStyle{ 139 | Color: color.NRGBA{A: 200}, 140 | State: style.DiscloserState, 141 | Size: unit.Dp(10), 142 | Side: style.ControlSide, 143 | Margin: layout.UniformInset(unit.Dp(4)), 144 | } 145 | } 146 | 147 | // Width returns the width of the arrow and surrounding whitespace. 148 | func (d DiscloserArrowStyle) Width() unit.Dp { 149 | return d.Size + d.Margin.Right + d.Margin.Left 150 | } 151 | 152 | // DetailInset returns a layout.Inset that can be used to align a 153 | // Discloser's details with its summary. 154 | func (d DiscloserArrowStyle) DetailInset() layout.Inset { 155 | if d.Side == Left { 156 | return layout.Inset{ 157 | Left: d.Width(), 158 | } 159 | } 160 | return layout.Inset{ 161 | Right: d.Width(), 162 | } 163 | } 164 | 165 | const halfPi float32 = math.Pi * .5 166 | 167 | // Layout the arrow. 168 | func (d DiscloserArrowStyle) Layout(gtx C) D { 169 | return d.Margin.Layout(gtx, func(gtx C) D { 170 | // Draw a triangle. 171 | path := clip.Path{} 172 | path.Begin(gtx.Ops) 173 | size := float32(gtx.Dp(d.Size)) 174 | halfSize := size * .5 175 | path.LineTo(f32.Pt(0, size)) 176 | path.LineTo(f32.Pt(size, halfSize)) 177 | path.Close() 178 | outline := clip.Outline{ 179 | Path: path.End(), 180 | } 181 | affine := f32.Affine2D{} 182 | if d.State.Visible() { 183 | // Rotate the triangle. 184 | origin := f32.Pt(halfSize, halfSize) 185 | rotation := halfPi 186 | if d.State.Animating() { 187 | rotation *= d.State.Revealed(gtx) 188 | } 189 | affine = affine.Rotate(origin, rotation) 190 | } 191 | if d.Side == Right { 192 | // Mirror the triangle. 193 | affine = affine.Scale(f32.Point{}, f32.Point{ 194 | X: -1, 195 | Y: 1, 196 | }).Offset(f32.Pt(size, 0)) 197 | } 198 | defer op.Affine(affine).Push(gtx.Ops).Pop() 199 | paint.FillShape(gtx.Ops, d.Color, outline.Op()) 200 | return D{ 201 | Size: image.Pt(int(size), int(size)), 202 | } 203 | }) 204 | } 205 | 206 | // SimpleDiscloserStyle configures a default discloser that uses a simple 207 | // rotating triangle control and indents its details. 208 | type SimpleDiscloserStyle struct { 209 | DiscloserStyle 210 | DiscloserArrowStyle 211 | } 212 | 213 | // SimpleDiscloser creates a SimpleDiscloserStyle for the given theme and 214 | // DiscloserState. 215 | func SimpleDiscloser(th *material.Theme, state *DiscloserState) SimpleDiscloserStyle { 216 | sd := SimpleDiscloserStyle{ 217 | DiscloserStyle: Discloser(th, state), 218 | } 219 | sd.DiscloserArrowStyle = DiscloserArrow(th, sd.DiscloserStyle) 220 | return sd 221 | } 222 | 223 | // Layout the discloser with the provided summary and detail widget content. 224 | func (sd SimpleDiscloserStyle) Layout(gtx C, summary, details layout.Widget) D { 225 | return sd.DiscloserStyle.Layout(gtx, sd.DiscloserArrowStyle.Layout, summary, func(gtx C) D { 226 | return sd.DiscloserArrowStyle.DetailInset().Layout(gtx, details) 227 | }) 228 | } 229 | -------------------------------------------------------------------------------- /component/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package component provides material design UI components as described 3 | by https://material.io 4 | */ 5 | package component 6 | -------------------------------------------------------------------------------- /component/grid.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "math" 5 | 6 | "gioui.org/io/pointer" 7 | "gioui.org/layout" 8 | "gioui.org/widget" 9 | "gioui.org/widget/material" 10 | "gioui.org/x/outlay" 11 | ) 12 | 13 | // Grid holds the persistent state for a layout.List that has a 14 | // scrollbar attached. 15 | type GridState struct { 16 | VScrollbar widget.Scrollbar 17 | HScrollbar widget.Scrollbar 18 | outlay.Grid 19 | } 20 | 21 | // TableStyle is the persistent state for a table with heading and grid. 22 | type TableStyle GridStyle 23 | 24 | // Table makes a grid with its persistent state. 25 | func Table(th *material.Theme, state *GridState) TableStyle { 26 | return TableStyle{ 27 | State: state, 28 | VScrollbarStyle: material.Scrollbar(th, &state.VScrollbar), 29 | HScrollbarStyle: material.Scrollbar(th, &state.HScrollbar), 30 | } 31 | } 32 | 33 | // GridStyle is the persistent state for the grid. 34 | type GridStyle struct { 35 | State *GridState 36 | VScrollbarStyle material.ScrollbarStyle 37 | HScrollbarStyle material.ScrollbarStyle 38 | material.AnchorStrategy 39 | } 40 | 41 | // Grid makes a grid with its persistent state. 42 | func Grid(th *material.Theme, state *GridState) GridStyle { 43 | return GridStyle{ 44 | State: state, 45 | VScrollbarStyle: material.Scrollbar(th, &state.VScrollbar), 46 | HScrollbarStyle: material.Scrollbar(th, &state.HScrollbar), 47 | } 48 | } 49 | 50 | // constrain a value to be between min and max (inclusive). 51 | func constrain(value *int, min int, max int) { 52 | if *value < min { 53 | *value = min 54 | } 55 | if *value > max { 56 | *value = max 57 | } 58 | } 59 | 60 | // Layout will draw a table with a heading, using fixed column widths and row height. 61 | func (t TableStyle) Layout(gtx layout.Context, rows, cols int, dimensioner outlay.Dimensioner, headingFunc layout.ListElement, cellFunc outlay.Cell) layout.Dimensions { 62 | t.State.Grid.LockedRows = 1 63 | return GridStyle(t).Layout(gtx, rows+1, cols, dimensioner, func(gtx layout.Context, row, col int) layout.Dimensions { 64 | if row == 0 { 65 | return headingFunc(gtx, col) 66 | } 67 | return cellFunc(gtx, row-1, col) 68 | }) 69 | } 70 | 71 | // Layout will draw a grid, using fixed column widths and row height. 72 | func (g GridStyle) Layout(gtx layout.Context, rows, cols int, dimensioner outlay.Dimensioner, cellFunc outlay.Cell) layout.Dimensions { 73 | // Determine how much space the scrollbars occupy when present. 74 | hBarWidth := gtx.Dp(g.HScrollbarStyle.Width()) 75 | vBarWidth := gtx.Dp(g.VScrollbarStyle.Width()) 76 | 77 | // Reserve space for the scrollbars using the gtx constraints. 78 | if g.AnchorStrategy == material.Occupy { 79 | gtx.Constraints.Max.X -= vBarWidth 80 | gtx.Constraints.Max.Y -= hBarWidth 81 | } 82 | 83 | defer pointer.PassOp{}.Push(gtx.Ops).Pop() 84 | // Draw grid. 85 | dim := g.State.Grid.Layout(gtx, rows, cols, dimensioner, cellFunc) 86 | 87 | // Calculate column widths in pixels. Width is sum of widths. 88 | totalWidth := g.State.Horizontal.Length 89 | totalHeight := g.State.Vertical.Length 90 | 91 | // Make the scroll bar stick to the grid. 92 | if gtx.Constraints.Max.X > dim.Size.X { 93 | gtx.Constraints.Max.X = dim.Size.X 94 | if g.AnchorStrategy == material.Occupy { 95 | gtx.Constraints.Max.X += vBarWidth 96 | } 97 | } 98 | 99 | // Get horizontal scroll info. 100 | delta := g.HScrollbarStyle.Scrollbar.ScrollDistance() 101 | if delta != 0 { 102 | g.State.Horizontal.Offset += int(float32(totalWidth-vBarWidth) * delta) 103 | } 104 | 105 | // Get vertical scroll info. 106 | delta = g.VScrollbarStyle.Scrollbar.ScrollDistance() 107 | if delta != 0 { 108 | g.State.Vertical.Offset += int(math.Round(float64(float32(totalHeight-hBarWidth) * delta))) 109 | } 110 | 111 | var start float32 112 | var end float32 113 | 114 | // Draw vertical scroll-bar. 115 | if vBarWidth > 0 { 116 | c := gtx 117 | start = float32(g.State.Vertical.OffsetAbs) / float32(totalHeight) 118 | end = start + float32(c.Constraints.Max.Y)/float32(totalHeight) 119 | if g.AnchorStrategy == material.Overlay { 120 | c.Constraints.Max.Y -= hBarWidth 121 | } else { 122 | c.Constraints.Max.X += vBarWidth 123 | } 124 | c.Constraints.Min = c.Constraints.Max 125 | layout.E.Layout(c, func(gtx layout.Context) layout.Dimensions { 126 | return g.VScrollbarStyle.Layout(gtx, layout.Vertical, start, end) 127 | }) 128 | } 129 | 130 | // Draw horizontal scroll-bar if it is visible. 131 | if hBarWidth > 0 { 132 | c := gtx 133 | start = float32(g.State.Horizontal.OffsetAbs) / float32(totalWidth) 134 | end = start + float32(c.Constraints.Max.X)/float32(totalWidth) 135 | if g.AnchorStrategy == material.Overlay { 136 | c.Constraints.Max.X -= vBarWidth 137 | } else { 138 | c.Constraints.Max.Y += hBarWidth 139 | } 140 | c.Constraints.Min = c.Constraints.Max 141 | layout.S.Layout(c, func(gtx layout.Context) layout.Dimensions { 142 | return g.HScrollbarStyle.Layout(gtx, layout.Horizontal, start, end) 143 | }) 144 | } 145 | if g.AnchorStrategy == material.Occupy { 146 | dim.Size.Y += hBarWidth 147 | } 148 | 149 | return dim 150 | } 151 | -------------------------------------------------------------------------------- /component/img/app-bar-top-contextual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/component/img/app-bar-top-contextual.png -------------------------------------------------------------------------------- /component/img/app-bar-top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/component/img/app-bar-top.png -------------------------------------------------------------------------------- /component/img/menu1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/component/img/menu1.png -------------------------------------------------------------------------------- /component/img/menu2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/component/img/menu2.png -------------------------------------------------------------------------------- /component/img/modal-nav.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/component/img/modal-nav.png -------------------------------------------------------------------------------- /component/img/tooltip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/component/img/tooltip.png -------------------------------------------------------------------------------- /component/materials.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | 7 | "gioui.org/layout" 8 | "gioui.org/op/clip" 9 | "gioui.org/op/paint" 10 | ) 11 | 12 | type ( 13 | C = layout.Context 14 | D = layout.Dimensions 15 | ) 16 | 17 | type Rect struct { 18 | Color color.NRGBA 19 | Size image.Point 20 | Radii int 21 | } 22 | 23 | func (r Rect) Layout(gtx C) D { 24 | paint.FillShape( 25 | gtx.Ops, 26 | r.Color, 27 | clip.UniformRRect( 28 | image.Rectangle{ 29 | Max: r.Size, 30 | }, 31 | r.Radii, 32 | ).Op(gtx.Ops)) 33 | return layout.Dimensions{Size: r.Size} 34 | } 35 | -------------------------------------------------------------------------------- /component/modal_layer.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "time" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/op" 8 | "gioui.org/widget/material" 9 | ) 10 | 11 | // ModalLayer is a widget drawn on top of the normal UI that can be populated 12 | // by other material components with dismissble modal dialogs. For instance, 13 | // the App Bar can render its overflow menu within the modal layer, and the 14 | // modal navigation drawer is entirely within the modal layer. 15 | type ModalLayer struct { 16 | VisibilityAnimation 17 | Scrim 18 | Widget func(gtx layout.Context, th *material.Theme, anim *VisibilityAnimation) layout.Dimensions 19 | } 20 | 21 | const defaultModalAnimationDuration = time.Millisecond * 250 22 | 23 | // NewModal creates an initializes a modal layer. 24 | func NewModal() *ModalLayer { 25 | m := ModalLayer{} 26 | m.VisibilityAnimation.State = Invisible 27 | m.VisibilityAnimation.Duration = defaultModalAnimationDuration 28 | m.Scrim.FinalAlpha = 82 //default 29 | return &m 30 | } 31 | 32 | // Layout renders the modal layer. Unless a modal widget has been triggered, 33 | // this will do nothing. 34 | func (m *ModalLayer) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { 35 | if !m.Visible() { 36 | return D{} 37 | } 38 | if m.Scrim.Clicked(gtx) { 39 | m.Disappear(gtx.Now) 40 | } 41 | scrimDims := m.Scrim.Layout(gtx, th, &m.VisibilityAnimation) 42 | if m.Widget != nil { 43 | _ = m.Widget(gtx, th, &m.VisibilityAnimation) 44 | } 45 | return scrimDims 46 | } 47 | 48 | // ModalState defines persistent state for a modal. 49 | type ModalState struct { 50 | ScrimState 51 | // content is the content widget to layout atop a scrim. 52 | // This is specified as a field because where the content is defined 53 | // is not where it is invoked. 54 | // Thus, the content widget becomes the state of the modal. 55 | content layout.Widget 56 | } 57 | 58 | // ModalStyle describes how to layout a modal. 59 | // Modal content is layed centered atop a clickable scrim. 60 | type ModalStyle struct { 61 | *ModalState 62 | Scrim ScrimStyle 63 | } 64 | 65 | // Modal lays out a content widget atop a clickable scrim. 66 | // Clicking the scrim dismisses the modal. 67 | func Modal(th *material.Theme, modal *ModalState) ModalStyle { 68 | return ModalStyle{ 69 | ModalState: modal, 70 | Scrim: NewScrim(th, &modal.ScrimState, 250), 71 | } 72 | } 73 | 74 | // Layout the scrim and content. The content is only laid out once 75 | // the scrim is fully animated in, and is hidden on the first frame 76 | // of the scrim's fade-out animation. 77 | func (m ModalStyle) Layout(gtx C) D { 78 | if m.content == nil || !m.Visible() { 79 | return D{} 80 | } 81 | if m.Clicked(gtx) { 82 | m.Disappear(gtx.Now) 83 | } 84 | macro := op.Record(gtx.Ops) 85 | dims := layout.Stack{}.Layout( 86 | gtx, 87 | layout.Expanded(func(gtx C) D { 88 | return m.Scrim.Layout(gtx) 89 | }), 90 | layout.Expanded(func(gtx C) D { 91 | if m.Scrim.Visible() && !m.Scrim.Animating() { 92 | return m.content(gtx) 93 | } 94 | return D{} 95 | }), 96 | ) 97 | op.Defer(gtx.Ops, macro.Stop()) 98 | return dims 99 | } 100 | 101 | // Show widget w in the modal, starting animation at now. 102 | func (m *ModalState) Show(now time.Time, w layout.Widget) { 103 | m.content = w 104 | m.Appear(now) 105 | } 106 | -------------------------------------------------------------------------------- /component/resizer.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "image" 5 | 6 | "gioui.org/gesture" 7 | "gioui.org/io/pointer" 8 | "gioui.org/layout" 9 | "gioui.org/op" 10 | "gioui.org/op/clip" 11 | ) 12 | 13 | // Resize provides a draggable handle in between two widgets for resizing their area. 14 | type Resize struct { 15 | // Axis defines how the widgets and the handle are laid out. 16 | Axis layout.Axis 17 | // Ratio defines how much space is available to the first widget. 18 | Ratio float32 19 | float float 20 | } 21 | 22 | // Layout displays w1 and w2 with handle in between. 23 | // 24 | // The widgets w1 and w2 must be able to gracefully resize their minimum and maximum dimensions 25 | // in order for the resize to be smooth. 26 | func (rs *Resize) Layout(gtx layout.Context, w1, w2, handle layout.Widget) layout.Dimensions { 27 | // Compute the first widget's max width/height. 28 | rs.float.Length = rs.Axis.Convert(gtx.Constraints.Max).X 29 | rs.float.Pos = int(rs.Ratio * float32(rs.float.Length)) 30 | oldPos := rs.float.Pos 31 | m := op.Record(gtx.Ops) 32 | dims := rs.float.Layout(gtx, rs.Axis, handle) 33 | c := m.Stop() 34 | if rs.float.Pos != oldPos { 35 | // We update rs.Ratio conditionally to avoid cumulating rounding errors when changing the constraints instead of 36 | // dragging the handle. 37 | rs.Ratio = float32(rs.float.Pos) / float32(rs.float.Length) 38 | } 39 | return layout.Flex{ 40 | Axis: rs.Axis, 41 | }.Layout(gtx, 42 | layout.Flexed(rs.Ratio, w1), 43 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 44 | c.Add(gtx.Ops) 45 | return dims 46 | }), 47 | layout.Flexed(1-rs.Ratio, w2), 48 | ) 49 | } 50 | 51 | type float struct { 52 | Length int // max constraint for the axis 53 | Pos int // position in pixels of the handle 54 | drag gesture.Drag 55 | } 56 | 57 | func (f *float) Layout(gtx layout.Context, axis layout.Axis, w layout.Widget) layout.Dimensions { 58 | gtx.Constraints.Min = image.Point{} 59 | dims := w(gtx) 60 | 61 | var de *pointer.Event 62 | for { 63 | e, ok := f.drag.Update(gtx.Metric, gtx.Source, gesture.Axis(axis)) 64 | if !ok { 65 | break 66 | } 67 | if e.Kind == pointer.Drag { 68 | de = &e 69 | } 70 | } 71 | if de != nil { 72 | xy := de.Position.X 73 | if axis == layout.Vertical { 74 | xy = de.Position.Y 75 | } 76 | f.Pos += int(xy) 77 | } 78 | 79 | // Clamp the handle position, leaving it always visible. 80 | if f.Pos < 0 { 81 | f.Pos = 0 82 | } else if f.Pos > f.Length { 83 | f.Pos = f.Length 84 | } 85 | 86 | rect := image.Rectangle{Max: dims.Size} 87 | defer clip.Rect(rect).Push(gtx.Ops).Pop() 88 | f.drag.Add(gtx.Ops) 89 | cursor := pointer.CursorRowResize 90 | if axis == layout.Horizontal { 91 | cursor = pointer.CursorColResize 92 | } 93 | cursor.Add(gtx.Ops) 94 | 95 | return layout.Dimensions{Size: dims.Size} 96 | } 97 | -------------------------------------------------------------------------------- /component/scrim.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "image/color" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/widget" 8 | "gioui.org/widget/material" 9 | ) 10 | 11 | // Scrim implments a clickable translucent overlay. It can animate appearing 12 | // and disappearing as a fade-in, fade-out transition from zero opacity 13 | // to a fixed maximum opacity. 14 | type Scrim struct { 15 | // FinalAlpha is the final opacity of the scrim on a scale from 0 to 255. 16 | FinalAlpha uint8 17 | widget.Clickable 18 | } 19 | 20 | // Layout draws the scrim using the provided animation. If the animation indicates 21 | // that the scrim is not visible, this is a no-op. 22 | func (s *Scrim) Layout(gtx layout.Context, th *material.Theme, anim *VisibilityAnimation) layout.Dimensions { 23 | return s.Clickable.Layout(gtx, func(gtx C) D { 24 | if !anim.Visible() { 25 | return layout.Dimensions{} 26 | } 27 | gtx.Constraints.Min = gtx.Constraints.Max 28 | currentAlpha := s.FinalAlpha 29 | if anim.Animating() { 30 | revealed := anim.Revealed(gtx) 31 | currentAlpha = uint8(float32(s.FinalAlpha) * revealed) 32 | } 33 | color := th.Fg 34 | color.A = currentAlpha 35 | fill := WithAlpha(color, currentAlpha) 36 | paintRect(gtx, gtx.Constraints.Max, fill) 37 | return layout.Dimensions{Size: gtx.Constraints.Max} 38 | }) 39 | } 40 | 41 | // ScrimState defines persistent state for a scrim. 42 | type ScrimState struct { 43 | widget.Clickable 44 | VisibilityAnimation 45 | } 46 | 47 | // ScrimStyle defines how to layout a scrim. 48 | type ScrimStyle struct { 49 | *ScrimState 50 | Color color.NRGBA 51 | FinalAlpha uint8 52 | } 53 | 54 | // NewScrim allocates a ScrimStyle. 55 | // Alpha is the final alpha of a fully "appeared" scrim. 56 | func NewScrim(th *material.Theme, scrim *ScrimState, alpha uint8) ScrimStyle { 57 | return ScrimStyle{ 58 | ScrimState: scrim, 59 | Color: th.Fg, 60 | FinalAlpha: alpha, 61 | } 62 | } 63 | 64 | func (scrim ScrimStyle) Layout(gtx C) D { 65 | return scrim.Clickable.Layout(gtx, func(gtx C) D { 66 | if !scrim.Visible() { 67 | return D{} 68 | } 69 | gtx.Constraints.Min = gtx.Constraints.Max 70 | alpha := scrim.FinalAlpha 71 | if scrim.Animating() { 72 | alpha = uint8(float32(scrim.FinalAlpha) * scrim.Revealed(gtx)) 73 | } 74 | return Rect{ 75 | Color: WithAlpha(scrim.Color, alpha), 76 | Size: gtx.Constraints.Max, 77 | }.Layout(gtx) 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /component/sheet.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "image" 5 | "time" 6 | 7 | "gioui.org/f32" 8 | "gioui.org/gesture" 9 | "gioui.org/io/event" 10 | "gioui.org/io/pointer" 11 | "gioui.org/layout" 12 | "gioui.org/op" 13 | "gioui.org/op/clip" 14 | "gioui.org/unit" 15 | "gioui.org/widget/material" 16 | ) 17 | 18 | // Sheet implements the standard side sheet described here: 19 | // https://material.io/components/sheets-side#usage 20 | type Sheet struct{} 21 | 22 | // NewSheet returns a new sheet 23 | func NewSheet() Sheet { 24 | return Sheet{} 25 | } 26 | 27 | // Layout renders the provided widget on a background. The background will use 28 | // the maximum space available. 29 | func (s Sheet) Layout(gtx layout.Context, th *material.Theme, anim *VisibilityAnimation, widget layout.Widget) layout.Dimensions { 30 | revealed := -1 + anim.Revealed(gtx) 31 | finalOffset := int(revealed * (float32(gtx.Constraints.Max.X))) 32 | revealedWidth := finalOffset + gtx.Constraints.Max.X 33 | defer op.Offset(image.Point{X: finalOffset}).Push(gtx.Ops).Pop() 34 | // lay out background 35 | paintRect(gtx, gtx.Constraints.Max, th.Bg) 36 | 37 | // lay out sheet contents 38 | dims := widget(gtx) 39 | 40 | return layout.Dimensions{ 41 | Size: image.Point{ 42 | X: int(revealedWidth), 43 | Y: gtx.Constraints.Max.Y, 44 | }, 45 | Baseline: dims.Baseline, 46 | } 47 | } 48 | 49 | // ModalSheet implements the Modal Side Sheet component 50 | // specified at https://material.io/components/sheets-side#modal-side-sheet 51 | type ModalSheet struct { 52 | // MaxWidth constrains the maximum amount of horizontal screen real-estate 53 | // covered by the drawer. If the screen is narrower than this value, the 54 | // width will be inferred by reserving space for the scrim and using the 55 | // leftover area for the drawer. Values between 200 and 400 Dp are recommended. 56 | // 57 | // The default value used by NewModalNav is 400 Dp. 58 | MaxWidth unit.Dp 59 | 60 | Modal *ModalLayer 61 | 62 | drag gesture.Drag 63 | 64 | // animation state 65 | dragging bool 66 | dragStarted f32.Point 67 | dragOffset int 68 | 69 | Sheet 70 | } 71 | 72 | // NewModalSheet creates a modal sheet that can render a widget on the modal layer. 73 | func NewModalSheet(m *ModalLayer) *ModalSheet { 74 | s := &ModalSheet{ 75 | MaxWidth: unit.Dp(400), 76 | Modal: m, 77 | Sheet: NewSheet(), 78 | } 79 | return s 80 | } 81 | 82 | // updateDragState ensures that a partially-dragged sheet 83 | // snaps back into place when released and otherwise chooses 84 | // when the sheet has been dragged far enough to close. 85 | func (s *ModalSheet) updateDragState(gtx layout.Context, anim *VisibilityAnimation) { 86 | if s.dragOffset != 0 && !s.dragging && !anim.Animating() { 87 | if s.dragOffset < 2 { 88 | s.dragOffset = 0 89 | } else { 90 | s.dragOffset /= 2 91 | } 92 | } else if s.dragging && int(s.dragOffset) > gtx.Constraints.Max.X/10 { 93 | anim.Disappear(gtx.Now) 94 | } 95 | } 96 | 97 | // ConfigureModal requests that the sheet prepare the associated 98 | // ModalLayer to render itself (rather than another modal widget). 99 | func (s *ModalSheet) LayoutModal(contents func(gtx layout.Context, th *material.Theme, anim *VisibilityAnimation) layout.Dimensions) { 100 | s.Modal.Widget = func(gtx C, th *material.Theme, anim *VisibilityAnimation) D { 101 | s.updateDragState(gtx, anim) 102 | if !anim.Visible() { 103 | return D{} 104 | } 105 | for { 106 | event, ok := s.drag.Update(gtx.Metric, gtx.Source, gesture.Horizontal) 107 | if !ok { 108 | break 109 | } 110 | switch event.Kind { 111 | case pointer.Press: 112 | s.dragStarted = event.Position 113 | s.dragOffset = 0 114 | s.dragging = true 115 | case pointer.Drag: 116 | newOffset := int(s.dragStarted.X - event.Position.X) 117 | if newOffset > s.dragOffset { 118 | s.dragOffset = newOffset 119 | } 120 | case pointer.Release: 121 | fallthrough 122 | case pointer.Cancel: 123 | s.dragging = false 124 | } 125 | } 126 | for { 127 | // Beneath sheet content, listen for tap events. This prevents taps in the 128 | // empty sheet area from passing downward to the scrim underneath it. 129 | _, ok := gtx.Event(pointer.Filter{ 130 | Target: s, 131 | Kinds: pointer.Press | pointer.Release, 132 | }) 133 | if !ok { 134 | break 135 | } 136 | } 137 | // Ensure any transformation is undone on return. 138 | defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() 139 | if s.dragOffset != 0 || anim.Animating() { 140 | s.drawerTransform(gtx, anim).Add(gtx.Ops) 141 | gtx.Execute(op.InvalidateCmd{}) 142 | } 143 | gtx.Constraints.Max.X = s.sheetWidth(gtx) 144 | 145 | // Beneath sheet content, listen for tap events. This prevents taps in the 146 | // empty sheet area from passing downward to the scrim underneath it. 147 | pr := clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}) 148 | defer pr.Push(gtx.Ops).Pop() 149 | event.Op(gtx.Ops, s) 150 | // lay out widget 151 | dims := s.Sheet.Layout(gtx, th, anim, func(gtx C) D { 152 | return contents(gtx, th, anim) 153 | }) 154 | 155 | // On top of sheet content, listen for drag events to close the sheet. 156 | defer pointer.PassOp{}.Push(gtx.Ops).Pop() 157 | defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}).Push(gtx.Ops).Pop() 158 | s.drag.Add(gtx.Ops) 159 | 160 | return dims 161 | } 162 | } 163 | 164 | // drawerTransform returns the current offset transformation 165 | // of the sheet taking both drag and animation progress 166 | // into account. 167 | func (s ModalSheet) drawerTransform(gtx C, anim *VisibilityAnimation) op.TransformOp { 168 | finalOffset := -s.dragOffset 169 | return op.Offset(image.Point{X: finalOffset}) 170 | } 171 | 172 | // sheetWidth returns the width of the sheet taking both the dimensions 173 | // of the modal layer and the MaxWidth field into account. 174 | func (s ModalSheet) sheetWidth(gtx layout.Context) int { 175 | scrimWidth := gtx.Dp(unit.Dp(56)) 176 | withScrim := gtx.Constraints.Max.X - scrimWidth 177 | max := gtx.Dp(s.MaxWidth) 178 | return min(withScrim, max) 179 | } 180 | 181 | // ToggleVisibility triggers the appearance or disappearance of the 182 | // ModalSheet. 183 | func (s *ModalSheet) ToggleVisibility(when time.Time) { 184 | s.Modal.ToggleVisibility(when) 185 | } 186 | 187 | // Appear triggers the appearance of the ModalSheet. 188 | func (s *ModalSheet) Appear(when time.Time) { 189 | s.Modal.Appear(when) 190 | } 191 | 192 | // Disappear triggers the appearance of the ModalSheet. 193 | func (s *ModalSheet) Disappear(when time.Time) { 194 | s.Modal.Disappear(when) 195 | } 196 | -------------------------------------------------------------------------------- /component/tooltip_desktop.go: -------------------------------------------------------------------------------- 1 | //go:build !android && !ios 2 | // +build !android,!ios 3 | 4 | package component 5 | 6 | import "gioui.org/widget/material" 7 | 8 | // PlatformTooltip creates a tooltip styled to the current platform 9 | // (desktop or mobile) by choosing based on the OS. This choice may 10 | // not always be appropriate as it only uses the OS to decide. 11 | func PlatformTooltip(th *material.Theme, text string) Tooltip { 12 | return DesktopTooltip(th, text) 13 | } 14 | -------------------------------------------------------------------------------- /component/tooltip_mobile.go: -------------------------------------------------------------------------------- 1 | //go:build ios || android 2 | // +build ios android 3 | 4 | package component 5 | 6 | import "gioui.org/widget/material" 7 | 8 | // PlatformTooltip creates a tooltip styled to the current platform 9 | // (desktop or mobile) by choosing based on the OS. This choice may 10 | // not always be appropriate as it only uses the OS to decide. 11 | func PlatformTooltip(th *material.Theme, text string) Tooltip { 12 | return MobileTooltip(th, text) 13 | } 14 | -------------------------------------------------------------------------------- /component/truncating_label.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "gioui.org/layout" 5 | "gioui.org/widget/material" 6 | ) 7 | 8 | // TruncatingLabelStyle is a type that forces a label to 9 | // fit on one line and adds a truncation indicator symbol 10 | // to the end of the line if the text has been truncated. 11 | // 12 | // Deprecated: You can set material.LabelStyle.MaxLines to achieve truncation 13 | // without this type. This type has been reimplemented to do that internally. 14 | type TruncatingLabelStyle material.LabelStyle 15 | 16 | // Layout renders the label into the provided context. 17 | func (t TruncatingLabelStyle) Layout(gtx layout.Context) layout.Dimensions { 18 | t.MaxLines = 1 19 | return ((material.LabelStyle)(t)).Layout(gtx) 20 | } 21 | -------------------------------------------------------------------------------- /explorer/README.md: -------------------------------------------------------------------------------- 1 | # explorer [![Go Reference](https://pkg.go.dev/badge/gioui.org/x/explorer.svg)](https://pkg.go.dev/gioui.org/x/explorer) 2 | 3 | ----------- 4 | 5 | Integrates a simple `Save As...` or `Open...` mechanism to your Gio application. 6 | 7 | ## What can it be used for? 8 | 9 | Well, for anything that manipulates user's file. You can use `os.Open` to open and write file, 10 | but sometimes you want to know where to save the data, in those case `Explorer` is useful. 11 | 12 | ## Status 13 | 14 | Currently, `Explorer` supports most platforms, including Android 6+, JS, Linux (with XDG Portals), Windows 10+, iOS 14+ and macOS 10+. It will 15 | return ErrAvailableAPI for any other platform that isn't supported. 16 | 17 | ## Limitations 18 | 19 | ### Edit file content via `explorer.ReadFile()`: 20 | 21 | It may not be possible to edit/write data using `explorer.ReadFile()`. Because of that, it returns a ` 22 | io.ReadCloser` instead of `io.ReadWriteCloser`, since some operational systems (such as JS) doesn't 23 | allow us to modify the file. However, you can use type-assertion to check if it's possible or not: 24 | 25 | ``` 26 | reader, _ := explorer.ReadFile() 27 | if f, ok := reader.(*os.File); ok { 28 | // We can use `os.File.Write` in that case. It's NOT possible in all OSes. 29 | f.Write(...) 30 | } 31 | ``` 32 | 33 | ### Select folders: 34 | 35 | It's not possible to select folders. 36 | -------------------------------------------------------------------------------- /explorer/explorer.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package explorer 4 | 5 | import ( 6 | "errors" 7 | "io" 8 | "runtime" 9 | "sync" 10 | "sync/atomic" 11 | 12 | "gioui.org/app" 13 | "gioui.org/io/event" 14 | ) 15 | 16 | var ( 17 | // ErrUserDecline is returned when the user doesn't select the file. 18 | ErrUserDecline = errors.New("user exited the file selector without selecting a file") 19 | 20 | // ErrNotAvailable is return when the current OS isn't supported. 21 | ErrNotAvailable = errors.New("current OS not supported") 22 | ) 23 | 24 | type result struct { 25 | file interface{} 26 | error error 27 | } 28 | 29 | // Explorer facilitates opening OS-native dialogs to choose files and create files. 30 | type Explorer struct { 31 | id int32 32 | mutex sync.Mutex 33 | 34 | // explorer holds OS-Specific content, it varies for each OS. 35 | *explorer 36 | } 37 | 38 | // active holds all explorer currently active, that may necessary for callback functions. 39 | // 40 | // Some OSes (Android, iOS, macOS) may call Golang exported functions as callback, but we need 41 | // someway to link that callback with the respective explorer, in order to give them a response. 42 | // 43 | // In that case, a construction like `callback(..., id int32)` is used. Then, it's possible to get the explorer 44 | // by lookup the active using the callback id. 45 | // 46 | // To avoid hold dead/unnecessary explorer, the active will be removed using `runtime.SetFinalizer` on the related 47 | // Explorer. 48 | var ( 49 | active = sync.Map{} // map[int32]*explorer 50 | counter = new(int32) 51 | ) 52 | 53 | // NewExplorer creates a new Explorer for the given *app.Window. 54 | // The given app.Window must be unique and you should call NewExplorer 55 | // once per new app.Window. 56 | // 57 | // It's mandatory to use Explorer.ListenEvents on the same *app.Window. 58 | func NewExplorer(w *app.Window) (e *Explorer) { 59 | e = &Explorer{ 60 | explorer: newExplorer(w), 61 | id: atomic.AddInt32(counter, 1), 62 | } 63 | 64 | active.Store(e.id, e.explorer) 65 | runtime.SetFinalizer(e, func(e *Explorer) { active.Delete(e.id) }) 66 | 67 | return e 68 | } 69 | 70 | // ListenEvents must get all the events from Gio, in order to get the GioView. You must 71 | // include that function where you listen for Gio events. 72 | // 73 | // Similar as: 74 | // 75 | // select { 76 | // case e := <-window.Events(): 77 | // 78 | // explorer.ListenEvents(e) 79 | // switch e := e.(type) { 80 | // (( ... your code ... )) 81 | // } 82 | // } 83 | func (e *Explorer) ListenEvents(evt event.Event) { 84 | if e == nil { 85 | return 86 | } 87 | e.listenEvents(evt) 88 | } 89 | 90 | // ChooseFile shows the file selector, allowing the user to select a single file. 91 | // Optionally, it's possible to define which file extensions is supported to 92 | // be selected (such as `.jpg`, `.png`). 93 | // 94 | // Example: ChooseFile(".jpg", ".png") will only accept the selection of files with 95 | // .jpg or .png extensions. 96 | // 97 | // In some platforms the resulting `io.ReadCloser` is a `os.File`, but it's not 98 | // a guarantee. 99 | // 100 | // In most known browsers, when user clicks cancel then this function never returns. 101 | // 102 | // It's a blocking call, you should call it on a separated goroutine. For most OSes, only one 103 | // ChooseFile or CreateFile, can happen at the same time, for each app.Window/Explorer. 104 | func (e *Explorer) ChooseFile(extensions ...string) (io.ReadCloser, error) { 105 | if e == nil { 106 | return nil, ErrNotAvailable 107 | } 108 | 109 | if runtime.GOOS != "js" { 110 | e.mutex.Lock() 111 | defer e.mutex.Unlock() 112 | } 113 | 114 | return e.importFile(extensions...) 115 | } 116 | 117 | // ChooseFiles shows the files selector, allowing the user to select multiple files. 118 | // Optionally, it's possible to define which file extensions is supported to 119 | // be selected (such as `.jpg`, `.png`). 120 | // 121 | // Example: ChooseFiles(".jpg", ".png") will only accept the selection of files with 122 | // .jpg or .png extensions. 123 | // 124 | // In some platforms the resulting `io.ReadCloser` is a `os.File`, but it's not 125 | // a guarantee. 126 | // 127 | // In most known browsers, when user clicks cancel then this function never returns. 128 | // 129 | // It's a blocking call, you should call it on a separated goroutine. For most OSes, only one 130 | // ChooseFile{,s} or CreateFile, can happen at the same time, for each app.Window/Explorer. 131 | func (e *Explorer) ChooseFiles(extensions ...string) ([]io.ReadCloser, error) { 132 | if e == nil { 133 | return nil, ErrNotAvailable 134 | } 135 | 136 | if runtime.GOOS != "js" { 137 | e.mutex.Lock() 138 | defer e.mutex.Unlock() 139 | } 140 | 141 | return e.importFiles(extensions...) 142 | } 143 | 144 | // CreateFile opens the file selector, and writes the given content into 145 | // some file, which the use can choose the location. 146 | // 147 | // It's important to close the `io.WriteCloser`. In some platforms the 148 | // file will be saved only when the writer is closer. 149 | // 150 | // In some platforms the resulting `io.WriteCloser` is a `os.File`, but it's not 151 | // a guarantee. 152 | // 153 | // It's a blocking call, you should call it on a separated goroutine. For most OSes, only one 154 | // ChooseFile or CreateFile, can happen at the same time, for each app.Window/Explorer. 155 | func (e *Explorer) CreateFile(name string) (io.WriteCloser, error) { 156 | if e == nil { 157 | return nil, ErrNotAvailable 158 | } 159 | 160 | if runtime.GOOS != "js" { 161 | e.mutex.Lock() 162 | defer e.mutex.Unlock() 163 | } 164 | 165 | return e.exportFile(name) 166 | } 167 | 168 | var ( 169 | DefaultExplorer *Explorer 170 | ) 171 | 172 | // ListenEventsWindow calls Explorer.ListenEvents on DefaultExplorer, 173 | // and creates a new Explorer, if needed. 174 | // 175 | // Deprecated: Use NewExplorer instead. 176 | func ListenEventsWindow(win *app.Window, event event.Event) { 177 | if DefaultExplorer == nil { 178 | DefaultExplorer = NewExplorer(win) 179 | } 180 | DefaultExplorer.ListenEvents(event) 181 | } 182 | 183 | // ReadFile calls Explorer.ChooseFile on DefaultExplorer. 184 | // 185 | // Deprecated: Use NewExplorer and Explorer.ChooseFile instead. 186 | func ReadFile(extensions ...string) (io.ReadCloser, error) { 187 | return DefaultExplorer.ChooseFile(extensions...) 188 | } 189 | 190 | // WriteFile calls Explorer.CreateFile on DefaultExplorer. 191 | // 192 | // Deprecated: Use NewExplorer and Explorer.CreateFile instead. 193 | func WriteFile(name string) (io.WriteCloser, error) { 194 | return DefaultExplorer.CreateFile(name) 195 | } 196 | -------------------------------------------------------------------------------- /explorer/explorer_android.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package explorer 4 | 5 | /* 6 | #cgo LDFLAGS: -landroid 7 | 8 | #include 9 | #include 10 | */ 11 | import "C" 12 | import ( 13 | "errors" 14 | "io" 15 | "mime" 16 | "path/filepath" 17 | "strings" 18 | "unsafe" 19 | 20 | "gioui.org/app" 21 | "gioui.org/io/event" 22 | "git.wow.st/gmp/jni" 23 | ) 24 | 25 | //go:generate javac -source 8 -target 8 -bootclasspath $ANDROID_HOME/platforms/android-30/android.jar -d $TEMP/explorer_explorer_android/classes explorer_android.java 26 | //go:generate jar cf explorer_android.jar -C $TEMP/explorer_explorer_android/classes . 27 | 28 | type explorer struct { 29 | window *app.Window 30 | view uintptr 31 | 32 | libObject jni.Object 33 | libClass jni.Class 34 | 35 | importFile jni.MethodID 36 | exportFile jni.MethodID 37 | 38 | result chan result 39 | } 40 | 41 | func newExplorer(w *app.Window) *explorer { 42 | return &explorer{window: w, result: make(chan result)} 43 | } 44 | 45 | // init will get all necessary MethodID (to future JNI calls) and get our Java library/class (which 46 | // is defined on explorer_android.java file). The Java class doesn't retain information about the view, 47 | // the view (GioView/GioActivity) is passed as argument for each importFile/exportFile function, so it 48 | // can safely change between each call. 49 | func (e *explorer) init(env jni.Env) error { 50 | if e.libObject != 0 && e.libClass != 0 { 51 | return nil // Already initialized 52 | } 53 | 54 | class, err := jni.LoadClass(env, jni.ClassLoaderFor(env, jni.Object(app.AppContext())), "org/gioui/x/explorer/explorer_android") 55 | if err != nil { 56 | return err 57 | } 58 | 59 | obj, err := jni.NewObject(env, class, jni.GetMethodID(env, class, "", `()V`)) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | e.libObject = jni.NewGlobalRef(env, obj) 65 | e.libClass = jni.Class(jni.NewGlobalRef(env, jni.Object(class))) 66 | e.importFile = jni.GetMethodID(env, e.libClass, "importFile", "(Landroid/view/View;Ljava/lang/String;I)V") 67 | e.exportFile = jni.GetMethodID(env, e.libClass, "exportFile", "(Landroid/view/View;Ljava/lang/String;I)V") 68 | 69 | return nil 70 | } 71 | 72 | func (e *Explorer) listenEvents(evt event.Event) { 73 | if evt, ok := evt.(app.AndroidViewEvent); ok { 74 | e.view = evt.View 75 | } 76 | } 77 | 78 | func (e *Explorer) exportFile(name string) (io.WriteCloser, error) { 79 | go e.window.Run(func() { 80 | err := jni.Do(jni.JVMFor(app.JavaVM()), func(env jni.Env) error { 81 | if err := e.init(env); err != nil { 82 | return err 83 | } 84 | 85 | return jni.CallVoidMethod(env, e.libObject, e.explorer.exportFile, 86 | jni.Value(e.view), 87 | jni.Value(jni.JavaString(env, strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), "."))), 88 | jni.Value(e.id), 89 | ) 90 | }) 91 | 92 | if err != nil { 93 | e.result <- result{error: err} 94 | } 95 | }) 96 | 97 | file := <-e.result 98 | if file.error != nil { 99 | return nil, file.error 100 | } 101 | return file.file.(io.WriteCloser), nil 102 | } 103 | 104 | func (e *Explorer) importFile(extensions ...string) (io.ReadCloser, error) { 105 | for i, ext := range extensions { 106 | extensions[i] = mime.TypeByExtension(ext) 107 | } 108 | 109 | mimes := strings.Join(extensions, ",") 110 | go e.window.Run(func() { 111 | err := jni.Do(jni.JVMFor(app.JavaVM()), func(env jni.Env) error { 112 | if err := e.init(env); err != nil { 113 | return err 114 | } 115 | 116 | return jni.CallVoidMethod(env, e.libObject, e.explorer.importFile, 117 | jni.Value(e.view), 118 | jni.Value(jni.JavaString(env, mimes)), 119 | jni.Value(e.id), 120 | ) 121 | }) 122 | 123 | if err != nil { 124 | e.result <- result{error: err} 125 | } 126 | }) 127 | 128 | file := <-e.result 129 | if file.error != nil { 130 | return nil, file.error 131 | } 132 | return file.file.(io.ReadCloser), nil 133 | } 134 | 135 | func (e *Explorer) importFiles(_ ...string) ([]io.ReadCloser, error) { 136 | return nil, ErrNotAvailable 137 | } 138 | 139 | //export Java_org_gioui_x_explorer_explorer_1android_ImportCallback 140 | func Java_org_gioui_x_explorer_explorer_1android_ImportCallback(env *C.JNIEnv, _ C.jclass, stream C.jobject, id C.jint, err C.jstring) { 141 | fileCallback(env, stream, id, err) 142 | } 143 | 144 | //export Java_org_gioui_x_explorer_explorer_1android_ExportCallback 145 | func Java_org_gioui_x_explorer_explorer_1android_ExportCallback(env *C.JNIEnv, _ C.jclass, stream C.jobject, id C.jint, err C.jstring) { 146 | fileCallback(env, stream, id, err) 147 | } 148 | 149 | func fileCallback(env *C.JNIEnv, stream C.jobject, id C.jint, err C.jstring) { 150 | var res result 151 | if v, ok := active.Load(int32(id)); ok { 152 | env := jni.EnvFor(uintptr(unsafe.Pointer(env))) 153 | if stream == 0 { 154 | res.error = ErrUserDecline 155 | if err != 0 { 156 | if err := jni.GoString(env, jni.String(uintptr(err))); len(err) > 0 { 157 | res.error = errors.New(err) 158 | } 159 | } 160 | } else { 161 | res.file, res.error = newFile(env, jni.NewGlobalRef(env, jni.Object(uintptr(stream)))) 162 | } 163 | v.(*explorer).result <- res 164 | } 165 | } 166 | 167 | var ( 168 | _ io.ReadCloser = (*File)(nil) 169 | _ io.WriteCloser = (*File)(nil) 170 | ) 171 | -------------------------------------------------------------------------------- /explorer/explorer_android.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/explorer/explorer_android.jar -------------------------------------------------------------------------------- /explorer/explorer_android.java: -------------------------------------------------------------------------------- 1 | package org.gioui.x.explorer; 2 | 3 | import android.content.Context; 4 | import android.util.Log; 5 | import android.content.Intent; 6 | import android.view.View; 7 | import android.app.Activity; 8 | import android.Manifest; 9 | import android.content.pm.PackageManager; 10 | import android.os.Handler.Callback; 11 | import android.os.Handler; 12 | import android.net.Uri; 13 | import android.app.Fragment; 14 | import android.app.FragmentManager; 15 | import android.app.FragmentTransaction; 16 | import android.os.Looper; 17 | import android.content.ContentResolver; 18 | import java.io.InputStream; 19 | import java.io.OutputStream; 20 | import android.webkit.MimeTypeMap; 21 | import java.io.ByteArrayOutputStream; 22 | import java.io.Closeable; 23 | import java.io.Flushable; 24 | import java.util.ArrayList; 25 | import java.util.List; 26 | 27 | public class explorer_android { 28 | final Fragment frag = new explorer_android_fragment(); 29 | 30 | // List of requestCode used in the callback, to identify the caller. 31 | static List import_codes = new ArrayList(); 32 | static List export_codes = new ArrayList(); 33 | 34 | // Functions defined on Golang. 35 | static public native void ImportCallback(InputStream f, int id, String err); 36 | static public native void ExportCallback(OutputStream f, int id, String err); 37 | 38 | public static class explorer_android_fragment extends Fragment { 39 | Context context; 40 | 41 | @Override public void onAttach(Context ctx) { 42 | context = ctx; 43 | super.onAttach(ctx); 44 | } 45 | 46 | @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { 47 | super.onActivityResult(requestCode, resultCode, data); 48 | 49 | Activity activity = this.getActivity(); 50 | 51 | activity.runOnUiThread(new Runnable() { 52 | public void run() { 53 | if (import_codes.contains(Integer.valueOf(requestCode))) { 54 | import_codes.remove(Integer.valueOf(requestCode)); 55 | if (resultCode != Activity.RESULT_OK) { 56 | explorer_android.ImportCallback(null, requestCode, ""); 57 | activity.getFragmentManager().popBackStack(); 58 | return; 59 | } 60 | try { 61 | InputStream f = activity.getApplicationContext().getContentResolver().openInputStream(data.getData()); 62 | explorer_android.ImportCallback(f, requestCode, ""); 63 | } catch (Exception e) { 64 | explorer_android.ImportCallback(null, requestCode, e.toString()); 65 | return; 66 | } 67 | } 68 | 69 | if (export_codes.contains(Integer.valueOf(requestCode))) { 70 | export_codes.remove(Integer.valueOf(requestCode)); 71 | if (resultCode != Activity.RESULT_OK) { 72 | explorer_android.ExportCallback(null, requestCode, ""); 73 | activity.getFragmentManager().popBackStack(); 74 | return; 75 | } 76 | try { 77 | OutputStream f = activity.getApplicationContext().getContentResolver().openOutputStream(data.getData(), "wt"); 78 | explorer_android.ExportCallback(f, requestCode, ""); 79 | } catch (Exception e) { 80 | explorer_android.ExportCallback(null, requestCode, e.toString()); 81 | return; 82 | } 83 | } 84 | } 85 | }); 86 | 87 | } 88 | } 89 | 90 | public void exportFile(View view, String ext, int id) { 91 | askPermission(view); 92 | 93 | ((Activity) view.getContext()).runOnUiThread(new Runnable() { 94 | public void run() { 95 | registerFrag(view); 96 | export_codes.add(Integer.valueOf(id)); 97 | 98 | final Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); 99 | intent.setType(MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)); 100 | intent.addCategory(Intent.CATEGORY_OPENABLE); 101 | frag.startActivityForResult(Intent.createChooser(intent, ""), id); 102 | } 103 | }); 104 | } 105 | 106 | public void importFile(View view, String mime, int id) { 107 | askPermission(view); 108 | 109 | ((Activity) view.getContext()).runOnUiThread(new Runnable() { 110 | public void run() { 111 | registerFrag(view); 112 | import_codes.add(Integer.valueOf(id)); 113 | 114 | final Intent intent = new Intent(Intent.ACTION_GET_CONTENT); 115 | intent.setType("*/*"); 116 | intent.addCategory(Intent.CATEGORY_OPENABLE); 117 | 118 | if (mime != null) { 119 | final String[] mimes = mime.split(","); 120 | if (mimes != null && mimes.length > 0) { 121 | intent.putExtra(Intent.EXTRA_MIME_TYPES, mimes); 122 | } 123 | } 124 | frag.startActivityForResult(Intent.createChooser(intent, ""), id); 125 | } 126 | }); 127 | } 128 | 129 | public void registerFrag(View view) { 130 | final Context ctx = view.getContext(); 131 | final FragmentManager fm; 132 | 133 | try { 134 | fm = (FragmentManager) ctx.getClass().getMethod("getFragmentManager").invoke(ctx); 135 | } catch (Exception e) { 136 | e.printStackTrace(); 137 | return; 138 | } 139 | 140 | if (fm.findFragmentByTag("explorer_android_fragment") != null) { 141 | return; // Already exists; 142 | } 143 | 144 | FragmentTransaction ft = fm.beginTransaction(); 145 | ft.add(frag, "explorer_android_fragment"); 146 | ft.commitNow(); 147 | } 148 | 149 | public void askPermission(View view) { 150 | Activity activity = (Activity) view.getContext(); 151 | 152 | if (activity.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { 153 | activity.requestPermissions(new String[] { Manifest.permission.READ_EXTERNAL_STORAGE }, 255); 154 | } 155 | 156 | if (activity.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { 157 | activity.requestPermissions(new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE }, 254); 158 | } 159 | } 160 | } -------------------------------------------------------------------------------- /explorer/explorer_ios.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | //go:build ios 4 | // +build ios 5 | 6 | package explorer 7 | 8 | /* 9 | #cgo CFLAGS: -Werror -xobjective-c -fmodules -fobjc-arc 10 | 11 | #include 12 | #include 13 | 14 | // Defined on explorer_ios.m file (implements UIDocumentPickerDelegate). 15 | @interface explorer_picker:NSObject 16 | @property (strong) UIDocumentPickerViewController * picker; 17 | @property (strong) UIViewController * controller; 18 | @property uint64_t mode; 19 | @property uint32_t id; 20 | @end 21 | 22 | static const uint64_t IMPORT_MODE = 1; 23 | static const uint64_t EXPORT_MODE = 2; 24 | 25 | extern CFTypeRef createPicker(CFTypeRef controllerRef, int32_t id); 26 | extern bool exportFile(CFTypeRef expl, char * name); 27 | extern bool importFile(CFTypeRef expl, char * ext); 28 | */ 29 | import "C" 30 | import ( 31 | "io" 32 | "os" 33 | "path/filepath" 34 | "strings" 35 | 36 | "gioui.org/app" 37 | "gioui.org/io/event" 38 | ) 39 | 40 | type explorer struct { 41 | window *app.Window 42 | picker C.CFTypeRef 43 | result chan result 44 | } 45 | 46 | func newExplorer(w *app.Window) *explorer { 47 | return &explorer{window: w, result: make(chan result)} 48 | } 49 | 50 | func (e *Explorer) listenEvents(evt event.Event) { 51 | switch evt := evt.(type) { 52 | case app.UIKitViewEvent: 53 | e.explorer.picker = C.createPicker(C.CFTypeRef(evt.ViewController), C.int32_t(e.id)) 54 | } 55 | } 56 | 57 | func (e *Explorer) exportFile(name string) (io.WriteCloser, error) { 58 | name = filepath.Join(os.TempDir(), name) 59 | 60 | f, err := os.Create(name) 61 | if err != nil { 62 | return nil, nil 63 | } 64 | f.Close() 65 | 66 | name = "file://" + name 67 | 68 | go e.window.Run(func() { 69 | if ok := bool(C.exportFile(e.explorer.picker, C.CString(name))); !ok { 70 | e.result <- result{error: ErrNotAvailable} 71 | } 72 | }) 73 | 74 | file := <-e.result 75 | if file.error != nil { 76 | return nil, file.error 77 | } 78 | return file.file.(io.WriteCloser), nil 79 | } 80 | 81 | func (e *Explorer) importFile(extensions ...string) (io.ReadCloser, error) { 82 | for i, ext := range extensions { 83 | extensions[i] = strings.TrimPrefix(ext, ".") 84 | } 85 | 86 | cextensions := C.CString(strings.Join(extensions, ",")) 87 | go e.window.Run(func() { 88 | if ok := bool(C.importFile(e.explorer.picker, cextensions)); !ok { 89 | e.result <- result{error: ErrNotAvailable} 90 | } 91 | }) 92 | 93 | file := <-e.result 94 | if file.error != nil { 95 | return nil, file.error 96 | } 97 | return file.file.(io.ReadCloser), nil 98 | } 99 | 100 | func (e *Explorer) importFiles(_ ...string) ([]io.ReadCloser, error) { 101 | return nil, ErrNotAvailable 102 | } 103 | 104 | //export importCallback 105 | func importCallback(u C.CFTypeRef, id C.int32_t) { 106 | fileCallback(u, id) 107 | } 108 | 109 | //export exportCallback 110 | func exportCallback(u C.CFTypeRef, id C.int32_t) { 111 | fileCallback(u, id) 112 | } 113 | 114 | func fileCallback(u C.CFTypeRef, id C.int32_t) { 115 | var res result 116 | if v, ok := active.Load(int32(id)); ok { 117 | if u == 0 { 118 | res.error = ErrUserDecline 119 | } else { 120 | res.file, res.error = newFile(u) 121 | } 122 | v.(*explorer).result <- res 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /explorer/explorer_ios.m: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | //go:build ios 4 | // +build ios 5 | 6 | #include 7 | #include 8 | #include 9 | #include "_cgo_export.h" 10 | 11 | @implementation explorer_picker 12 | - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { 13 | NSURL *url = [urls objectAtIndex:0]; 14 | 15 | switch (self.mode) { 16 | case EXPORT_MODE: 17 | exportCallback((__bridge_retained CFTypeRef)url, self.id); 18 | return; 19 | case IMPORT_MODE: 20 | importCallback((__bridge_retained CFTypeRef)url, self.id); 21 | return; 22 | } 23 | } 24 | - (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller { 25 | switch (self.mode) { 26 | case EXPORT_MODE: 27 | exportCallback(0, self.id); 28 | return; 29 | case IMPORT_MODE: 30 | importCallback(0, self.id); 31 | return; 32 | } 33 | } 34 | @end 35 | 36 | CFTypeRef createPicker(CFTypeRef controllerRef, int32_t id) { 37 | explorer_picker *e = [[explorer_picker alloc] init]; 38 | e.controller = (__bridge UIViewController *)controllerRef; 39 | e.id = id; 40 | return (__bridge_retained CFTypeRef)e; 41 | } 42 | 43 | bool exportFile(CFTypeRef expl, char * name) { 44 | if (@available(iOS 14, *)) { 45 | explorer_picker *explorer = (__bridge explorer_picker *)expl; 46 | explorer.picker = [[UIDocumentPickerViewController alloc] initForExportingURLs:@[[NSURL URLWithString:@(name)]] asCopy:true]; 47 | explorer.picker.delegate = explorer; 48 | explorer.mode = EXPORT_MODE; 49 | 50 | [explorer.controller presentViewController:explorer.picker animated:YES completion:nil]; 51 | return YES; 52 | } 53 | return NO; 54 | } 55 | 56 | bool importFile(CFTypeRef expl, char * ext) { 57 | if (@available(iOS 14, *)) { 58 | explorer_picker *explorer = (__bridge explorer_picker *)expl; 59 | 60 | NSMutableArray *exts = [[@(ext) componentsSeparatedByString:@","] mutableCopy]; 61 | NSMutableArray *contentTypes = [[NSMutableArray alloc]init]; 62 | 63 | int i; 64 | for (i = 0; i < [exts count]; i++) { 65 | UTType *utt = [UTType typeWithFilenameExtension:exts[i]]; 66 | if (utt != nil) { 67 | [contentTypes addObject:utt]; 68 | } 69 | } 70 | 71 | explorer.picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:contentTypes asCopy:true]; 72 | explorer.picker.delegate = explorer; 73 | explorer.mode = IMPORT_MODE; 74 | 75 | [explorer.controller presentViewController:explorer.picker animated:YES completion:nil]; 76 | return YES; 77 | } 78 | return NO; 79 | } 80 | -------------------------------------------------------------------------------- /explorer/explorer_js.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package explorer 4 | 5 | import ( 6 | "io" 7 | "strings" 8 | "syscall/js" 9 | 10 | "gioui.org/app" 11 | "gioui.org/io/event" 12 | ) 13 | 14 | type explorer struct{} 15 | 16 | func newExplorer(_ *app.Window) *explorer { 17 | return &explorer{} 18 | } 19 | 20 | func (e *Explorer) listenEvents(_ event.Event) { 21 | // NO-OP 22 | } 23 | 24 | func (e *Explorer) exportFile(name string) (io.WriteCloser, error) { 25 | return newFileWriter(name), nil 26 | } 27 | 28 | func (e *Explorer) importFile(extensions ...string) (io.ReadCloser, error) { 29 | // TODO: Replace with "File System Access API" when that becomes available on most browsers. 30 | // BUG: Not work on iOS/Safari. 31 | 32 | // It's not possible to know if the user closes the file-picker dialog, so an new channel is needed. 33 | r := make(chan result) 34 | 35 | document := js.Global().Get("document") 36 | input := document.Call("createElement", "input") 37 | input.Call("addEventListener", "change", openCallback(r)) 38 | input.Call("addEventListener", "cancel", openCallback(r)) 39 | input.Set("type", "file") 40 | input.Set("style", "display:none;") 41 | if len(extensions) > 0 { 42 | input.Set("accept", strings.Join(extensions, ",")) 43 | } 44 | document.Get("body").Call("appendChild", input) 45 | input.Call("click") 46 | 47 | file := <-r 48 | if file.error != nil { 49 | return nil, file.error 50 | } 51 | return file.file.(io.ReadCloser), nil 52 | } 53 | 54 | func (e *Explorer) importFiles(_ ...string) ([]io.ReadCloser, error) { 55 | return nil, ErrNotAvailable 56 | } 57 | 58 | type FileReader struct { 59 | buffer js.Value 60 | isClosed bool 61 | index int 62 | callback chan js.Value 63 | successFunc, failureFunc js.Func 64 | } 65 | 66 | func newFileReader(v js.Value) *FileReader { 67 | f := &FileReader{ 68 | buffer: v, 69 | callback: make(chan js.Value, 1), 70 | } 71 | f.successFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} { 72 | f.callback <- args[0] 73 | return nil 74 | }) 75 | f.failureFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} { 76 | f.callback <- js.Undefined() 77 | return nil 78 | }) 79 | 80 | return f 81 | } 82 | 83 | func (f *FileReader) Read(b []byte) (n int, err error) { 84 | if f == nil || f.isClosed { 85 | return 0, io.ErrClosedPipe 86 | } 87 | 88 | go func() { 89 | fileSlice(f.index, f.index+len(b), f.buffer, f.successFunc, f.failureFunc) 90 | }() 91 | 92 | buffer := <-f.callback 93 | if !buffer.Truthy() { 94 | return 0, io.ErrUnexpectedEOF 95 | } 96 | 97 | n = fileRead(buffer, b) 98 | if n == 0 { 99 | return 0, io.EOF 100 | } 101 | f.index += n 102 | 103 | return n, err 104 | } 105 | 106 | func (f *FileReader) Close() error { 107 | if f == nil || f.isClosed { 108 | return io.ErrClosedPipe 109 | } 110 | 111 | f.failureFunc.Release() 112 | f.successFunc.Release() 113 | f.isClosed = true 114 | return nil 115 | } 116 | 117 | type FileWriter struct { 118 | buffers []js.Value 119 | isClosed bool 120 | name string 121 | successFunc, failureFunc js.Func 122 | } 123 | 124 | func newFileWriter(name string) *FileWriter { 125 | return &FileWriter{ 126 | name: name, 127 | } 128 | } 129 | 130 | func (f *FileWriter) Write(b []byte) (n int, err error) { 131 | if f == nil || f.isClosed { 132 | return 0, io.ErrClosedPipe 133 | } 134 | if len(b) == 0 { 135 | return 0, nil 136 | } 137 | 138 | buff := js.Global().Get("Uint8Array").New(len(b)) 139 | fileWrite(buff, b) 140 | f.buffers = append(f.buffers, buff) 141 | return len(b), err 142 | } 143 | 144 | func (f *FileWriter) Close() error { 145 | if f == nil || f.isClosed { 146 | return io.ErrClosedPipe 147 | } 148 | f.isClosed = true 149 | return f.saveFile() 150 | } 151 | 152 | func (f *FileWriter) saveFile() error { 153 | config := js.Global().Get("Object").New() 154 | config.Set("type", "octet/stream") 155 | 156 | buffs := js.Global().Get("Array").New(len(f.buffers)) 157 | for idx, buf := range f.buffers { 158 | buffs.SetIndex(idx, js.ValueOf(buf)) 159 | } 160 | blob := js.Global().Get("Blob").New( 161 | buffs, 162 | config, 163 | ) 164 | 165 | document := js.Global().Get("document") 166 | anchor := document.Call("createElement", "a") 167 | anchor.Set("download", f.name) 168 | anchor.Set("href", js.Global().Get("URL").Call("createObjectURL", blob)) 169 | document.Get("body").Call("appendChild", anchor) 170 | anchor.Call("click") 171 | 172 | return nil 173 | } 174 | 175 | func fileRead(value js.Value, b []byte) int { 176 | return js.CopyBytesToGo(b, js.Global().Get("Uint8Array").New(value)) 177 | } 178 | 179 | func fileWrite(value js.Value, b []byte) int { 180 | return js.CopyBytesToJS(value, b) 181 | } 182 | 183 | func fileSlice(start, end int, value js.Value, success, failure js.Func) { 184 | value.Call("slice", start, end).Call("arrayBuffer").Call("then", success, failure) 185 | } 186 | 187 | func openCallback(r chan result) js.Func { 188 | // There's no way to detect when the dialog is closed, so we can't re-use the callback. 189 | return js.FuncOf(func(this js.Value, args []js.Value) interface{} { 190 | files := args[0].Get("target").Get("files") 191 | if files.Length() <= 0 { 192 | r <- result{error: ErrUserDecline} 193 | return nil 194 | } 195 | r <- result{file: newFileReader(files.Index(0))} 196 | return nil 197 | }) 198 | } 199 | 200 | var ( 201 | _ io.ReadCloser = (*FileReader)(nil) 202 | _ io.WriteCloser = (*FileWriter)(nil) 203 | ) 204 | -------------------------------------------------------------------------------- /explorer/explorer_macos.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | //go:build darwin && !ios 4 | // +build darwin,!ios 5 | 6 | package explorer 7 | 8 | /* 9 | #cgo CFLAGS: -Werror -xobjective-c -fmodules -fobjc-arc 10 | 11 | #import 12 | 13 | // Defined on explorer_macos.m file. 14 | extern void exportFile(CFTypeRef viewRef, char * name, int32_t id); 15 | extern void importFile(CFTypeRef viewRef, char * ext, int32_t id); 16 | */ 17 | import "C" 18 | import ( 19 | "io" 20 | "net/url" 21 | "os" 22 | "strings" 23 | 24 | "gioui.org/app" 25 | "gioui.org/io/event" 26 | ) 27 | 28 | type explorer struct { 29 | window *app.Window 30 | view C.CFTypeRef 31 | result chan result 32 | } 33 | 34 | func newExplorer(w *app.Window) *explorer { 35 | return &explorer{window: w, result: make(chan result)} 36 | } 37 | 38 | func (e *Explorer) listenEvents(evt event.Event) { 39 | switch evt := evt.(type) { 40 | case app.AppKitViewEvent: 41 | e.view = C.CFTypeRef(evt.View) 42 | } 43 | } 44 | 45 | func (e *Explorer) exportFile(name string) (io.WriteCloser, error) { 46 | cname := C.CString(name) 47 | e.window.Run(func() { C.exportFile(e.view, cname, C.int32_t(e.id)) }) 48 | 49 | resp := <-e.result 50 | if resp.error != nil { 51 | return nil, resp.error 52 | } 53 | return resp.file.(io.WriteCloser), resp.error 54 | 55 | } 56 | 57 | func (e *Explorer) importFile(extensions ...string) (io.ReadCloser, error) { 58 | for i, ext := range extensions { 59 | extensions[i] = strings.TrimPrefix(ext, ".") 60 | } 61 | 62 | cextensions := C.CString(strings.Join(extensions, ",")) 63 | e.window.Run(func() { C.importFile(e.view, cextensions, C.int32_t(e.id)) }) 64 | 65 | resp := <-e.result 66 | if resp.error != nil { 67 | return nil, resp.error 68 | } 69 | return resp.file.(io.ReadCloser), resp.error 70 | } 71 | 72 | func (e *Explorer) importFiles(_ ...string) ([]io.ReadCloser, error) { 73 | return nil, ErrNotAvailable 74 | } 75 | 76 | //export importCallback 77 | func importCallback(u *C.char, id int32) { 78 | if v, ok := active.Load(id); ok { 79 | v.(*explorer).result <- newOSFile(u, os.Open) 80 | } 81 | } 82 | 83 | //export exportCallback 84 | func exportCallback(u *C.char, id int32) { 85 | if v, ok := active.Load(id); ok { 86 | v.(*explorer).result <- newOSFile(u, os.Create) 87 | } 88 | } 89 | 90 | func newOSFile(u *C.char, action func(s string) (*os.File, error)) result { 91 | name := C.GoString(u) 92 | if name == "" { 93 | return result{error: ErrUserDecline, file: nil} 94 | } 95 | 96 | uri, err := url.Parse(name) 97 | if err != nil { 98 | return result{error: err, file: nil} 99 | } 100 | 101 | path, err := url.PathUnescape(uri.Path) 102 | if err != nil { 103 | return result{error: err, file: nil} 104 | } 105 | 106 | f, err := action(path) 107 | return result{error: err, file: f} 108 | } 109 | -------------------------------------------------------------------------------- /explorer/explorer_macos.m: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | //go:build darwin && !ios 4 | // +build darwin,!ios 5 | 6 | #include "_cgo_export.h" 7 | #import 8 | #import 9 | #import 10 | 11 | void exportFile(CFTypeRef viewRef, char * name, int32_t id) { 12 | NSView *view = (__bridge NSView *)viewRef; 13 | 14 | NSSavePanel *panel = [NSSavePanel savePanel]; 15 | 16 | [panel setNameFieldStringValue:@(name)]; 17 | [panel beginSheetModalForWindow:[view window] completionHandler:^(NSInteger result){ 18 | if (result == NSModalResponseOK) { 19 | exportCallback((char *)[[panel URL].absoluteString UTF8String], id); 20 | } else { 21 | exportCallback((char *)(""), id); 22 | } 23 | }]; 24 | } 25 | 26 | void importFile(CFTypeRef viewRef, char * ext, int32_t id) { 27 | NSView *view = (__bridge NSView *)viewRef; 28 | 29 | NSOpenPanel *panel = [NSOpenPanel openPanel]; 30 | 31 | NSMutableArray *exts = [[@(ext) componentsSeparatedByString:@","] mutableCopy]; 32 | NSMutableArray *contentTypes = [[NSMutableArray alloc]init]; 33 | 34 | int i; 35 | for (i = 0; i < [exts count]; i++) { 36 | UTType * utt = [UTType typeWithFilenameExtension:exts[i]]; 37 | if (utt != nil){ 38 | [contentTypes addObject:utt]; 39 | } 40 | } 41 | 42 | [(NSSavePanel*)panel setAllowedContentTypes:[NSArray arrayWithArray:contentTypes]]; 43 | [panel beginSheetModalForWindow:[view window] completionHandler:^(NSInteger result){ 44 | if (result == NSModalResponseOK) { 45 | importCallback((char *)[[panel URL].absoluteString UTF8String], id); 46 | } else { 47 | importCallback((char *)(""), id); 48 | } 49 | }]; 50 | } -------------------------------------------------------------------------------- /explorer/explorer_unsupported.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | //go:build !windows && !android && !js && !darwin && !ios && !linux 4 | // +build !windows,!android,!js,!darwin,!ios,!linux 5 | 6 | package explorer 7 | 8 | import ( 9 | "io" 10 | 11 | "gioui.org/app" 12 | "gioui.org/io/event" 13 | ) 14 | 15 | type explorer struct{} 16 | 17 | func newExplorer(w *app.Window) *explorer { 18 | return new(explorer) 19 | } 20 | 21 | func (e *Explorer) listenEvents(_ event.Event) {} 22 | 23 | func (e *Explorer) exportFile(_ string) (io.WriteCloser, error) { 24 | return nil, ErrNotAvailable 25 | } 26 | 27 | func (e *Explorer) importFile(_ ...string) (io.ReadCloser, error) { 28 | return nil, ErrNotAvailable 29 | } 30 | 31 | func (e *Explorer) importFiles(_ ...string) ([]io.ReadCloser, error) { 32 | return nil, ErrNotAvailable 33 | } 34 | -------------------------------------------------------------------------------- /explorer/explorer_windows.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package explorer 4 | 5 | import ( 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "unsafe" 11 | 12 | "gioui.org/app" 13 | "gioui.org/io/event" 14 | "golang.org/x/sys/windows" 15 | ) 16 | 17 | var ( 18 | // https://docs.microsoft.com/en-us/windows/win32/api/commdlg/ 19 | _Dialog32 = windows.NewLazySystemDLL("comdlg32.dll") 20 | 21 | _GetSaveFileName = _Dialog32.NewProc("GetSaveFileNameW") 22 | _GetOpenFileName = _Dialog32.NewProc("GetOpenFileNameW") 23 | 24 | // https://docs.microsoft.com/en-us/windows/win32/api/commdlg/ns-commdlg-openfilenamew 25 | _FlagFileMustExist = uint32(0x00001000) 26 | _FlagForceShowHidden = uint32(0x10000000) 27 | _FlagOverwritePrompt = uint32(0x00000002) 28 | _FlagDisableLinks = uint32(0x00100000) 29 | _FlagAllowMultiSelect = uint32(0x00000200) 30 | _FlagExplorer = uint32(0x00080000) 31 | 32 | _FilePathLength = uint32(65535) 33 | _OpenFileStructLength = uint32(unsafe.Sizeof(_OpenFileName{})) 34 | ) 35 | 36 | type ( 37 | // _OpenFileName is defined at https://docs.microsoft.com/pt-br/windows/win32/api/commdlg/ns-commdlg-openfilenamew 38 | _OpenFileName struct { 39 | StructSize uint32 40 | Owner uintptr 41 | Instance uintptr 42 | Filter *uint16 43 | CustomFilter *uint16 44 | MaxCustomFilter uint32 45 | FilterIndex uint32 46 | File *uint16 47 | MaxFile uint32 48 | FileTitle *uint16 49 | MaxFileTitle uint32 50 | InitialDir *uint16 51 | Title *uint16 52 | Flags uint32 53 | FileOffset uint16 54 | FileExtension uint16 55 | DefExt *uint16 56 | CustData uintptr 57 | FnHook uintptr 58 | TemplateName *uint16 59 | PvReserved uintptr 60 | DwReserved uint32 61 | FlagsEx uint32 62 | } 63 | ) 64 | 65 | type explorer struct{} 66 | 67 | func newExplorer(_ *app.Window) *explorer { 68 | return &explorer{} 69 | } 70 | 71 | func (e *Explorer) listenEvents(evt event.Event) { 72 | // NO-OP 73 | } 74 | 75 | func (e *Explorer) exportFile(name string) (io.WriteCloser, error) { 76 | pathUTF16 := make([]uint16, _FilePathLength) 77 | copy(pathUTF16, windows.StringToUTF16(name)) 78 | 79 | open := _OpenFileName{ 80 | File: &pathUTF16[0], 81 | MaxFile: _FilePathLength, 82 | Filter: buildFilter([]string{filepath.Ext(name)}), 83 | FileExtension: uint16(strings.Index(name, filepath.Ext(name))), 84 | Flags: _FlagOverwritePrompt, 85 | StructSize: _OpenFileStructLength, 86 | } 87 | 88 | if r, _, _ := _GetSaveFileName.Call(uintptr(unsafe.Pointer(&open))); r == 0 { 89 | return nil, ErrUserDecline 90 | } 91 | 92 | path := windows.UTF16ToString(pathUTF16) 93 | if len(path) == 0 { 94 | return nil, ErrUserDecline 95 | } 96 | 97 | return os.Create(path) 98 | } 99 | 100 | func (e *Explorer) importFile(extensions ...string) (io.ReadCloser, error) { 101 | pathUTF16 := make([]uint16, _FilePathLength) 102 | 103 | open := _OpenFileName{ 104 | File: &pathUTF16[0], 105 | MaxFile: _FilePathLength, 106 | Filter: buildFilter(extensions), 107 | Flags: _FlagFileMustExist | _FlagForceShowHidden | _FlagDisableLinks, 108 | StructSize: _OpenFileStructLength, 109 | } 110 | 111 | if r, _, _ := _GetOpenFileName.Call(uintptr(unsafe.Pointer(&open))); r == 0 { 112 | return nil, ErrUserDecline 113 | } 114 | 115 | path := windows.UTF16ToString(pathUTF16) 116 | if len(path) == 0 { 117 | return nil, ErrUserDecline 118 | } 119 | 120 | return os.Open(path) 121 | } 122 | 123 | func (e *Explorer) importFiles(extensions ...string) ([]io.ReadCloser, error) { 124 | pathUTF16 := make([]uint16, _FilePathLength) 125 | 126 | open := _OpenFileName{ 127 | File: &pathUTF16[0], 128 | MaxFile: _FilePathLength, 129 | Filter: buildFilter(extensions), 130 | Flags: _FlagFileMustExist | _FlagForceShowHidden | _FlagDisableLinks | _FlagAllowMultiSelect | _FlagExplorer, 131 | StructSize: _OpenFileStructLength, 132 | } 133 | 134 | if r, _, _ := _GetOpenFileName.Call(uintptr(unsafe.Pointer(&open))); r == 0 { 135 | return nil, ErrUserDecline 136 | } 137 | 138 | // Split the pathUTF16 by null characters 139 | paths := make([]string, 0) 140 | currentPath := make([]uint16, 0) 141 | for _, char := range pathUTF16 { 142 | if char == 0 { 143 | if len(currentPath) > 0 { 144 | paths = append(paths, windows.UTF16ToString(currentPath)) 145 | currentPath = currentPath[:0] 146 | } 147 | } else { 148 | currentPath = append(currentPath, char) 149 | } 150 | } 151 | 152 | // The first element is the directory, append it to each filename 153 | dir := paths[0] 154 | filePaths := make([]string, len(paths)-1) 155 | for i, file := range paths[1:] { 156 | filePaths[i] = filepath.Join(dir, file) 157 | } 158 | 159 | if len(filePaths) == 0 { 160 | return nil, ErrUserDecline 161 | } 162 | 163 | files := make([]io.ReadCloser, len(filePaths)) 164 | for i, filePath := range filePaths { 165 | file, err := os.Open(filePath) 166 | if err != nil { 167 | for _, file := range files { 168 | if file != nil { 169 | file.Close() 170 | } 171 | } 172 | return nil, err 173 | } 174 | files[i] = file 175 | } 176 | 177 | return files, nil 178 | } 179 | 180 | func buildFilter(extensions []string) *uint16 { 181 | if len(extensions) <= 0 { 182 | return nil 183 | } 184 | 185 | for k, v := range extensions { 186 | // Extension must have `*` wildcard, so `.jpg` must be `*.jpg`. 187 | if !strings.HasPrefix(v, "*") { 188 | extensions[k] = "*" + v 189 | } 190 | } 191 | e := strings.ToUpper(strings.Join(extensions, ";")) 192 | 193 | // That is a "string-pair", Windows have a Title and the Filter, for instance it could be: 194 | // Images\0*.JPG;*.PNG\0\0 195 | // Where `\0` means NULL 196 | f := windows.StringToUTF16(e + " " + e) // Use the filter as title so it appear `*.JPG;*.PNG` for the user. 197 | f[len(e)] = 0 // Replace the " " (space) with NULL. 198 | f = append(f, uint16(0)) // Adding another NULL, because we need two. 199 | return &f[0] 200 | } 201 | -------------------------------------------------------------------------------- /explorer/file_android.go: -------------------------------------------------------------------------------- 1 | package explorer 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | 7 | "gioui.org/app" 8 | "git.wow.st/gmp/jni" 9 | ) 10 | 11 | //go:generate javac -source 8 -target 8 -bootclasspath $ANDROID_HOME/platforms/android-30/android.jar -d $TEMP/explorer_file_android/classes file_android.java 12 | //go:generate jar cf file_android.jar -C $TEMP/explorer_file_android/classes . 13 | 14 | type File struct { 15 | stream jni.Object 16 | libObject jni.Object 17 | libClass jni.Class 18 | 19 | fileRead jni.MethodID 20 | fileWrite jni.MethodID 21 | fileClose jni.MethodID 22 | getError jni.MethodID 23 | 24 | sharedBuffer jni.Object 25 | sharedBufferLen int 26 | isClosed bool 27 | } 28 | 29 | func newFile(env jni.Env, stream jni.Object) (*File, error) { 30 | f := &File{stream: stream} 31 | 32 | class, err := jni.LoadClass(env, jni.ClassLoaderFor(env, jni.Object(app.AppContext())), "org/gioui/x/explorer/file_android") 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | obj, err := jni.NewObject(env, class, jni.GetMethodID(env, class, "", `()V`)) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | // For some reason, using `f.stream` as argument for a constructor (`public file_android(Object j) {}`) doesn't work. 43 | if err := jni.CallVoidMethod(env, obj, jni.GetMethodID(env, class, "setHandle", `(Ljava/lang/Object;)V`), jni.Value(f.stream)); err != nil { 44 | return nil, err 45 | } 46 | 47 | f.libObject = jni.NewGlobalRef(env, obj) 48 | f.libClass = jni.Class(jni.NewGlobalRef(env, jni.Object(class))) 49 | f.fileRead = jni.GetMethodID(env, f.libClass, "fileRead", "([B)I") 50 | f.fileWrite = jni.GetMethodID(env, f.libClass, "fileWrite", "([B)Z") 51 | f.fileClose = jni.GetMethodID(env, f.libClass, "fileClose", "()Z") 52 | f.getError = jni.GetMethodID(env, f.libClass, "getError", "()Ljava/lang/String;") 53 | 54 | return f, nil 55 | 56 | } 57 | 58 | func (f *File) Read(b []byte) (n int, err error) { 59 | if f == nil || f.isClosed { 60 | return 0, io.ErrClosedPipe 61 | } 62 | if len(b) == 0 { 63 | return 0, nil // Avoid unnecessary call to JNI. 64 | } 65 | 66 | err = jni.Do(jni.JVMFor(app.JavaVM()), func(env jni.Env) error { 67 | if len(b) != f.sharedBufferLen { 68 | f.sharedBuffer = jni.Object(jni.NewGlobalRef(env, jni.Object(jni.NewByteArray(env, b)))) 69 | f.sharedBufferLen = len(b) 70 | } 71 | 72 | size, err := jni.CallIntMethod(env, f.libObject, f.fileRead, jni.Value(f.sharedBuffer)) 73 | if err != nil { 74 | return err 75 | } 76 | if size <= 0 { 77 | return f.lastError(env) 78 | } 79 | 80 | n = copy(b, jni.GetByteArrayElements(env, jni.ByteArray(f.sharedBuffer))[:int(size)]) 81 | return nil 82 | }) 83 | if err != nil { 84 | return 0, err 85 | } 86 | if n == 0 { 87 | return 0, io.EOF 88 | } 89 | return n, err 90 | } 91 | 92 | func (f *File) Write(b []byte) (n int, err error) { 93 | if f == nil || f.isClosed { 94 | return 0, io.ErrClosedPipe 95 | } 96 | if len(b) == 0 { 97 | return 0, nil // Avoid unnecessary call to JNI. 98 | } 99 | 100 | err = jni.Do(jni.JVMFor(app.JavaVM()), func(env jni.Env) error { 101 | ok, err := jni.CallBooleanMethod(env, f.libObject, f.fileWrite, jni.Value(jni.NewByteArray(env, b))) 102 | if err != nil { 103 | return err 104 | } 105 | if !ok { 106 | return f.lastError(env) 107 | } 108 | return nil 109 | }) 110 | if err != nil { 111 | return 0, err 112 | } 113 | return len(b), err 114 | } 115 | 116 | func (f *File) Close() error { 117 | if f == nil || f.isClosed { 118 | return io.ErrClosedPipe 119 | } 120 | 121 | return jni.Do(jni.JVMFor(app.JavaVM()), func(env jni.Env) error { 122 | ok, err := jni.CallBooleanMethod(env, f.libObject, f.fileClose) 123 | if err != nil { 124 | return err 125 | } 126 | if !ok { 127 | return f.lastError(env) 128 | } 129 | 130 | f.isClosed = true 131 | jni.DeleteGlobalRef(env, f.stream) 132 | jni.DeleteGlobalRef(env, f.libObject) 133 | jni.DeleteGlobalRef(env, jni.Object(f.libClass)) 134 | if f.sharedBuffer != 0 { 135 | jni.DeleteGlobalRef(env, f.sharedBuffer) 136 | } 137 | 138 | return nil 139 | }) 140 | } 141 | 142 | func (f *File) lastError(env jni.Env) error { 143 | message, err := jni.CallObjectMethod(env, f.libObject, f.getError) 144 | if err != nil { 145 | return err 146 | } 147 | if err := jni.GoString(env, jni.String(message)); len(err) > 0 { 148 | return errors.New(err) 149 | } 150 | return err 151 | } 152 | -------------------------------------------------------------------------------- /explorer/file_android.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/explorer/file_android.jar -------------------------------------------------------------------------------- /explorer/file_android.java: -------------------------------------------------------------------------------- 1 | package org.gioui.x.explorer; 2 | 3 | import java.io.InputStream; 4 | import java.io.OutputStream; 5 | import java.io.Closeable; 6 | import java.io.Flushable; 7 | 8 | public class file_android { 9 | public String err; 10 | public Object handler; 11 | 12 | public void setHandle(Object f) { 13 | this.handler = f; 14 | } 15 | 16 | public int fileRead(byte[] b) { 17 | try { 18 | return ((InputStream) this.handler).read(b, 0, b.length); 19 | } catch (Exception e) { 20 | this.err = e.toString(); 21 | return 0; 22 | } 23 | } 24 | 25 | public boolean fileWrite(byte[] b) { 26 | try { 27 | ((OutputStream) this.handler).write(b); 28 | return true; 29 | } catch (Exception e) { 30 | this.err = e.toString(); 31 | return false; 32 | } 33 | } 34 | 35 | public boolean fileClose() { 36 | try { 37 | if (this.handler instanceof Flushable) { 38 | ((Flushable) this.handler).flush(); 39 | } 40 | if (this.handler instanceof Closeable) { 41 | ((Closeable) this.handler).close(); 42 | } 43 | return true; 44 | } catch (Exception e) { 45 | this.err = e.toString(); 46 | return false; 47 | } 48 | } 49 | 50 | public String getError() { 51 | return this.err; 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /explorer/file_darwin.go: -------------------------------------------------------------------------------- 1 | package explorer 2 | 3 | /* 4 | #cgo CFLAGS: -Werror -xobjective-c -fmodules -fobjc-arc 5 | 6 | #import 7 | 8 | @interface explorer_file:NSObject 9 | @property NSFileHandle* handler; 10 | @property NSError* err; 11 | @property NSURL* url; 12 | @end 13 | 14 | extern CFTypeRef newFile(CFTypeRef url); 15 | extern uint64_t fileRead(CFTypeRef file, uint8_t *b, uint64_t len); 16 | extern bool fileWrite(CFTypeRef file, uint8_t *b, uint64_t len); 17 | extern bool fileClose(CFTypeRef file); 18 | extern char* getError(CFTypeRef file); 19 | extern const char* getURL(CFTypeRef url_ref); 20 | 21 | */ 22 | import "C" 23 | import ( 24 | "errors" 25 | "io" 26 | "net/url" 27 | "unsafe" 28 | ) 29 | 30 | type File struct { 31 | file C.CFTypeRef 32 | url string 33 | closed bool 34 | } 35 | 36 | func newFile(url C.CFTypeRef) (*File, error) { 37 | file := C.newFile(url) 38 | if err := getError(file); err != nil { 39 | return nil, err 40 | } 41 | 42 | cstr := C.getURL(url) 43 | urlStr := C.GoString(cstr) 44 | C.free(unsafe.Pointer(cstr)) 45 | 46 | ret := &File{ 47 | file: file, 48 | url: urlStr, 49 | } 50 | return ret, nil 51 | } 52 | 53 | func (f *File) Read(b []byte) (n int, err error) { 54 | if f.file == 0 || f.closed { 55 | return 0, io.ErrClosedPipe 56 | } 57 | 58 | buf := (*C.uint8_t)(unsafe.Pointer(&b[0])) 59 | length := C.uint64_t(uint64(len(b))) 60 | 61 | if n = int(int64(C.fileRead(f.file, buf, length))); n == 0 { 62 | if err := getError(f.file); err != nil { 63 | return n, err 64 | } 65 | return n, io.EOF 66 | } 67 | return n, nil 68 | } 69 | 70 | func (f *File) Write(b []byte) (n int, err error) { 71 | if f.file == 0 || f.closed { 72 | return 0, io.ErrClosedPipe 73 | } 74 | 75 | buf := (*C.uint8_t)(unsafe.Pointer(&b[0])) 76 | length := C.uint64_t(int64(len(b))) 77 | 78 | if ok := bool(C.fileWrite(f.file, buf, length)); !ok { 79 | if err := getError(f.file); err != nil { 80 | return 0, err 81 | } 82 | return 0, errors.New("unknown error") 83 | } 84 | return len(b), nil 85 | } 86 | 87 | func (f *File) Name() string { 88 | parsed, err := url.Parse(f.url) 89 | if err != nil { 90 | return "" 91 | } 92 | 93 | return parsed.Path 94 | } 95 | 96 | func (f *File) Close() error { 97 | if ok := bool(C.fileClose(f.file)); !ok { 98 | return getError(f.file) 99 | } 100 | f.closed = true 101 | return nil 102 | } 103 | 104 | func getError(file C.CFTypeRef) error { 105 | // file will be 0 if the current device doesn't match with @available (i.e older than iOS 13). 106 | if file == 0 { 107 | return ErrNotAvailable 108 | } 109 | if err := C.GoString(C.getError(file)); len(err) > 0 { 110 | return errors.New(err) 111 | } 112 | return nil 113 | } 114 | 115 | // Exported function is required to create cgo header. 116 | // 117 | //export file_darwin 118 | func file_darwin() {} 119 | 120 | var ( 121 | _ io.ReadWriteCloser = (*File)(nil) 122 | _ io.ReadCloser = (*File)(nil) 123 | _ io.WriteCloser = (*File)(nil) 124 | ) 125 | -------------------------------------------------------------------------------- /explorer/file_darwin.m: -------------------------------------------------------------------------------- 1 | #include "_cgo_export.h" 2 | 3 | @implementation explorer_file 4 | @end 5 | 6 | CFTypeRef newFile(CFTypeRef url) { 7 | if (@available(iOS 13, macOS 10.15, *)) { 8 | explorer_file *f = [[explorer_file alloc] init]; 9 | f.url = (__bridge NSURL *)url; 10 | [f.url startAccessingSecurityScopedResource]; 11 | 12 | NSError *err = nil; 13 | f.handler = [NSFileHandle fileHandleForUpdatingURL:f.url error:&err]; 14 | f.err = err; 15 | return (__bridge_retained CFTypeRef)f; 16 | } 17 | return 0; 18 | } 19 | 20 | uint64_t fileRead(CFTypeRef file, uint8_t *b, uint64_t len) { 21 | explorer_file *f = (__bridge explorer_file *)file; 22 | if (@available(iOS 13, macOS 10.15, *)) { 23 | NSError *err = nil; 24 | NSData *data = [f.handler readDataUpToLength:len error:&err]; 25 | if (err != nil) { 26 | f.err = err; 27 | return 0; 28 | } 29 | 30 | [data getBytes:b length:data.length]; 31 | return data.length; 32 | } 33 | return 0; // Impossible condition since newFileReader will return 0. 34 | } 35 | 36 | bool fileWrite(CFTypeRef file, uint8_t *b, uint64_t len) { 37 | explorer_file *f = (__bridge explorer_file *)file; 38 | if (@available(iOS 13, macOS 10.15, *)) { 39 | NSError *err = nil; 40 | [f.handler writeData:[NSData dataWithBytes:b length:len] error:&err]; 41 | if (err != nil) { 42 | f.err = err; 43 | return NO; 44 | } 45 | 46 | return YES; 47 | } 48 | return NO; // Impossible condition since newFileWriter will return 0. 49 | } 50 | 51 | bool fileClose(CFTypeRef file) { 52 | explorer_file *f = (__bridge explorer_file *)file; 53 | if (@available(iOS 13, macOS 10.15, *)) { 54 | [f.url stopAccessingSecurityScopedResource]; 55 | 56 | NSError *err = nil; 57 | [f.handler closeAndReturnError:&err]; 58 | if (err != nil) { 59 | f.err = err; 60 | return NO; 61 | } 62 | return YES; 63 | } 64 | return NO; // Impossible condition since newFileWriter will return 0. 65 | } 66 | 67 | char* getError(CFTypeRef file) { 68 | explorer_file *f = (__bridge explorer_file *)file; 69 | if (f.err == nil) { 70 | return 0; 71 | } 72 | return (char*)([[f.err localizedDescription] UTF8String]); 73 | } 74 | 75 | const char* getURL(CFTypeRef url_ref) { 76 | NSURL *url = (__bridge NSURL *)url_ref; 77 | NSString *str = [url absoluteString]; 78 | 79 | const char *unsafe_cstr = [str UTF8String]; 80 | char *safe_cstr = strdup(unsafe_cstr); 81 | return safe_cstr; 82 | } 83 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module gioui.org/x 2 | 3 | go 1.21 4 | 5 | require ( 6 | gioui.org v0.8.0 7 | git.sr.ht/~jackmordaunt/go-toast v1.0.0 8 | git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 9 | github.com/andybalholm/stroke v0.0.0-20221221101821-bd29b49d73f0 10 | github.com/esiqveland/notify v0.11.0 11 | github.com/godbus/dbus/v5 v5.0.6 12 | github.com/yuin/goldmark v1.4.13 13 | golang.org/x/exp v0.0.0-20240707233637-46b078467d37 14 | golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 15 | golang.org/x/image v0.18.0 16 | golang.org/x/sys v0.22.0 17 | golang.org/x/text v0.16.0 18 | ) 19 | 20 | require ( 21 | gioui.org/shader v1.0.8 // indirect 22 | github.com/go-ole/go-ole v1.2.6 // indirect 23 | github.com/go-text/typesetting v0.2.1 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= 2 | eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= 3 | gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg= 4 | gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc= 5 | gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= 6 | gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= 7 | gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= 8 | git.sr.ht/~jackmordaunt/go-toast v1.0.0 h1:bbRox6VkotdOj3QcWimZQ84APoszIsA/pSIj8ypDdV8= 9 | git.sr.ht/~jackmordaunt/go-toast v1.0.0/go.mod h1:aIuRX/HdBOz7yRS8rOVYQCwJQlFS7DbYBTpUV0SHeeg= 10 | git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI= 11 | git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo= 12 | github.com/andybalholm/stroke v0.0.0-20221221101821-bd29b49d73f0 h1:uF5Q/hWnDU1XZeT6CsrRSxHLroUSEYYO3kgES+yd+So= 13 | github.com/andybalholm/stroke v0.0.0-20221221101821-bd29b49d73f0/go.mod h1:ccdDYaY5+gO+cbnQdFxEXqfy0RkoV25H3jLXUDNM3wg= 14 | github.com/esiqveland/notify v0.11.0 h1:0WJ/xW+3Ln8uRBYntG7f0XihXxnlOaQTdha1yyzXz30= 15 | github.com/esiqveland/notify v0.11.0/go.mod h1:63UbVSaeJwF0LVJARHFuPgUAoM7o1BEvCZyknsuonBc= 16 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 17 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 18 | github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= 19 | github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= 20 | github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= 21 | github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= 22 | github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 23 | github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro= 24 | github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 25 | github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 26 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 27 | golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= 28 | golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 29 | golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU= 30 | golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= 31 | golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= 32 | golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= 33 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 34 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 35 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 36 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 37 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 38 | -------------------------------------------------------------------------------- /haptic/README.md: -------------------------------------------------------------------------------- 1 | # haptic 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/gioui.org/x/haptic.svg)](https://pkg.go.dev/gioui.org/x/haptic) 4 | 5 | Haptic feedback for Gio applications 6 | 7 | ## Status 8 | 9 | Experimental, but working. API is not stable, so use go modules to lock 10 | to a particular version. 11 | 12 | On non-supported OSes, the API is the same but does nothing. This makes 13 | it easier to write cross-platform code that depends on haptic. 14 | 15 | ## Why is the API weird? 16 | 17 | We can't interact with the JVM from the same OS thread that runs your Gio 18 | event processing. Rather than accidentally allow you to deadlock by calling 19 | these methods the wrong way, they're written to be safe to invoke from your 20 | normal Gio layout code without deadlock. This means that all of the work 21 | needs to occur on other goroutines. 22 | -------------------------------------------------------------------------------- /haptic/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package haptic implements haptic feedback support for gio. 3 | */ 4 | package haptic 5 | -------------------------------------------------------------------------------- /haptic/haptic_android.go: -------------------------------------------------------------------------------- 1 | //go:build android 2 | // +build android 3 | 4 | /* 5 | Package haptic provides access to haptic feedback methods on Android to gio 6 | applications. 7 | */ 8 | package haptic 9 | 10 | import ( 11 | "unsafe" 12 | 13 | "gioui.org/app" 14 | "git.wow.st/gmp/jni" 15 | ) 16 | 17 | // Buzzer provides methods to trigger haptic feedback 18 | type Buzzer struct { 19 | // the latest view reference from an app.ViewEvent 20 | view uintptr 21 | // updated signals changes to the view field 22 | updated chan struct{} 23 | 24 | jvm jni.JVM 25 | performFeedbackID jni.MethodID 26 | jvmOperations chan func(env jni.Env, view jni.Object) error 27 | errors chan error 28 | } 29 | 30 | // inJVM runs the provided closure within the JVM context associated with this buzzer. 31 | // This method must not be invoked from the same goroutine/thread as the gio event 32 | // processing code, so it's best not to invoke it directly and instead to submit 33 | // closures to b.jvmOperations. 34 | func (b *Buzzer) inJVM(req func(env jni.Env, view jni.Object) error) { 35 | for b.view == 0 { 36 | <-b.updated 37 | } 38 | if err := jni.Do(b.jvm, func(env jni.Env) error { 39 | view := *(*jni.Object)(unsafe.Pointer(&b.view)) 40 | return req(env, view) 41 | }); err != nil { 42 | b.errors <- err 43 | } 44 | } 45 | 46 | // Buzz attempts to trigger a haptic vibration without blocking. It returns whether 47 | // or not it was successful. If it returns false, it is safe to retry. 48 | func (b *Buzzer) Buzz() bool { 49 | select { 50 | case b.jvmOperations <- func(env jni.Env, view jni.Object) error { 51 | _, err := jni.CallBooleanMethod(env, view, b.performFeedbackID, 0) 52 | return err 53 | }: 54 | return true 55 | default: 56 | return false 57 | } 58 | } 59 | 60 | // Shutdown stops the background event loop that interfaces with the JVM. 61 | // Call this when you are done with a Buzzer to allow it to be garbage 62 | // collected. Do not call this method more than once per Buzzer. 63 | func (b *Buzzer) Shutdown() { 64 | close(b.jvmOperations) 65 | close(b.errors) 66 | } 67 | 68 | // Errors returns a channel of errors from trying to interface with the JVM. This 69 | // channel will close when Shutdown() is invoked. 70 | func (b *Buzzer) Errors() <-chan error { 71 | if b == nil { 72 | return nil 73 | } 74 | return b.errors 75 | } 76 | 77 | // SetView updates the buzzer's internal reference to the android view. This value 78 | // should come from the View field of an app.ViewEvent, and this method should be 79 | // invoked each time an app.ViewEvent is emitted. 80 | func (b *Buzzer) SetView(view uintptr) { 81 | b.view = view 82 | // signal the state change if it isn't already being signaled. 83 | select { 84 | case b.updated <- struct{}{}: 85 | default: 86 | } 87 | } 88 | 89 | // NewBuzzer constructs a buzzer. 90 | func NewBuzzer(_ *app.Window) *Buzzer { 91 | b := &Buzzer{ 92 | updated: make(chan struct{}, 1), 93 | jvm: jni.JVMFor(app.JavaVM()), 94 | jvmOperations: make(chan func(env jni.Env, view jni.Object) error), 95 | errors: make(chan error), 96 | } 97 | go func() { 98 | for op := range b.jvmOperations { 99 | b.inJVM(op) 100 | } 101 | }() 102 | b.jvmOperations <- func(env jni.Env, view jni.Object) error { 103 | viewClass := jni.GetObjectClass(env, view) 104 | b.performFeedbackID = jni.GetMethodID(env, viewClass, "performHapticFeedback", "(I)Z") 105 | methodID := jni.GetMethodID(env, viewClass, "setHapticFeedbackEnabled", "(Z)V") 106 | return jni.CallVoidMethod(env, view, methodID, jni.TRUE) 107 | } 108 | return b 109 | } 110 | -------------------------------------------------------------------------------- /haptic/haptic_default.go: -------------------------------------------------------------------------------- 1 | //go:build !android && !ios 2 | // +build !android,!ios 3 | 4 | package haptic 5 | 6 | import "gioui.org/app" 7 | 8 | // Buzzer provides methods to trigger haptic feedback. On OSes other than android, 9 | // all methods are no-ops. 10 | type Buzzer struct { 11 | } 12 | 13 | // Buzz attempts to trigger a haptic vibration without blocking. It returns whether 14 | // or not it was successful. If it returns false, it is safe to retry. On unsupported 15 | // platforms, it always returns true. 16 | func (b *Buzzer) Buzz() bool { 17 | return true 18 | } 19 | 20 | // Update does nothing on platforms other than Android. See the documentation with 21 | // GOOS=android for information on using this method correctly on that platform. 22 | func (b *Buzzer) SetView(_ uintptr) { 23 | } 24 | 25 | // Shutdown stops the background event loop that interfaces with the JVM. 26 | // Call this when you are done with a Buzzer to allow it to be garbage 27 | // collected. Do not call this method more than per Buzzer. 28 | func (b *Buzzer) Shutdown() { 29 | } 30 | 31 | // Errors returns a channel of errors from trying to interface with the JVM. This 32 | // channel will close when Shutdown() is invoked. 33 | func (b *Buzzer) Errors() <-chan error { 34 | return nil 35 | } 36 | 37 | // NewBuzzer constructs a buzzer. 38 | func NewBuzzer(_ *app.Window) *Buzzer { 39 | return &Buzzer{} 40 | } 41 | -------------------------------------------------------------------------------- /haptic/haptic_ios.go: -------------------------------------------------------------------------------- 1 | //go:build ios && cgo 2 | // +build ios,cgo 3 | 4 | package haptic 5 | 6 | //#cgo LDFLAGS: -framework AudioToolbox 7 | 8 | /* 9 | #cgo CFLAGS: -x objective-c -fno-objc-arc -fmodules 10 | #pragma clang diagnostic ignored "-Wformat-security" 11 | @import AudioToolbox; 12 | 13 | SystemSoundID buzzID = 1520; 14 | 15 | void buzz() { 16 | AudioServicesPlaySystemSound(buzzID); 17 | } 18 | */ 19 | import "C" 20 | 21 | import ( 22 | "gioui.org/app" 23 | ) 24 | 25 | // Buzzer provides methods to trigger haptic feedback. On OSes other than android, 26 | // all methods are no-ops. 27 | type Buzzer struct { 28 | } 29 | 30 | // Buzz attempts to trigger a haptic vibration without blocking. It returns whether 31 | // or not it was successful. If it returns false, it is safe to retry. On unsupported 32 | // platforms, it always returns true. 33 | func (b *Buzzer) Buzz() bool { 34 | C.buzz() 35 | return true 36 | } 37 | 38 | // Update does nothing on platforms other than Android. See the documentation with 39 | // GOOS=android for information on using this method correctly on that platform. 40 | func (b *Buzzer) SetView(_ uintptr) { 41 | } 42 | 43 | // Shutdown stops the background event loop that interfaces with the JVM. 44 | // Call this when you are done with a Buzzer to allow it to be garbage 45 | // collected. Do not call this method more than per Buzzer. 46 | func (b *Buzzer) Shutdown() { 47 | } 48 | 49 | // Errors returns a channel of errors from trying to interface with the JVM. This 50 | // channel will close when Shutdown() is invoked. 51 | func (b *Buzzer) Errors() <-chan error { 52 | return nil 53 | } 54 | 55 | // NewBuzzer constructs a buzzer. 56 | func NewBuzzer(_ *app.Window) *Buzzer { 57 | return &Buzzer{} 58 | } 59 | -------------------------------------------------------------------------------- /notify/README.md: -------------------------------------------------------------------------------- 1 | ## notify 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/gioui.org/x/notify.svg)](https://pkg.go.dev/gioui.org/x/notify) 4 | 5 | Cross platform notifications for [Gio](https://gioui.org) applications. 6 | 7 | ## Status 8 | 9 | This repo is experimental, and does not have a stable interface. Currently niotify 10 | only supports the following OSes: 11 | 12 | - linux (x11/wayland doesn't matter so long as dbus is used for notifications) 13 | - android 14 | - macOS (support is preliminary; some code-signing-related issues) 15 | - iOS (support is preliminary; some code-signing-related issues) 16 | 17 | ## Use 18 | 19 | See the package documentation of `./notification_manager.go` for usage information. 20 | -------------------------------------------------------------------------------- /notify/android/NotificationHelper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/notify/android/NotificationHelper.jar -------------------------------------------------------------------------------- /notify/cmd/png2bmp/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "image" 7 | "image/color" 8 | "image/png" 9 | "log" 10 | "os" 11 | ) 12 | 13 | func max(ints ...uint32) uint32 { 14 | max := uint32(0) 15 | for _, i := range ints { 16 | if i > max { 17 | max = i 18 | } 19 | } 20 | return max 21 | } 22 | 23 | func main() { 24 | flag.Usage = func() { 25 | fmt.Fprintf(flag.CommandLine.Output(), `%s converts a black and white png image into a java int array. 26 | It is suitable for embedding simple bitmap images, but hits limits 27 | on the length of java source code when used with larger images. 28 | 29 | NOTE: this program only examines the alpha of each pixel in the image. 30 | 31 | Use: %s [file.png] >> SomeClass.java 32 | 33 | You'll then need to tweak the java file so that these fields are 34 | declared inside of a class definition. 35 | 36 | Additionally, this program will create a preview of the embedded image 37 | in the file out.png in the current working directory. 38 | `, os.Args[0], os.Args[0]) 39 | flag.PrintDefaults() 40 | } 41 | flag.Parse() 42 | imgFile, err := os.Open(os.Args[1]) 43 | if err != nil { 44 | log.Fatalf("Failed reading file: %v", err) 45 | } 46 | defer imgFile.Close() 47 | pngData, err := png.Decode(imgFile) 48 | if err != nil { 49 | log.Fatalf("Failed decoding image: %v", err) 50 | } 51 | bounds := pngData.Bounds() 52 | fmt.Printf("\tprivate static int height = %d;\n", bounds.Dy()) 53 | fmt.Printf("\tprivate static int width = %d;\n", bounds.Dx()) 54 | fmt.Printf("\tprivate static int[] data = {") 55 | out := image.NewRGBA(bounds) 56 | for y := bounds.Min.Y; y < bounds.Max.Y; y++ { 57 | for x := bounds.Min.X; x < bounds.Max.X; x++ { 58 | c := pngData.At(x, y) 59 | _, _, _, a := c.RGBA() 60 | ratio := (float64(a) / 0xffff) 61 | asByte := uint8(256 * ratio) 62 | scaled := int32(asByte) << 24 // ARGB, so alpha needs to be first 63 | fmt.Printf("%d", scaled) 64 | if x != bounds.Max.X-1 || y != bounds.Max.Y-1 { 65 | fmt.Printf(",") 66 | } 67 | out.SetRGBA(x, y, color.RGBA{A: asByte}) 68 | } 69 | } 70 | fmt.Printf("};") 71 | outFile, err := os.Create("out.png") 72 | if err != nil { 73 | log.Fatalf("failed opening output file: %v", err) 74 | } 75 | defer outFile.Close() 76 | if err := png.Encode(outFile, out); err != nil { 77 | log.Fatalf("failed encoding output image: %v", err) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /notify/macos/niotify_test.go: -------------------------------------------------------------------------------- 1 | //go:build darwin && cgo 2 | // +build darwin,cgo 3 | 4 | package macos 5 | 6 | import ( 7 | "testing" 8 | ) 9 | 10 | func TestCancelNonexistent(t *testing.T) { 11 | var notif *Notification 12 | if err := notif.Cancel(); err == nil { 13 | t.Fatalf("should fail to cancel nonexistent notification id") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /notify/macos/notify_macos.go: -------------------------------------------------------------------------------- 1 | //go:build darwin && cgo 2 | // +build darwin,cgo 3 | 4 | package macos 5 | 6 | //#cgo LDFLAGS: -framework Foundation -framework UserNotifications 7 | 8 | /* 9 | #cgo CFLAGS: -x objective-c -fno-objc-arc -fmodules 10 | #pragma clang diagnostic ignored "-Wformat-security" 11 | 12 | #import 13 | 14 | @import Foundation; 15 | @import UserNotifications; 16 | 17 | const unsigned char NIOTIFY_SUCCESS = 0; 18 | const unsigned char NIOTIFY_NO_PERMISSION = 1; 19 | const unsigned char NIOTIFY_NO_BUNDLE = 2; 20 | const unsigned char NIOTIFY_UKNOWN_ERR = 3; 21 | 22 | @interface UNDelegate : NSObject 23 | { } 24 | - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler; 25 | - (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler; 26 | - (void)userNotificationCenter:(UNUserNotificationCenter *)center openSettingsForNotification:(UNNotification *)notification; 27 | @end 28 | 29 | @implementation UNDelegate 30 | - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler { 31 | NSLog(@"didReceiveNotificationResponse"); 32 | } 33 | 34 | - (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler { 35 | NSLog(@"willPresentNotification"); 36 | } 37 | 38 | - (void)userNotificationCenter:(UNUserNotificationCenter *)center openSettingsForNotification:(UNNotification *)notification { 39 | NSLog(@"openSettingsForNotification"); 40 | } 41 | @end 42 | 43 | UNUserNotificationCenter *nc; 44 | BOOL enabled; 45 | BOOL hasBundle; 46 | UNDelegate *del; 47 | 48 | void setup() { 49 | @autoreleasepool { 50 | NSLog(@"Getting application bundle"); 51 | enabled = NO; 52 | hasBundle = NO; 53 | NSBundle *main = [NSBundle mainBundle]; 54 | if (main.bundleIdentifier == nil) { 55 | NSLog(@"No app bundle."); 56 | return; 57 | } 58 | hasBundle = YES; 59 | NSLog(@"Bundle ID: %@", main.bundleIdentifier); 60 | NSLog(@"Getting notification center"); 61 | nc = [UNUserNotificationCenter currentNotificationCenter]; 62 | del = [[UNDelegate alloc] init]; 63 | nc.delegate = del; 64 | } 65 | } 66 | 67 | NSString* 68 | notify(char *id, char *title, char *content, unsigned char *errorCode) { 69 | NSString *ret; 70 | 71 | if (!hasBundle) { 72 | *errorCode = NIOTIFY_NO_BUNDLE; 73 | return ret; 74 | } 75 | 76 | @autoreleasepool { 77 | dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); 78 | NSLog(@"Creating notification"); 79 | UNMutableNotificationContent *note = [[UNMutableNotificationContent alloc] init]; 80 | note.title = [[NSString alloc] initWithUTF8String: title]; 81 | note.body = [[NSString alloc] initWithUTF8String: content]; 82 | NSString *identifier = [[NSUUID UUID] UUIDString]; 83 | 84 | NSLog(@"Creating request"); 85 | UNNotificationRequest *req = [UNNotificationRequest requestWithIdentifier:identifier content: note trigger:nil]; 86 | ret = req.identifier; 87 | [ret retain]; 88 | NSLog(@"Requesting authorization"); 89 | [nc requestAuthorizationWithOptions: UNAuthorizationOptionBadge | UNAuthorizationOptionSound | UNAuthorizationOptionAlert completionHandler: ^(BOOL granted, NSError *error){ 90 | NSLog(@"Granted = %s", granted?"true":"false"); 91 | NSLog(@"Error = %@", error); 92 | enabled = granted; 93 | if (enabled == YES) { 94 | NSLog(@"Adding notification request"); 95 | [nc addNotificationRequest:req withCompletionHandler: ^(NSError *error) { 96 | NSLog(@"added notification. Error: %@", error); 97 | *errorCode=NIOTIFY_SUCCESS; 98 | dispatch_semaphore_signal(semaphore); 99 | }]; 100 | } else { 101 | *errorCode=NIOTIFY_NO_PERMISSION; 102 | dispatch_semaphore_signal(semaphore); 103 | } 104 | }]; 105 | dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); 106 | } 107 | return ret; 108 | } 109 | 110 | void 111 | cancel(void *nid, unsigned char *errorCode) { 112 | if (!hasBundle) { 113 | *errorCode = NIOTIFY_NO_BUNDLE; 114 | return; 115 | } 116 | 117 | @try { 118 | [nc removePendingNotificationRequestsWithIdentifiers: @[(NSString*)nid]]; 119 | [nc removeDeliveredNotificationsWithIdentifiers: @[(NSString*)nid]]; 120 | } 121 | @catch(NSException *ne) { 122 | NSLog(@"caught exception when cancelling notification %@: %@", nid, ne); 123 | *errorCode=NIOTIFY_UKNOWN_ERR; 124 | } 125 | } 126 | 127 | */ 128 | import "C" 129 | 130 | import ( 131 | "fmt" 132 | "runtime" 133 | "unsafe" 134 | ) 135 | 136 | func init() { 137 | runtime.LockOSThread() 138 | C.setup() 139 | } 140 | 141 | type NotificationChannel struct { 142 | id *C.char 143 | } 144 | 145 | func NewNotificationChannel(id string) NotificationChannel { 146 | return NotificationChannel{id: C.CString(id)} 147 | } 148 | 149 | func (c NotificationChannel) Send(title, text string) (*Notification, error) { 150 | return notify(c.id, title, text) 151 | } 152 | 153 | type Notification C.NSString 154 | 155 | // toErr converts C integer error codes into Go errors with semi-useful string 156 | // messages. 157 | func toErr(errCode C.uchar) error { 158 | switch errCode { 159 | case C.NIOTIFY_SUCCESS: 160 | return nil 161 | case C.NIOTIFY_NO_PERMISSION: 162 | return fmt.Errorf("permission denied") 163 | case C.NIOTIFY_NO_BUNDLE: 164 | return fmt.Errorf("no app bundle detected") 165 | default: 166 | return fmt.Errorf("unknown error: %v", errCode) 167 | } 168 | } 169 | 170 | func notify(cid *C.char, title, content string) (*Notification, error) { 171 | ct := C.CString(title) 172 | defer C.free(unsafe.Pointer(ct)) 173 | cc := C.CString(content) 174 | defer C.free(unsafe.Pointer(cc)) 175 | var errCode C.uchar 176 | 177 | id := C.notify(cid, ct, cc, &errCode) 178 | err := toErr(errCode) 179 | if err != nil { 180 | return nil, err 181 | } 182 | 183 | return (*Notification)(id), nil 184 | } 185 | 186 | func (n *Notification) Cancel() error { 187 | if n == nil { 188 | return fmt.Errorf("attempted to cancel nil notification") 189 | } 190 | var errCode C.uchar 191 | C.cancel(unsafe.Pointer(n), &errCode) 192 | return toErr(errCode) 193 | } 194 | -------------------------------------------------------------------------------- /notify/notification_manager.go: -------------------------------------------------------------------------------- 1 | // Package notify provides cross-platform notifications for Gio applications. 2 | // 3 | // https://gioui.org 4 | // 5 | // Sending a notification is easy: 6 | // 7 | // notifier, _ := NewNotifier() 8 | // notification, _ := notifier.CreateNotification("hello!", "I was sent from Gio!") 9 | // notification.Cancel() 10 | package notify 11 | 12 | import "sync" 13 | 14 | // impl is a package global notifier initialized to the current platform 15 | // implementation. 16 | var impl Notifier 17 | var implErr error 18 | var implLock sync.Mutex 19 | 20 | // Notifier provides methods for creating and managing notifications. 21 | type Notifier interface { 22 | CreateNotification(title, text string) (Notification, error) 23 | } 24 | 25 | // IconNotifier is a notifier that can display an icon notification. 26 | type IconNotifier interface { 27 | Notifier 28 | UseIcon(path string) 29 | } 30 | 31 | // OngoingNotifier is a notifier that can display an ongoing notification. 32 | // Some platforms (currently Android) support persistent notifications and 33 | // will implement this optional interface. 34 | type OngoingNotifier interface { 35 | Notifier 36 | // CreateOngoingNotification creates a notification that cannot be dismissed 37 | // by the user. Callers must be careful to cancel this notification when it 38 | // is no longer needed. 39 | CreateOngoingNotification(title, text string) (Notification, error) 40 | } 41 | 42 | // NewNotifier creates a new Manager tailored to the current operating system. 43 | func NewNotifier() (Notifier, error) { 44 | return newNotifier() 45 | } 46 | 47 | // Notification handle that can used to manipulate a platform notification, 48 | // such as by cancelling it. 49 | type Notification interface { 50 | // Cancel a notification. 51 | Cancel() error 52 | } 53 | 54 | // noop notification for convenience. 55 | type noop struct{} 56 | 57 | func (noop) Cancel() error { 58 | return nil 59 | } 60 | 61 | // Push a notification to the OS. 62 | func Push(title, text string) (Notification, error) { 63 | implLock.Lock() 64 | defer implLock.Unlock() 65 | if impl == nil && implErr == nil { 66 | impl, implErr = newNotifier() 67 | } 68 | if implErr != nil { 69 | return nil, implErr 70 | } 71 | return impl.CreateNotification(title, text) 72 | } 73 | -------------------------------------------------------------------------------- /notify/notify_android.go: -------------------------------------------------------------------------------- 1 | //go:build android 2 | // +build android 3 | 4 | package notify 5 | 6 | import ( 7 | "gioui.org/x/notify/android" 8 | ) 9 | 10 | type androidNotifier struct { 11 | channel *android.NotificationChannel 12 | } 13 | 14 | var _ Notifier = (*androidNotifier)(nil) 15 | 16 | func newNotifier() (Notifier, error) { 17 | channel, err := android.NewChannel(android.ImportanceDefault, "DEFAULT", "niotify", "background notifications") 18 | if err != nil { 19 | return nil, err 20 | } 21 | return &androidNotifier{ 22 | channel: channel, 23 | }, nil 24 | } 25 | 26 | func (a *androidNotifier) CreateNotification(title, text string) (Notification, error) { 27 | return a.createNotification(title, text, false) 28 | } 29 | 30 | func (a *androidNotifier) createNotification(title, text string, ongoing bool) (Notification, error) { 31 | notification, err := a.channel.Send(title, text, ongoing) 32 | if err != nil { 33 | return nil, err 34 | } 35 | return notification, nil 36 | } 37 | 38 | func (a *androidNotifier) CreateOngoingNotification(title, text string) (Notification, error) { 39 | return a.createNotification(title, text, true) 40 | } 41 | -------------------------------------------------------------------------------- /notify/notify_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | // +build darwin 3 | 4 | package notify 5 | 6 | import ( 7 | "gioui.org/x/notify/macos" 8 | ) 9 | 10 | type darwinNotifier struct { 11 | channel macos.NotificationChannel 12 | } 13 | 14 | var _ Notifier = (*darwinNotifier)(nil) 15 | 16 | func newNotifier() (Notifier, error) { 17 | c := macos.NewNotificationChannel("Gio App") 18 | 19 | return &darwinNotifier{channel: c}, nil 20 | } 21 | 22 | func (a *darwinNotifier) CreateNotification(title, text string) (Notification, error) { 23 | notification, err := a.channel.Send(title, text) 24 | if err != nil { 25 | return nil, err 26 | } 27 | return notification, nil 28 | } 29 | -------------------------------------------------------------------------------- /notify/notify_dbus.go: -------------------------------------------------------------------------------- 1 | //go:build (linux && !android) || openbsd || freebsd || netbsd 2 | // +build linux,!android openbsd freebsd netbsd 3 | 4 | package notify 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/esiqveland/notify" 10 | dbus "github.com/godbus/dbus/v5" 11 | ) 12 | 13 | type dbusNotifier struct { 14 | notify.Notifier 15 | } 16 | 17 | var _ Notifier = (*dbusNotifier)(nil) 18 | 19 | func newNotifier() (Notifier, error) { 20 | conn, err := dbus.SessionBus() 21 | if err != nil { 22 | return nil, fmt.Errorf("failed connecting to dbus: %w", err) 23 | } 24 | notifier, err := notify.New(conn) 25 | if err != nil { 26 | return nil, fmt.Errorf("failed creating notifier: %w", err) 27 | } 28 | return &dbusNotifier{ 29 | Notifier: notifier, 30 | }, nil 31 | } 32 | 33 | type dbusNotification struct { 34 | id uint32 35 | *dbusNotifier 36 | } 37 | 38 | var _ Notification = &dbusNotification{} 39 | 40 | func (l *dbusNotifier) CreateNotification(title, text string) (Notification, error) { 41 | id, err := l.Notifier.SendNotification(notify.Notification{ 42 | Summary: title, 43 | Body: text, 44 | }) 45 | if err != nil { 46 | return nil, err 47 | } 48 | return &dbusNotification{ 49 | id: id, 50 | dbusNotifier: l, 51 | }, nil 52 | } 53 | 54 | func (l dbusNotification) Cancel() error { 55 | _, err := l.dbusNotifier.CloseNotification(l.id) 56 | return err 57 | } 58 | -------------------------------------------------------------------------------- /notify/notify_unsupported.go: -------------------------------------------------------------------------------- 1 | //go:build !linux && !android && !openbsd && !freebsd && !dragonfly && !netbsd && !darwin && !windows 2 | // +build !linux,!android,!openbsd,!freebsd,!dragonfly,!netbsd,!darwin,!windows 3 | 4 | package notify 5 | 6 | type unsupported struct{} 7 | 8 | func newNotifier() (Notifier, error) { 9 | return unsupported{}, nil 10 | } 11 | 12 | func (unsupported) CreateNotification(title, text string) (Notification, error) { 13 | return &noop{}, nil 14 | } 15 | -------------------------------------------------------------------------------- /notify/notify_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package notify 5 | 6 | import ( 7 | "git.sr.ht/~jackmordaunt/go-toast" 8 | ) 9 | 10 | type windowsNotifier struct { 11 | // icon contains the path to an icon to use. 12 | // Ignored if empty. 13 | icon string 14 | } 15 | 16 | var _ Notifier = (*windowsNotifier)(nil) 17 | 18 | func newNotifier() (Notifier, error) { 19 | return &windowsNotifier{}, nil 20 | } 21 | 22 | // CreateNotification pushes a notification to windows. 23 | // Note; cancellation is not implemented. 24 | func (m *windowsNotifier) CreateNotification(title, text string) (Notification, error) { 25 | return noop{}, (&toast.Notification{ 26 | AppID: title, 27 | Title: title, 28 | Body: text, 29 | Icon: m.icon, 30 | }).Push() 31 | } 32 | 33 | // UseIcon configures an icon to use for notifications, specified as a filepath. 34 | func (m *windowsNotifier) UseIcon(path string) { 35 | m.icon = path 36 | } 37 | -------------------------------------------------------------------------------- /outlay/README.md: -------------------------------------------------------------------------------- 1 | # outlay 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/gioui.org/x/outlay.svg)](https://pkg.go.dev/gioui.org/x/outlay) 4 | 5 | This package provides extra layouts for [gio](https://gioui.org). 6 | 7 | ## State 8 | 9 | This package has no stable API, and should always be locked to a particular commit with 10 | go modules. 11 | 12 | ## Layouts 13 | 14 | ### Grid 15 | 16 | This layout allows placement of many items in a grid with to several different strategies for wrapping across lines. For examples, run: 17 | 18 | ### Radial 19 | 20 | The radial layout allows you to lay out a set of widgets along an arc. The width and oritentation of the arc are configurable to allow for everything from a hand of cards to a full circle of widgets. 21 | 22 | Known issues: 23 | - The radial layout does not currently return correct dimensions for itself, which breaks most attempts to use it as part of a larger layout. 24 | 25 | -------------------------------------------------------------------------------- /outlay/anim.go: -------------------------------------------------------------------------------- 1 | package outlay 2 | 3 | import ( 4 | "time" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/op" 8 | ) 9 | 10 | // Animation holds state for an Animation between two states that 11 | // is not invertible. 12 | type Animation struct { 13 | time.Duration 14 | StartTime time.Time 15 | } 16 | 17 | // Progress returns the current progress through the animation 18 | // as a value in the range [0,1] 19 | func (n *Animation) Progress(gtx layout.Context) float32 { 20 | if n.Duration == time.Duration(0) { 21 | return 0 22 | } 23 | progressDur := gtx.Now.Sub(n.StartTime) 24 | if progressDur > n.Duration { 25 | return 1 26 | } 27 | gtx.Execute(op.InvalidateCmd{}) 28 | progress := float32(progressDur.Milliseconds()) / float32(n.Duration.Milliseconds()) 29 | return progress 30 | } 31 | 32 | func (n *Animation) Start(now time.Time) { 33 | n.StartTime = now 34 | } 35 | 36 | func (n *Animation) SetDuration(d time.Duration) { 37 | n.Duration = d 38 | } 39 | 40 | func (n *Animation) Animating(gtx layout.Context) bool { 41 | if n.Duration == 0 { 42 | return false 43 | } 44 | if gtx.Now.After(n.StartTime.Add(n.Duration)) { 45 | return false 46 | } 47 | return true 48 | } 49 | -------------------------------------------------------------------------------- /outlay/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package outlay provides extra layouts for gio. 3 | */ 4 | package outlay 5 | -------------------------------------------------------------------------------- /outlay/fan.go: -------------------------------------------------------------------------------- 1 | package outlay 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "math" 7 | 8 | "gioui.org/f32" 9 | "gioui.org/layout" 10 | "gioui.org/op" 11 | "gioui.org/unit" 12 | ) 13 | 14 | type Fan struct { 15 | itemsCache []cacheItem 16 | last fanParams 17 | animatedLastFrame bool 18 | Animation 19 | 20 | // The width, in radians, of the full arc that items should occupy. 21 | // If zero, math.Pi/2 will be used (1/4 of a full circle). 22 | WidthRadians float32 23 | 24 | // The offset, in radians, above the X axis to apply before rendering the 25 | // arc. This can be used with a value of Pi/4 to center an arc of width 26 | // Pi/2. If zero, math.Pi/4 will be used (1/8 of a full circle). To get the 27 | // equivalent of specifying zero, specify a value of 2*math.Pi. 28 | OffsetRadians float32 29 | 30 | // The radius of the hollow circle at the center of the fan. Leave nil to 31 | // use the default heuristic of half the width of the widest item. 32 | HollowRadius *unit.Dp 33 | } 34 | 35 | type fanParams struct { 36 | arc float32 37 | radius float32 38 | len int 39 | } 40 | 41 | func (f fanParams) String() string { 42 | return fmt.Sprintf("arc: %v radus: %v len: %v", f.arc, f.radius, f.len) 43 | } 44 | 45 | type cacheItem struct { 46 | elevated bool 47 | op.CallOp 48 | layout.Dimensions 49 | } 50 | 51 | type FanItem struct { 52 | W layout.Widget 53 | Elevate bool 54 | } 55 | 56 | func Item(elevate bool, w layout.Widget) FanItem { 57 | return FanItem{ 58 | W: w, 59 | Elevate: elevate, 60 | } 61 | } 62 | 63 | func (f *Fan) fullWidthRadians() float32 { 64 | if f.WidthRadians == 0 { 65 | return math.Pi / 2 66 | } 67 | return f.WidthRadians 68 | } 69 | 70 | func (f *Fan) offsetRadians() float32 { 71 | if f.OffsetRadians == 0 { 72 | return math.Pi / 4 73 | } 74 | return f.OffsetRadians 75 | } 76 | 77 | func (f *Fan) Layout(gtx layout.Context, items ...FanItem) layout.Dimensions { 78 | defer op.Offset(image.Point{ 79 | X: gtx.Constraints.Max.X / 2, 80 | Y: gtx.Constraints.Max.Y / 2, 81 | }).Push(gtx.Ops).Pop() 82 | f.itemsCache = f.itemsCache[:0] 83 | maxWidth := 0 84 | for i := range items { 85 | item := items[i] 86 | macro := op.Record(gtx.Ops) 87 | dims := item.W(gtx) 88 | if dims.Size.X > maxWidth { 89 | maxWidth = dims.Size.X 90 | } 91 | f.itemsCache = append(f.itemsCache, cacheItem{ 92 | CallOp: macro.Stop(), 93 | Dimensions: dims, 94 | elevated: item.Elevate, 95 | }) 96 | } 97 | var current fanParams 98 | current.len = len(items) 99 | if f.HollowRadius == nil { 100 | current.radius = float32(maxWidth * 2.0) 101 | } else { 102 | current.radius = float32(gtx.Dp(*f.HollowRadius)) 103 | } 104 | var itemArcFraction float32 105 | if len(items) > 1 { 106 | itemArcFraction = float32(1) / float32(len(items)-1) 107 | } else { 108 | itemArcFraction = 1 109 | } 110 | current.arc = f.fullWidthRadians() * itemArcFraction 111 | 112 | var empty fanParams 113 | if f.last == empty { 114 | f.last = current 115 | } else if f.last != current { 116 | 117 | if !f.animatedLastFrame { 118 | f.Start(gtx.Now) 119 | } 120 | progress := f.Progress(gtx) 121 | if f.animatedLastFrame && progress >= 1 { 122 | f.last = current 123 | } 124 | f.animatedLastFrame = false 125 | if f.Animating(gtx) { 126 | f.animatedLastFrame = true 127 | gtx.Execute(op.InvalidateCmd{}) 128 | } 129 | current.arc = f.last.arc - (f.last.arc-current.arc)*progress 130 | current.radius = f.last.radius - (f.last.radius-current.radius)*progress 131 | } 132 | 133 | visible := f.itemsCache[:min(f.last.len, current.len)] 134 | for i := range visible { 135 | if !f.itemsCache[i].elevated { 136 | f.layoutItem(gtx, i, current) 137 | } 138 | } 139 | for i := range visible { 140 | if f.itemsCache[i].elevated { 141 | f.layoutItem(gtx, i, current) 142 | } 143 | } 144 | return layout.Dimensions{ 145 | Size: gtx.Constraints.Max, 146 | } 147 | 148 | } 149 | 150 | func min(a, b int) int { 151 | if a < b { 152 | return a 153 | } 154 | return b 155 | } 156 | 157 | func (f *Fan) layoutItem(gtx layout.Context, index int, params fanParams) layout.Dimensions { 158 | arc := params.arc 159 | radius := params.radius 160 | arc = arc*float32(index) + f.offsetRadians() 161 | var transform f32.Affine2D 162 | transform = transform.Rotate(f32.Point{}, -math.Pi/2). 163 | Offset(f32.Pt(-radius, float32(f.itemsCache[index].Dimensions.Size.X/2))). 164 | Rotate(f32.Point{}, arc) 165 | defer op.Affine(transform).Push(gtx.Ops).Pop() 166 | f.itemsCache[index].Add(gtx.Ops) 167 | return layout.Dimensions{} 168 | } 169 | -------------------------------------------------------------------------------- /outlay/flow.go: -------------------------------------------------------------------------------- 1 | package outlay 2 | 3 | import ( 4 | "image" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/op" 8 | ) 9 | 10 | // inf is an infinite axis constraint. 11 | const inf = 1e6 12 | 13 | // FlowElement lays out the ith element of a Grid. 14 | type FlowElement func(gtx layout.Context, i int) layout.Dimensions 15 | 16 | // Flow lays out at most Num elements along the main axis. 17 | // The number of cross axis elements depend on the total number of elements. 18 | type Flow struct { 19 | Num int 20 | Axis layout.Axis 21 | Alignment layout.Alignment 22 | list layout.List 23 | } 24 | 25 | // FlowWrap lays out as many elements as possible along the main axis 26 | // before wrapping to the cross axis. 27 | type FlowWrap struct { 28 | Axis layout.Axis 29 | Alignment layout.Alignment 30 | } 31 | 32 | type wrapData struct { 33 | dims layout.Dimensions 34 | call op.CallOp 35 | } 36 | 37 | func (g FlowWrap) Layout(gtx layout.Context, num int, el FlowElement) layout.Dimensions { 38 | defer op.TransformOp{}.Push(gtx.Ops).Pop() 39 | 40 | csMax := gtx.Constraints.Max 41 | var mainSize, crossSize, mainPos, crossPos, base, firstBase int 42 | gtx.Constraints.Min = image.Point{} 43 | mainCs := axisMain(g.Axis, csMax) 44 | crossCs := axisCross(g.Axis, gtx.Constraints.Max) 45 | 46 | var els []wrapData 47 | for i := 0; i < num; i++ { 48 | macro := op.Record(gtx.Ops) 49 | dims, okMain, okCross := g.place(gtx, i, el) 50 | if i == 0 { 51 | firstBase = dims.Size.Y - dims.Baseline 52 | } 53 | call := macro.Stop() 54 | if !okMain && !okCross { 55 | break 56 | } 57 | main := axisMain(g.Axis, dims.Size) 58 | cross := axisCross(g.Axis, dims.Size) 59 | if okMain { 60 | els = append(els, wrapData{dims, call}) 61 | 62 | mainCs := axisMain(g.Axis, gtx.Constraints.Max) 63 | gtx.Constraints.Max = axisPoint(g.Axis, mainCs-main, crossCs) 64 | 65 | mainPos += main 66 | crossPos = max(crossPos, cross) 67 | base = max(base, dims.Baseline) 68 | continue 69 | } 70 | // okCross 71 | mainSize = max(mainSize, mainPos) 72 | crossSize += crossPos 73 | g.placeAll(gtx.Ops, els, crossPos, base) 74 | els = append(els[:0], wrapData{dims, call}) 75 | 76 | gtx.Constraints.Max = axisPoint(g.Axis, mainCs-main, crossCs-crossPos) 77 | mainPos = main 78 | crossPos = cross 79 | base = dims.Baseline 80 | } 81 | mainSize = max(mainSize, mainPos) 82 | crossSize += crossPos 83 | g.placeAll(gtx.Ops, els, crossPos, base) 84 | sz := axisPoint(g.Axis, mainSize, crossSize) 85 | return layout.Dimensions{Size: sz, Baseline: sz.Y - firstBase} 86 | } 87 | 88 | func (g FlowWrap) place(gtx layout.Context, i int, el FlowElement) (dims layout.Dimensions, okMain, okCross bool) { 89 | cs := gtx.Constraints 90 | if g.Axis == layout.Horizontal { 91 | gtx.Constraints.Max.X = inf 92 | } else { 93 | gtx.Constraints.Max.Y = inf 94 | } 95 | dims = el(gtx, i) 96 | okMain = dims.Size.X <= cs.Max.X 97 | okCross = dims.Size.Y <= cs.Max.Y 98 | if g.Axis == layout.Vertical { 99 | okMain, okCross = okCross, okMain 100 | } 101 | return 102 | } 103 | 104 | func (g FlowWrap) placeAll(ops *op.Ops, els []wrapData, crossMax, baseMax int) { 105 | var mainPos int 106 | var pt image.Point 107 | for i, el := range els { 108 | cross := axisCross(g.Axis, el.dims.Size) 109 | switch g.Alignment { 110 | case layout.Start: 111 | cross = 0 112 | case layout.End: 113 | cross = crossMax - cross 114 | case layout.Middle: 115 | cross = (crossMax - cross) / 2 116 | case layout.Baseline: 117 | if g.Axis == layout.Horizontal { 118 | cross = baseMax - el.dims.Baseline 119 | } else { 120 | cross = 0 121 | } 122 | } 123 | if cross == 0 { 124 | el.call.Add(ops) 125 | } else { 126 | pt = axisPoint(g.Axis, 0, cross) 127 | op.Offset(pt).Add(ops) 128 | el.call.Add(ops) 129 | op.Offset(pt.Mul(-1)).Add(ops) 130 | } 131 | if i == len(els)-1 { 132 | pt = axisPoint(g.Axis, -mainPos, crossMax) 133 | } else { 134 | main := axisMain(g.Axis, el.dims.Size) 135 | pt = axisPoint(g.Axis, main, 0) 136 | mainPos += main 137 | } 138 | op.Offset(pt).Add(ops) 139 | } 140 | } 141 | 142 | func (g *Flow) Layout(gtx layout.Context, num int, el FlowElement) layout.Dimensions { 143 | if g.Num == 0 { 144 | return layout.Dimensions{Size: gtx.Constraints.Min} 145 | } 146 | if g.Axis == g.list.Axis { 147 | if g.Axis == layout.Horizontal { 148 | g.list.Axis = layout.Vertical 149 | } else { 150 | g.list.Axis = layout.Horizontal 151 | } 152 | g.list.Alignment = g.Alignment 153 | } 154 | csMax := gtx.Constraints.Max 155 | return g.list.Layout(gtx, (num+g.Num-1)/g.Num, func(gtx layout.Context, idx int) layout.Dimensions { 156 | if g.Axis == layout.Horizontal { 157 | gtx.Constraints.Max.Y = inf 158 | } else { 159 | gtx.Constraints.Max.X = inf 160 | } 161 | gtx.Constraints.Min = image.Point{} 162 | var mainMax, crossMax int 163 | left := axisMain(g.Axis, csMax) 164 | i := idx * g.Num 165 | n := min(num, i+g.Num) 166 | for ; i < n; i++ { 167 | dims := el(gtx, i) 168 | main := axisMain(g.Axis, dims.Size) 169 | crossMax = max(crossMax, axisCross(g.Axis, dims.Size)) 170 | left -= main 171 | if left <= 0 { 172 | mainMax = axisMain(g.Axis, csMax) 173 | break 174 | } 175 | pt := axisPoint(g.Axis, main, 0) 176 | op.Offset(pt).Add(gtx.Ops) 177 | mainMax += main 178 | } 179 | return layout.Dimensions{Size: axisPoint(g.Axis, mainMax, crossMax)} 180 | }) 181 | } 182 | 183 | func max(a, b int) int { 184 | if a > b { 185 | return a 186 | } 187 | return b 188 | } 189 | 190 | func axisPoint(a layout.Axis, main, cross int) image.Point { 191 | if a == layout.Horizontal { 192 | return image.Point{main, cross} 193 | } else { 194 | return image.Point{cross, main} 195 | } 196 | } 197 | 198 | func axisMain(a layout.Axis, sz image.Point) int { 199 | if a == layout.Horizontal { 200 | return sz.X 201 | } else { 202 | return sz.Y 203 | } 204 | } 205 | 206 | func axisCross(a layout.Axis, sz image.Point) int { 207 | if a == layout.Horizontal { 208 | return sz.Y 209 | } else { 210 | return sz.X 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /outlay/grid_test.go: -------------------------------------------------------------------------------- 1 | package outlay 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | "time" 7 | 8 | "gioui.org/f32" 9 | "gioui.org/io/event" 10 | "gioui.org/io/input" 11 | "gioui.org/io/pointer" 12 | "gioui.org/io/system" 13 | "gioui.org/layout" 14 | "gioui.org/op" 15 | "gioui.org/op/clip" 16 | "gioui.org/unit" 17 | ) 18 | 19 | func TestGridLockedRows(t *testing.T) { 20 | var grid Grid 21 | var ops op.Ops 22 | var gtx layout.Context = layout.Context{ 23 | Constraints: layout.Exact(image.Pt(100, 100)), 24 | Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, 25 | Source: (&input.Router{}).Source(), 26 | Now: time.Time{}, 27 | Locale: system.Locale{}, 28 | Ops: &ops, 29 | } 30 | 31 | highestX := 0 32 | highestY := 0 33 | 34 | dimensioner := func(axis layout.Axis, index, constraint int) int { 35 | return 10 36 | } 37 | layoutCell := func(gtx layout.Context, x, y int) layout.Dimensions { 38 | if x > highestX { 39 | highestX = x 40 | } 41 | if y > highestY { 42 | highestY = y 43 | } 44 | return layout.Dimensions{Size: image.Pt(10, 10)} 45 | } 46 | 47 | grid.Layout(gtx, 10, 10, dimensioner, layoutCell) 48 | 49 | if highestX != 9 { 50 | t.Errorf("expected highest X index laid out to be %d, got %d", 9, highestX) 51 | } 52 | if highestY != 9 { 53 | t.Errorf("expected highest Y index laid out to be %d, got %d", 9, highestY) 54 | } 55 | 56 | highestX = 0 57 | highestY = 0 58 | grid.LockedRows = 3 59 | grid.Layout(gtx, 10, 10, dimensioner, layoutCell) 60 | 61 | if highestX != 9 { 62 | t.Errorf("expected highest X index laid out to be %d, got %d", 9, highestX) 63 | } 64 | if highestY != 9 { 65 | t.Errorf("expected highest Y index laid out to be %d, got %d", 9, highestY) 66 | } 67 | } 68 | 69 | func TestGridSize(t *testing.T) { 70 | var grid Grid 71 | var ops op.Ops 72 | var gtx layout.Context = layout.Context{ 73 | Constraints: layout.Constraints{ 74 | Min: image.Pt(10, 10), 75 | Max: image.Pt(1000, 1000), 76 | }, 77 | Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, 78 | Source: (&input.Router{}).Source(), 79 | Now: time.Time{}, 80 | Locale: system.Locale{}, 81 | Ops: &ops, 82 | } 83 | 84 | dimensioner := func(axis layout.Axis, index, constraint int) int { 85 | return 10 86 | } 87 | layoutCell := func(gtx layout.Context, x, y int) layout.Dimensions { 88 | return layout.Dimensions{Size: image.Pt(10, 10)} 89 | } 90 | 91 | // Ensure the returned size is less than the maximum. 92 | dims := grid.Layout(gtx, 10, 10, dimensioner, layoutCell) 93 | expected := (layout.Dimensions{Size: image.Pt(100, 100)}) 94 | if dims != expected { 95 | t.Errorf("expected size %#+v, got %#+v", expected, dims) 96 | } 97 | 98 | // Ensure returned size respects maximum. 99 | gtx.Constraints.Max = image.Pt(50, 50) 100 | dims = grid.Layout(gtx, 10, 10, dimensioner, layoutCell) 101 | expected = (layout.Dimensions{Size: image.Pt(50, 50)}) 102 | if dims != expected { 103 | t.Errorf("expected size %#+v, got %#+v", expected, dims) 104 | } 105 | 106 | // Ensure returned size respects minimum. 107 | gtx.Constraints.Min = image.Pt(500, 500) 108 | gtx.Constraints.Max = image.Pt(1000, 1000) 109 | dims = grid.Layout(gtx, 10, 10, dimensioner, layoutCell) 110 | expected = (layout.Dimensions{Size: image.Pt(500, 500)}) 111 | if dims != expected { 112 | t.Errorf("expected size %#+v, got %#+v", expected, dims) 113 | } 114 | } 115 | 116 | func TestGridPointerEvents(t *testing.T) { 117 | var grid Grid 118 | var ops op.Ops 119 | router := &input.Router{} 120 | var gtx layout.Context = layout.Context{ 121 | Constraints: layout.Exact(image.Pt(100, 100)), 122 | Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, 123 | Source: router.Source(), 124 | Now: time.Time{}, 125 | Locale: system.Locale{}, 126 | Ops: &ops, 127 | } 128 | 129 | sideSize := 100 130 | 131 | dimensioner := func(axis layout.Axis, index, constraint int) int { 132 | return sideSize 133 | } 134 | layoutCell := func(gtx layout.Context, x, y int) layout.Dimensions { 135 | defer clip.Rect{Max: image.Pt(sideSize, sideSize)}.Push(gtx.Ops).Pop() 136 | event.Op(gtx.Ops, t) 137 | return layout.Dimensions{Size: image.Pt(sideSize, sideSize)} 138 | } 139 | 140 | // Lay out the grid to establish its input handlers. 141 | grid.Layout(gtx, 1, 1, dimensioner, layoutCell) 142 | router.Frame(gtx.Ops) 143 | 144 | // Drain the initial cancel event: 145 | _, _ = router.Event(pointer.Filter{ 146 | Target: t, 147 | Kinds: pointer.Press, 148 | }) 149 | 150 | // Queue up a press. 151 | press := pointer.Event{ 152 | Position: f32.Point{ 153 | X: 50, 154 | Y: 50, 155 | }, 156 | Kind: pointer.Press, 157 | } 158 | router.Queue(press) 159 | 160 | event, ok := router.Event(pointer.Filter{ 161 | Target: t, 162 | Kinds: pointer.Press, 163 | }) 164 | if !ok { 165 | t.Errorf("expected an event, got none") 166 | } else if event.(pointer.Event).Kind != press.Kind { 167 | t.Errorf("expected %#+v, got %#+v", press, event) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /outlay/if.go: -------------------------------------------------------------------------------- 1 | package outlay 2 | 3 | import ( 4 | "gioui.org/layout" 5 | "gioui.org/unit" 6 | ) 7 | 8 | // If shows a child widget if the boolean expression is true. 9 | // Allows the caller to easily display content conditionally 10 | // without needing to boilerplate the noop branch. 11 | type If bool 12 | 13 | func (i If) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions { 14 | if !i { 15 | return layout.Dimensions{} 16 | } 17 | return w(gtx) 18 | } 19 | 20 | func (i If) Flexed(weight float32, w layout.Widget) layout.FlexChild { 21 | if i { 22 | return layout.Flexed(weight, w) 23 | } 24 | 25 | return layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} }) 26 | } 27 | 28 | func (i If) Rigid(w layout.Widget) layout.FlexChild { 29 | if i { 30 | return layout.Rigid(w) 31 | } 32 | 33 | return layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} }) 34 | } 35 | 36 | func (i If) EmptyRigid(sz unit.Dp) layout.FlexChild { 37 | if i { 38 | return EmptyRigid(sz) 39 | } 40 | 41 | return layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} }) 42 | } 43 | 44 | func (i If) EmptyRigidHorizontal(sz unit.Dp) layout.FlexChild { 45 | if i { 46 | return EmptyRigidHorizontal(sz) 47 | } 48 | 49 | return layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} }) 50 | } 51 | 52 | func (i If) EmptyRigidVertical(sz unit.Dp) layout.FlexChild { 53 | if i { 54 | return EmptyRigidVertical(sz) 55 | } 56 | 57 | return layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} }) 58 | } 59 | 60 | func (i If) EmptyFlexed() layout.FlexChild { 61 | if i { 62 | return EmptyFlexed() 63 | } 64 | 65 | return layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} }) 66 | } 67 | 68 | func (i If) Stacked(w layout.Widget) layout.StackChild { 69 | if i { 70 | return layout.Stacked(w) 71 | } 72 | 73 | return layout.Stacked(func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} }) 74 | } 75 | 76 | func (i If) Expanded(w layout.Widget) layout.StackChild { 77 | if i { 78 | return layout.Expanded(w) 79 | } 80 | 81 | return layout.Expanded(func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} }) 82 | } 83 | -------------------------------------------------------------------------------- /outlay/localeinset.go: -------------------------------------------------------------------------------- 1 | package outlay 2 | 3 | import ( 4 | "image" 5 | 6 | "gioui.org/io/system" 7 | "gioui.org/layout" 8 | "gioui.org/op" 9 | "gioui.org/unit" 10 | ) 11 | 12 | // Inset adds space around a widget by decreasing its maximum 13 | // constraints. The minimum constraints will be adjusted to ensure 14 | // they do not exceed the maximum. Inset respects the system locale 15 | // provided at layout time, and will swap start/end insets for 16 | // RTL text. This differs from gioui.org/layout.Inset, which never 17 | // swaps the sides contextually. 18 | type Inset struct { 19 | Top, Bottom unit.Dp 20 | // Start and End refer to the visual start and end of the widget 21 | // and surrounding area. In LTR locales, Start is left and End is 22 | // right. In RTL locales the inverse in true. 23 | Start, End unit.Dp 24 | } 25 | 26 | // Layout a widget. 27 | func (in Inset) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions { 28 | top := gtx.Dp(in.Top) 29 | right := gtx.Dp(in.End) 30 | bottom := gtx.Dp(in.Bottom) 31 | left := gtx.Dp(in.Start) 32 | if gtx.Locale.Direction.Progression() == system.TowardOrigin { 33 | if gtx.Locale.Direction.Axis() == system.Horizontal { 34 | right, left = left, right 35 | } else { 36 | top, bottom = bottom, top 37 | } 38 | } 39 | mcs := gtx.Constraints 40 | mcs.Max.X -= left + right 41 | if mcs.Max.X < 0 { 42 | left = 0 43 | right = 0 44 | mcs.Max.X = 0 45 | } 46 | if mcs.Min.X > mcs.Max.X { 47 | mcs.Min.X = mcs.Max.X 48 | } 49 | mcs.Max.Y -= top + bottom 50 | if mcs.Max.Y < 0 { 51 | bottom = 0 52 | top = 0 53 | mcs.Max.Y = 0 54 | } 55 | if mcs.Min.Y > mcs.Max.Y { 56 | mcs.Min.Y = mcs.Max.Y 57 | } 58 | gtx.Constraints = mcs 59 | trans := op.Offset(image.Pt(left, top)).Push(gtx.Ops) 60 | dims := w(gtx) 61 | trans.Pop() 62 | return layout.Dimensions{ 63 | Size: dims.Size.Add(image.Point{X: right + left, Y: top + bottom}), 64 | Baseline: dims.Baseline + bottom, 65 | } 66 | } 67 | 68 | // UniformInset returns an Inset with a single inset applied to all 69 | // edges. 70 | func UniformInset(v unit.Dp) Inset { 71 | return Inset{Top: v, End: v, Bottom: v, Start: v} 72 | } 73 | -------------------------------------------------------------------------------- /outlay/multi-list.go: -------------------------------------------------------------------------------- 1 | package outlay 2 | 3 | import ( 4 | "gioui.org/layout" 5 | ) 6 | 7 | // Segment describes a list segment with an optional header. 8 | // 9 | // [Header] will be invoked first, if supplied. 10 | // [Footer] will be invoked last, if supplied. 11 | // 12 | // The segment must specify how many elements it ranges over since 13 | // the [Element] function does not intrinsically describe how many 14 | // elements it's valid for. 15 | type Segment struct { 16 | Header layout.Widget 17 | Footer layout.Widget 18 | Element layout.ListElement 19 | Range int 20 | } 21 | 22 | // Len returns the range accounting for the option [Header] and [Footer]. 23 | func (s Segment) Len() int { 24 | n := s.Range 25 | if s.Header != nil { 26 | n += 1 27 | } 28 | if s.Footer != nil { 29 | n += 1 30 | } 31 | return n 32 | } 33 | 34 | // Layout the list and header. If [Header] is supplied that is drawn for 35 | // index 0. The index is then adjusted and passed along so that [Element] 36 | // always has an index within its [Range]. [Footer] is drawn after the list 37 | // range. 38 | func (s Segment) Layout(gtx layout.Context, ii int) layout.Dimensions { 39 | if s.Header != nil { 40 | if ii == 0 { 41 | return s.Header(gtx) 42 | } 43 | ii-- 44 | } 45 | if s.Footer != nil { 46 | if ii >= s.Range { 47 | return s.Footer(gtx) 48 | } 49 | } 50 | return s.Element(gtx, ii) 51 | } 52 | 53 | // MultiList provides a layout composed of [Segment]s. Each segment can 54 | // map directly to some data structure, and will receive indexes within 55 | // its [Range] so that it doesn't need to do any index adjustment. 56 | type MultiList []Segment 57 | 58 | // Len computes element length of the entire [MultiList]. 59 | func (ml MultiList) Len() int { 60 | n := 0 61 | for _, s := range ml { 62 | n += s.Len() 63 | } 64 | return n 65 | } 66 | 67 | // Layout the using the corresponding list segment. 68 | // The list segment's [Element] function is invoked with relative offsets so that 69 | // it is always working with an index within its [Range]. 70 | func (ml MultiList) Layout(gtx layout.Context, ii int) layout.Dimensions { 71 | if len(ml) == 0 { 72 | return layout.Dimensions{} 73 | } 74 | idx := ii 75 | for _, s := range ml { 76 | if idx < s.Len() { 77 | return s.Layout(gtx, idx) 78 | } 79 | idx -= s.Len() 80 | } 81 | return layout.Dimensions{} 82 | } 83 | -------------------------------------------------------------------------------- /outlay/rigid-rows.go: -------------------------------------------------------------------------------- 1 | package outlay 2 | 3 | import ( 4 | "gioui.org/layout" 5 | "gioui.org/op" 6 | ) 7 | 8 | // RigidRows lays out a sequence of rigid widgets along Axis until it runs out of out space. 9 | // It then makes a new row/column on the cross axis and fills that with widgets until it 10 | // runs out there, repeating this process until all widgets are placed. 11 | type RigidRows struct { 12 | Axis layout.Axis 13 | Alignment layout.Alignment 14 | Spacing layout.Spacing 15 | CrossSpacing layout.Spacing 16 | CrossAlign layout.Alignment 17 | } 18 | 19 | // Layout children in rows/columns. 20 | func (m RigidRows) Layout(gtx layout.Context, children ...layout.Widget) layout.Dimensions { 21 | converted := m.Axis.Convert(gtx.Constraints.Max) 22 | col := []FlexChild{} 23 | row := make([]FlexChild, 0, len(children)) 24 | 25 | spine := Flex{ 26 | Spacing: m.CrossSpacing, 27 | Alignment: m.CrossAlign, 28 | } 29 | if m.Axis == layout.Horizontal { 30 | spine.Axis = layout.Vertical 31 | } else { 32 | spine.Axis = layout.Horizontal 33 | } 34 | 35 | for len(children) > 0 { 36 | remaining := converted.X 37 | var i int 38 | for i < len(children) { 39 | max := converted 40 | newGtx := gtx 41 | newGtx.Constraints.Max = m.Axis.Convert(max) 42 | macro := op.Record(newGtx.Ops) 43 | dims := children[i](newGtx) 44 | call := macro.Stop() 45 | convertedDims := m.Axis.Convert(dims.Size) 46 | if remaining-convertedDims.X >= 0 { 47 | row = append(row, Rigid(func(gtx layout.Context) layout.Dimensions { 48 | call.Add(gtx.Ops) 49 | return dims 50 | })) 51 | remaining -= convertedDims.X 52 | i++ 53 | } else if convertedDims.X > remaining && len(row) == 0 { 54 | // This is the first item on the row, but it doesn't fit. We have to 55 | // place it here anyway. 56 | row = append(row, Rigid(func(gtx layout.Context) layout.Dimensions { 57 | call.Add(gtx.Ops) 58 | return dims 59 | })) 60 | i++ 61 | break 62 | } else { 63 | break 64 | } 65 | } 66 | children = children[i:] 67 | rowRef := row 68 | col = append(col, Rigid(func(gtx layout.Context) layout.Dimensions { 69 | return Flex{ 70 | Axis: m.Axis, 71 | Spacing: m.Spacing, 72 | Alignment: m.Alignment, 73 | }.Layout(gtx, rowRef...) 74 | })) 75 | // Preserve the elements already in the slice so that our closure's references to them 76 | // remain valid. 77 | row = row[len(row):] 78 | } 79 | return spine.Layout(gtx, col...) 80 | } 81 | -------------------------------------------------------------------------------- /outlay/spacer.go: -------------------------------------------------------------------------------- 1 | package outlay 2 | 3 | import ( 4 | "gioui.org/layout" 5 | "gioui.org/unit" 6 | ) 7 | 8 | // Spacer spaces along both axis according to the value. 9 | type Spacer unit.Dp 10 | 11 | func (s Spacer) Layout(gtx layout.Context) layout.Dimensions { 12 | return layout.Spacer{ 13 | Height: unit.Dp(s), 14 | Width: unit.Dp(s), 15 | }.Layout(gtx) 16 | } 17 | 18 | // Space returns a widget that spaces both axis by a size. 19 | func Space(sz unit.Dp) layout.Widget { 20 | return Spacer(sz).Layout 21 | } 22 | 23 | // EmptyRigid is like space but returns a flex child directly. 24 | func EmptyRigid(sz unit.Dp) layout.FlexChild { 25 | return layout.Rigid(Space(sz)) 26 | } 27 | 28 | // EmptyRigidHorizontal is like EmptyRigid, but stretches only the horizontal space. 29 | func EmptyRigidHorizontal(sz unit.Dp) layout.FlexChild { 30 | return layout.Rigid(layout.Spacer{Width: sz}.Layout) 31 | } 32 | 33 | // EmptyRigidVertical is like EmptyRigid, but stretches only the vertical space. 34 | func EmptyRigidVertical(sz unit.Dp) layout.FlexChild { 35 | return layout.Rigid(layout.Spacer{Height: sz}.Layout) 36 | } 37 | 38 | // EmptyFlexed returns a flex that consumes its available space. 39 | // Use this to push rigid widgets around. 40 | func EmptyFlexed() layout.FlexChild { 41 | return layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { 42 | return layout.Dimensions{Size: gtx.Constraints.Min} 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /pref/README.md: -------------------------------------------------------------------------------- 1 | # pref [![Go Reference](https://pkg.go.dev/badge/gioui.org/x/pref.svg)](https://pkg.go.dev/gioui.org/x/pref) 2 | 3 | ------------- 4 | 5 | Get the user preferences for your Gio app. 6 | 7 | ## What can it be used for? 8 | 9 | The [Theme](https://pkg.go.dev/gioui.org/x/pref/theme) package provides `IsDarkMode`, which can be used to 10 | change your palette in order to honor the user's preferences. 11 | 12 | // Check the preference: 13 | isDark, _ := theme.IsDarkMode() 14 | 15 | // Change the Palette based on the preference: 16 | var palette material.Palette 17 | if isDark { 18 | palette.Bg = color.NRGBA{A: 255} // Black Background 19 | palette.Fg = color.NRGBA{R: 255, G: 255, B: 255, A: 255} // White Foreground 20 | } else { 21 | palette.Bg = color.NRGBA{R: 255, G: 255, B: 255, A: 255} // White Background 22 | palette.Fg = color.NRGBA{A: 255} // Black Foreground 23 | } 24 | 25 | The [Locale](https://pkg.go.dev/gioui.org/x/pref/locale) makes possible to match the user language preference, that 26 | is important for multi-language apps. So, let your app speak the user's native language. 27 | 28 | // Your dictionary (in that case using x/text/catalog): 29 | cat := catalog.NewBuilder() 30 | cat.SetString(language.English, "Hello World", "Hello World") 31 | cat.SetString(language.Portuguese, "Hello World", "Olá Mundo") 32 | cat.SetString(language.Spanish, "Hello World", "Hola Mundo") 33 | cat.SetString(language.Czech, "Hello World", "Ahoj světe") 34 | cat.SetString(language.French, "Hello World", "Bonjour le monde") 35 | 36 | // Get the user preferences: 37 | userLanguage, _ := locale.Language() 38 | 39 | // Get the best match based on the preferred language: 40 | userLanguage, _, confidence := cat.Matcher().Match(userLanguage) 41 | if confidence <= language.Low { 42 | userLanguage = language.English // Switch to the default language, due to low confidence. 43 | } 44 | 45 | // Creates the printer with the user language: 46 | printer := message.NewPrinter(userLanguage, message.Catalog(cat)) 47 | 48 | // Display the text based on the language: 49 | widget.Label{}.Layout(gtx, 50 | yourTheme.Shaper, 51 | text.Font{}, 52 | unit.Dp(12), 53 | printer.Sprintf("Hello World"), 54 | ) 55 | 56 | ## Status 57 | 58 | Most of the features is supported across Android 6+, JS and Windows 10. It will return ErrAvailableAPI for any other 59 | platform that isn't supported. 60 | 61 | | Package | OS | 62 | | ----------- | ----------- | 63 | | [Locale](https://pkg.go.dev/gioui.org/x/pref/locale) | Android 6+
JS
Linux
Windows Vista+ | 64 | | [Theme](https://pkg.go.dev/gioui.org/x/pref/theme) | Android 4+
JS
Windows 10+ | 65 | | [Battery](https://pkg.go.dev/gioui.org/x/pref/battery) | Android 6+
JS (Chrome)
Windows Vista+| 66 | -------------------------------------------------------------------------------- /pref/battery/battery.go: -------------------------------------------------------------------------------- 1 | // Package battery provides functions to get the current status of the batteries. 2 | // 3 | // That is useful to change app behavior based on the battery level. You can reduce animations 4 | // or requests when the battery Level is low or if the user IsSaving battery. 5 | package battery 6 | 7 | import ( 8 | "errors" 9 | ) 10 | 11 | var ( 12 | // ErrNotAvailableAPI indicates that the current device/OS doesn't support such function. 13 | ErrNotAvailableAPI = errors.New("pref: not available api") 14 | 15 | // ErrNoSystemBattery indicates that the current device doesn't use batteries. 16 | // 17 | // Some APIs (like Android and JS) don't provide a mechanism to determine whether the machine uses batteries or not. 18 | // In such a case ErrNoSystemBattery will never be returned. 19 | ErrNoSystemBattery = errors.New("pref: device isn't battery-powered") 20 | ) 21 | 22 | // Level returns the battery level as percent level, between 0~100. 23 | func Level() (uint8, error) { 24 | return batteryLevel() 25 | } 26 | 27 | // IsSaving returns "true" if the end-user enables the battery saver on the device. 28 | func IsSaving() (bool, error) { 29 | return isSavingBattery() 30 | } 31 | 32 | // IsCharging returns "true" if the device is charging. 33 | // If the device doesn't rely on batteries it will be always true. 34 | func IsCharging() (bool, error) { 35 | return isCharging() 36 | } 37 | -------------------------------------------------------------------------------- /pref/battery/battery_android.go: -------------------------------------------------------------------------------- 1 | package battery 2 | 3 | import ( 4 | "gioui.org/app" 5 | "gioui.org/x/pref/internal/xjni" 6 | "git.wow.st/gmp/jni" 7 | ) 8 | 9 | //go:generate javac -source 8 -target 8 -bootclasspath $ANDROID_HOME/platforms/android-29/android.jar -d $TEMP/pref_battery/classes battery_android.java 10 | //go:generate jar cf battery_android.jar -C $TEMP/pref_battery/classes . 11 | 12 | var ( 13 | _Lib = "org/gioui/x/pref/battery/battery_android" 14 | ) 15 | 16 | func batteryLevel() (uint8, error) { 17 | i, err := xjni.DoInt(_Lib, "batteryLevel", "(Landroid/content/Context;)I", jni.Value(app.AppContext())) 18 | if err != nil || i < 0 { 19 | return 100, ErrNotAvailableAPI 20 | } 21 | return uint8(i), nil 22 | } 23 | 24 | func isSavingBattery() (bool, error) { 25 | i, err := xjni.DoInt(_Lib, "isSaving", "(Landroid/content/Context;)I", jni.Value(app.AppContext())) 26 | if err != nil || i < 0 { 27 | return false, ErrNotAvailableAPI 28 | } 29 | return i >= 1, err 30 | } 31 | 32 | func isCharging() (bool, error) { 33 | i, err := xjni.DoInt(_Lib, "isCharging", "(Landroid/content/Context;)I", jni.Value(app.AppContext())) 34 | if err != nil || i < 0 { 35 | return false, ErrNotAvailableAPI 36 | } 37 | return i >= 1, err 38 | } 39 | -------------------------------------------------------------------------------- /pref/battery/battery_android.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/pref/battery/battery_android.jar -------------------------------------------------------------------------------- /pref/battery/battery_android.java: -------------------------------------------------------------------------------- 1 | package org.gioui.x.pref.battery; 2 | 3 | import android.os.Build; 4 | import android.content.Context; 5 | import android.os.BatteryManager; 6 | import android.os.PowerManager; 7 | import android.util.Log; 8 | 9 | public class battery_android { 10 | public static int batteryLevel(Context context) { 11 | if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP) { 12 | return -1; 13 | } 14 | 15 | BatteryManager bm = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE); 16 | if (bm == null) { 17 | return -1; 18 | } 19 | 20 | return bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY); 21 | } 22 | 23 | public static int isCharging(Context context) { 24 | if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP) { 25 | return -1; 26 | } 27 | 28 | BatteryManager bm = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE); 29 | if (bm == null) { 30 | return -1; 31 | } 32 | 33 | if (bm.isCharging()) { 34 | return 1; 35 | } 36 | return 0; 37 | } 38 | 39 | public static int isSaving(Context context) { 40 | if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP) { 41 | return -1; 42 | } 43 | 44 | PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); 45 | if (pm == null) { 46 | return -1; 47 | } 48 | 49 | if (pm.isPowerSaveMode()) { 50 | return 1; 51 | } 52 | return 0; 53 | } 54 | } -------------------------------------------------------------------------------- /pref/battery/battery_js.go: -------------------------------------------------------------------------------- 1 | package battery 2 | 3 | import ( 4 | "errors" 5 | "math" 6 | "syscall/js" 7 | ) 8 | 9 | var ( 10 | _GetBattery = js.Global().Get("navigator").Get("getBattery") 11 | ) 12 | 13 | func batteryLevel() (uint8, error) { 14 | value, err := do("level") 15 | if err != nil || !value.Truthy() { 16 | return 100, err 17 | } 18 | 19 | b := uint8(math.Ceil(value.Float() * 100)) 20 | switch { 21 | case b > 100: 22 | return 100, nil 23 | case b < 0: 24 | return 0, nil 25 | default: 26 | return b, nil 27 | } 28 | } 29 | 30 | func isSavingBattery() (bool, error) { 31 | return false, ErrNotAvailableAPI 32 | } 33 | 34 | func isCharging() (bool, error) { 35 | value, err := do("charging") 36 | if err != nil || !value.Truthy() { 37 | return false, err 38 | } 39 | 40 | return value.Bool(), nil 41 | } 42 | 43 | func do(name string) (js.Value, error) { 44 | if !_GetBattery.Truthy() { 45 | return js.Value{}, ErrNotAvailableAPI 46 | } 47 | 48 | var ( 49 | success, failure js.Func 50 | 51 | value = make(chan js.Value, 1) 52 | err = make(chan error, 1) 53 | ) 54 | 55 | success = js.FuncOf(func(this js.Value, args []js.Value) interface{} { 56 | success.Release() 57 | failure.Release() 58 | 59 | value <- args[0].Get(name) 60 | 61 | return nil 62 | }) 63 | 64 | failure = js.FuncOf(func(this js.Value, args []js.Value) interface{} { 65 | success.Release() 66 | failure.Release() 67 | 68 | err <- errors.New("failure getting battery") 69 | 70 | return nil 71 | }) 72 | 73 | go func() { 74 | js.Global().Get("navigator").Call("getBattery").Call("then", success, failure) 75 | }() 76 | 77 | select { 78 | case value := <-value: 79 | return value, nil 80 | case <-err: 81 | return js.Value{}, ErrNotAvailableAPI 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /pref/battery/battery_linux.go: -------------------------------------------------------------------------------- 1 | package battery 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | func batteryLevel() (uint8, error) { 10 | bat, err := newBattery() 11 | if err != nil { 12 | return 0, err 13 | } 14 | 15 | return bat.level() 16 | } 17 | 18 | func isSavingBattery() (bool, error) { 19 | const pattern = "/sys/devices/system/cpu/cpu*/cpufreq/scaling_governor" 20 | cpus, err := filepath.Glob(pattern) 21 | if err != nil || len(cpus) == 0 { 22 | return false, ErrNotAvailableAPI 23 | } 24 | 25 | raw, err := os.ReadFile(cpus[0]) 26 | if err != nil { 27 | return false, ErrNotAvailableAPI 28 | } 29 | 30 | return string(raw) == "powersave\n", nil 31 | } 32 | 33 | func isCharging() (bool, error) { 34 | bat, err := newBattery() 35 | if err != nil { 36 | return false, err 37 | } 38 | return bat.isCharging() 39 | } 40 | 41 | type battery struct { 42 | root string 43 | } 44 | 45 | func newBattery() (battery, error) { 46 | const pattern = "/sys/class/power_supply/BAT*" 47 | bs, err := filepath.Glob(pattern) 48 | if err != nil || len(bs) == 0 { 49 | return battery{}, ErrNoSystemBattery 50 | } 51 | 52 | return battery{bs[0]}, nil 53 | } 54 | 55 | func (bat battery) level() (lvl uint8, err error) { 56 | if bat.root == "" { 57 | return 0, ErrNoSystemBattery 58 | } 59 | 60 | f, err := os.Open(filepath.Join(bat.root, "capacity")) 61 | if err != nil { 62 | return 0, ErrNotAvailableAPI 63 | } 64 | defer f.Close() 65 | 66 | _, err = fmt.Fscanf(f, "%d", &lvl) 67 | if err != nil { 68 | return 0, ErrNotAvailableAPI 69 | } 70 | 71 | return lvl, nil 72 | } 73 | 74 | func (bat battery) isCharging() (bool, error) { 75 | if bat.root == "" { 76 | return false, ErrNoSystemBattery 77 | } 78 | 79 | raw, err := os.ReadFile(filepath.Join(bat.root, "status")) 80 | if err != nil { 81 | return false, ErrNotAvailableAPI 82 | } 83 | 84 | return string(raw) == "charging\n", nil 85 | } 86 | -------------------------------------------------------------------------------- /pref/battery/battery_unsupported.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !windows && !android && !linux 2 | // +build !js,!windows,!android,!linux 3 | 4 | package battery 5 | 6 | func batteryLevel() (uint8, error) { 7 | return 100, ErrNotAvailableAPI 8 | } 9 | 10 | func isSavingBattery() (bool, error) { 11 | return false, ErrNotAvailableAPI 12 | } 13 | 14 | func isCharging() (bool, error) { 15 | return false, ErrNotAvailableAPI 16 | } 17 | -------------------------------------------------------------------------------- /pref/battery/battery_windows.go: -------------------------------------------------------------------------------- 1 | package battery 2 | 3 | import ( 4 | "golang.org/x/sys/windows" 5 | "unsafe" 6 | ) 7 | 8 | var ( 9 | _Kernel32 = windows.NewLazySystemDLL("kernel32") 10 | 11 | // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getsystempowerstatus 12 | _GetSystemPowerStatus = _Kernel32.NewProc("GetSystemPowerStatus") 13 | 14 | _BatteryFlagCharging byte = 8 15 | _BatteryFlagNoSystemBattery byte = 128 16 | _BatteryFlagUnknownStatus byte = 255 17 | 18 | _BatteryLifePercentUnknown byte = 255 19 | ) 20 | 21 | func batteryLevel() (uint8, error) { 22 | resp, err := powerStatus() 23 | if err != nil { 24 | return 100, ErrNotAvailableAPI 25 | } 26 | 27 | if resp.BatteryLifePercent == _BatteryLifePercentUnknown || resp.BatteryFlag&_BatteryFlagNoSystemBattery > 0 { 28 | return 100, ErrNoSystemBattery 29 | } 30 | 31 | return resp.BatteryLifePercent, nil 32 | } 33 | 34 | // isSavingBattery returns "true" if battery saver is enabled on Windows 10. 35 | // For Windows Vista, Windows 7, Windows 8 it wil always "false" without any error. Because and it's indistinguishable 36 | // from a truly-false, see: https://docs.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-system_power_status 37 | func isSavingBattery() (bool, error) { 38 | resp, err := powerStatus() 39 | if err != nil { 40 | return false, ErrNotAvailableAPI 41 | } 42 | 43 | if resp.BatteryFlag&_BatteryFlagNoSystemBattery > 0 { 44 | return false, ErrNoSystemBattery 45 | } 46 | 47 | return resp.SystemStatusFlag == 1, nil 48 | } 49 | 50 | func isCharging() (bool, error) { 51 | resp, err := powerStatus() 52 | if err != nil || resp.BatteryFlag == _BatteryFlagUnknownStatus { 53 | return false, ErrNotAvailableAPI 54 | } 55 | 56 | if resp.BatteryFlag&_BatteryFlagNoSystemBattery > 0 { 57 | return true, ErrNoSystemBattery 58 | } 59 | 60 | return resp.BatteryFlag&_BatteryFlagCharging > 0, nil 61 | } 62 | 63 | // _SystemPowerStatus follows https://docs.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-system_power_status 64 | type _SystemPowerStatus struct { 65 | ACLineStatus byte 66 | BatteryFlag byte 67 | BatteryLifePercent byte 68 | SystemStatusFlag byte 69 | BatteryLifeTime int32 70 | BatteryFullLifeTime int32 71 | } 72 | 73 | func powerStatus() (resp _SystemPowerStatus, err error) { 74 | r, _, err := _GetSystemPowerStatus.Call(uintptr(unsafe.Pointer(&resp))) 75 | if r == 0 { 76 | return resp, err 77 | } 78 | 79 | return resp, nil 80 | } 81 | -------------------------------------------------------------------------------- /pref/internal/xjni/jni_android.go: -------------------------------------------------------------------------------- 1 | // That package is used to reduce code duplication, when using JNI. 2 | package xjni 3 | 4 | import ( 5 | "gioui.org/app" 6 | "git.wow.st/gmp/jni" 7 | ) 8 | 9 | // DoInt invokes a static int method in the JVM and returns its results. lib is the path to the 10 | // java class with the method, function is the name of the method, signature is the JNI 11 | // string description of the method signature, and args allows providing parameters to 12 | // the method. 13 | func DoInt(lib string, function string, signature string, args ...jni.Value) (i int, err error) { 14 | err = jni.Do(jni.JVMFor(app.JavaVM()), func(env jni.Env) error { 15 | class, err := jni.LoadClass(env, jni.ClassLoaderFor(env, jni.Object(app.AppContext())), lib) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | i, err = jni.CallStaticIntMethod(env, class, jni.GetStaticMethodID(env, class, function, signature), args...) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | return nil 26 | }) 27 | 28 | return i, err 29 | } 30 | 31 | // DoString invokes a static String method in the JVM and returns its results. lib is the path to the 32 | // java class with the method, function is the name of the method, signature is the JNI 33 | // string description of the method signature, and args allows providing parameters to 34 | // the method. 35 | func DoString(lib string, function string, signature string, args ...jni.Value) (s string, err error) { 36 | err = jni.Do(jni.JVMFor(app.JavaVM()), func(env jni.Env) error { 37 | class, err := jni.LoadClass(env, jni.ClassLoaderFor(env, jni.Object(app.AppContext())), lib) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | o, err := jni.CallStaticObjectMethod(env, class, jni.GetStaticMethodID(env, class, function, signature), args...) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | s = jni.GoString(env, jni.String(o)) 48 | return nil 49 | }) 50 | 51 | return s, err 52 | } 53 | -------------------------------------------------------------------------------- /pref/locale/locale.go: -------------------------------------------------------------------------------- 1 | // Package locale can be used to get the end-user preferred language, useful for multi-language apps. 2 | package locale 3 | 4 | import ( 5 | "errors" 6 | "golang.org/x/text/language" 7 | ) 8 | 9 | var ( 10 | // ErrNotAvailableAPI indicates that the current device/OS doesn't support such function. 11 | ErrNotAvailableAPI = errors.New("pref: not available api") 12 | 13 | // ErrUnknownLanguage indicates that the current language is not supported by x/text/language. 14 | ErrUnknownLanguage = errors.New("pref: unknown language") 15 | ) 16 | 17 | // Language is the preferred language of the end-user or the language of the operating system. 18 | func Language() (language.Tag, error) { 19 | l := getLanguage() 20 | if l == "" { 21 | return language.Tag{}, ErrNotAvailableAPI 22 | } 23 | 24 | tag, err := language.Parse(l) 25 | if err != nil { 26 | return language.Tag{}, ErrUnknownLanguage 27 | } 28 | 29 | return tag, nil 30 | } 31 | -------------------------------------------------------------------------------- /pref/locale/locale_android.go: -------------------------------------------------------------------------------- 1 | package locale 2 | 3 | import ( 4 | "gioui.org/x/pref/internal/xjni" 5 | ) 6 | 7 | //go:generate javac -source 8 -target 8 -bootclasspath $ANDROID_HOME/platforms/android-29/android.jar -d $TEMP/x_locale/classes locale_android.java 8 | //go:generate jar cf locale_android.jar -C $TEMP/x_locale/classes . 9 | 10 | var ( 11 | _Lib = "org/gioui/x/pref/locale/locale_android" 12 | ) 13 | 14 | func getLanguage() string { 15 | lang, err := xjni.DoString(_Lib, "getLanguage", "()Ljava/lang/String;") 16 | if err != nil { 17 | return "" 18 | } 19 | 20 | return lang 21 | } 22 | -------------------------------------------------------------------------------- /pref/locale/locale_android.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/pref/locale/locale_android.jar -------------------------------------------------------------------------------- /pref/locale/locale_android.java: -------------------------------------------------------------------------------- 1 | package org.gioui.x.pref.locale; 2 | 3 | import android.content.res.Resources; 4 | import android.os.Build; 5 | import java.util.Locale; 6 | 7 | public class locale_android { 8 | public static String getLanguage() { 9 | if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP) { 10 | return ""; 11 | } 12 | 13 | if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) { 14 | return Resources.getSystem().getConfiguration().locale.toLanguageTag(); 15 | } 16 | 17 | return Resources.getSystem().getConfiguration().getLocales().get(0).toLanguageTag(); 18 | } 19 | } -------------------------------------------------------------------------------- /pref/locale/locale_js.go: -------------------------------------------------------------------------------- 1 | package locale 2 | 3 | import ( 4 | "syscall/js" 5 | ) 6 | 7 | var ( 8 | _Navigator = js.Global().Get("navigator") 9 | ) 10 | 11 | func getLanguage() string { 12 | if !_Navigator.Truthy() { 13 | return "" 14 | } 15 | 16 | return _Navigator.Get("language").String() 17 | } 18 | -------------------------------------------------------------------------------- /pref/locale/locale_linux.go: -------------------------------------------------------------------------------- 1 | //go:build !android 2 | // +build !android 3 | 4 | package locale 5 | 6 | import ( 7 | "os" 8 | "strings" 9 | ) 10 | 11 | func getLanguage() string { 12 | lang := os.Getenv("LANG") 13 | if lang == "" { 14 | return "" 15 | } 16 | 17 | // Strip the ".UTF-8" (or equivalent) from the language. 18 | langs := strings.Split(lang, ".") 19 | if len(langs) < 1 { 20 | return "" 21 | } 22 | if langs[0] == "C" { 23 | // Fall back to English rather than an "unsupported" error. 24 | return "en" 25 | } 26 | 27 | return langs[0] 28 | } 29 | -------------------------------------------------------------------------------- /pref/locale/locale_unsupported.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !windows && !android && !linux 2 | // +build !js,!windows,!android,!linux 3 | 4 | package locale 5 | 6 | func getLanguage() string { 7 | return "" 8 | } 9 | -------------------------------------------------------------------------------- /pref/locale/locale_windows.go: -------------------------------------------------------------------------------- 1 | package locale 2 | 3 | import ( 4 | "golang.org/x/sys/windows" 5 | "unsafe" 6 | ) 7 | 8 | var ( 9 | _Kernel32 = windows.NewLazySystemDLL("kernel32") 10 | 11 | // https://docs.microsoft.com/en-us/windows/win32/api/winnls/nf-winnls-getuserdefaultlocalename 12 | _DefaultUserLang = _Kernel32.NewProc("GetUserDefaultLocaleName") 13 | 14 | // https://docs.microsoft.com/en-us/windows/win32/api/winnls/nf-winnls-getsystemdefaultlocalename 15 | _DefaultSystemLang = _Kernel32.NewProc("GetSystemDefaultLocaleName") 16 | 17 | // _LocaleNameMaxSize is the "LOCALE_NAME_MAX_LENGTH". 18 | // https://docs.microsoft.com/en-us/windows/win32/intl/locale-name-constants 19 | _LocaleNameMaxSize = 85 20 | ) 21 | 22 | func getLanguage() string { 23 | lang := make([]uint16, _LocaleNameMaxSize) 24 | 25 | r, _, _ := _DefaultUserLang.Call(uintptr(unsafe.Pointer(&lang[0])), uintptr(_LocaleNameMaxSize)) 26 | if r == 0 { 27 | _DefaultSystemLang.Call(uintptr(unsafe.Pointer(&lang[0])), uintptr(_LocaleNameMaxSize)) 28 | } 29 | 30 | return windows.UTF16ToString(lang) 31 | } 32 | -------------------------------------------------------------------------------- /pref/theme/theme.go: -------------------------------------------------------------------------------- 1 | // Package theme provides functions to retrieve user preferences related to theme and accessibility. 2 | package theme 3 | 4 | import ( 5 | "errors" 6 | ) 7 | 8 | var ( 9 | // ErrNotAvailableAPI indicates that the current device doesn't support such function. 10 | ErrNotAvailableAPI = errors.New("pref: not available api") 11 | ) 12 | 13 | // IsDarkMode returns "true" if the end-user prefers dark-mode theme. 14 | func IsDarkMode() (bool, error) { 15 | return isDark() 16 | } 17 | 18 | // IsReducedMotion returns "true" if the end-user prefers reduced-motion/disabled-animations. 19 | func IsReducedMotion() (bool, error) { 20 | return isReducedMotion() 21 | } 22 | -------------------------------------------------------------------------------- /pref/theme/theme_android.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | //go:generate javac -source 8 -target 8 -bootclasspath $ANDROID_HOME/platforms/android-29/android.jar -d $TEMP/pref_theme/classes theme_android.java 4 | //go:generate jar cf theme_android.jar -C $TEMP/pref_theme/classes . 5 | 6 | import ( 7 | "gioui.org/x/pref/internal/xjni" 8 | ) 9 | 10 | var ( 11 | _Lib = "org/gioui/x/pref/theme/theme_android" 12 | ) 13 | 14 | func isDark() (bool, error) { 15 | i, err := xjni.DoInt(_Lib, "isDark", "()I") 16 | if err != nil || i < 0 { 17 | return false, ErrNotAvailableAPI 18 | } 19 | return i >= 1, nil 20 | } 21 | 22 | func isReducedMotion() (bool, error) { 23 | i, err := xjni.DoInt(_Lib, "isReducedMotion", "()I") 24 | if err != nil || i < 0 { 25 | return false, ErrNotAvailableAPI 26 | } 27 | return i >= 1, nil 28 | } 29 | -------------------------------------------------------------------------------- /pref/theme/theme_android.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/pref/theme/theme_android.jar -------------------------------------------------------------------------------- /pref/theme/theme_android.java: -------------------------------------------------------------------------------- 1 | package org.gioui.x.pref.theme; 2 | 3 | import android.content.res.Resources; 4 | import android.content.res.Configuration; 5 | import android.provider.Settings.Global; 6 | import android.os.Build; 7 | 8 | public class theme_android { 9 | public static int isDark() { 10 | Resources res = Resources.getSystem(); 11 | if (res == null) { 12 | return -1; 13 | } 14 | 15 | Configuration config = res.getConfiguration(); 16 | if (config == null) { 17 | return -1; 18 | } 19 | 20 | if ((config.uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES) { 21 | return 1; 22 | } 23 | 24 | return 0; 25 | } 26 | 27 | public static int isReducedMotion() { 28 | if (Global.TRANSITION_ANIMATION_SCALE == "0") { 29 | return 1; 30 | } 31 | return 0; 32 | } 33 | } -------------------------------------------------------------------------------- /pref/theme/theme_js.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "syscall/js" 5 | ) 6 | 7 | var ( 8 | _MatchMedia = js.Global().Get("matchMedia") 9 | ) 10 | 11 | func isDark() (bool, error) { 12 | return do("(prefers-color-scheme: dark)") 13 | } 14 | 15 | func isReducedMotion() (bool, error) { 16 | return do("(prefers-reduced-motion: reduce)") 17 | } 18 | 19 | func do(name string) (bool, error) { 20 | if !_MatchMedia.Truthy() { 21 | return false, ErrNotAvailableAPI 22 | } 23 | 24 | return _MatchMedia.Invoke(name).Get("matches").Bool(), nil 25 | } 26 | -------------------------------------------------------------------------------- /pref/theme/theme_unsupported.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !windows && !android 2 | // +build !js,!windows,!android 3 | 4 | package theme 5 | 6 | func isDark() (bool, error) { 7 | return false, ErrNotAvailableAPI 8 | } 9 | 10 | func isReducedMotion() (bool, error) { 11 | return false, ErrNotAvailableAPI 12 | } 13 | -------------------------------------------------------------------------------- /pref/theme/theme_windows.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "golang.org/x/sys/windows" 5 | "golang.org/x/sys/windows/registry" 6 | "unsafe" 7 | ) 8 | 9 | var ( 10 | _RegistryPersonalize = `SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize` 11 | 12 | _User32 = windows.NewLazySystemDLL("user32") 13 | 14 | // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-systemparametersinfow 15 | _SystemParameters = _User32.NewProc("SystemParametersInfoW") 16 | ) 17 | 18 | const ( 19 | // _GetClientAreaAnimation is the_SPI_GETCLIENTAREAANIMATION. 20 | _GetClientAreaAnimation = 0x1042 21 | ) 22 | 23 | func isDark() (bool, error) { 24 | k, err := registry.OpenKey(registry.CURRENT_USER, _RegistryPersonalize, registry.QUERY_VALUE) 25 | if err != nil { 26 | return false, ErrNotAvailableAPI 27 | } 28 | defer k.Close() 29 | 30 | v, _, err := k.GetIntegerValue("AppsUseLightTheme") 31 | if err != nil { 32 | return false, ErrNotAvailableAPI 33 | } 34 | 35 | return v == 0, nil 36 | } 37 | 38 | func isReducedMotion() (bool, error) { 39 | disabled := true 40 | r, _, _ := _SystemParameters.Call(uintptr(_GetClientAreaAnimation), 0, uintptr(unsafe.Pointer(&disabled)), 0) 41 | if r == 0 { 42 | return false, ErrNotAvailableAPI 43 | } 44 | 45 | return !disabled, nil 46 | } 47 | -------------------------------------------------------------------------------- /richtext/README.md: -------------------------------------------------------------------------------- 1 | # richtext 2 | 3 | Provides a widget that renders text in different styles. 4 | -------------------------------------------------------------------------------- /richtext/example_richtext_test.go: -------------------------------------------------------------------------------- 1 | package richtext_test 2 | 3 | import ( 4 | "image/color" 5 | "log" 6 | 7 | "gioui.org/app" 8 | "gioui.org/font/gofont" 9 | "gioui.org/gesture" 10 | "gioui.org/op" 11 | "gioui.org/text" 12 | "gioui.org/unit" 13 | "gioui.org/widget/material" 14 | "gioui.org/x/richtext" 15 | ) 16 | 17 | func Example() { 18 | var ( 19 | fonts = gofont.Collection() 20 | th = material.NewTheme() 21 | black = color.NRGBA{A: 255} 22 | green = color.NRGBA{G: 170, A: 255} 23 | blue = color.NRGBA{B: 170, A: 255} 24 | red = color.NRGBA{R: 170, A: 255} 25 | ) 26 | th.Shaper = text.NewShaper(text.WithCollection(fonts)) 27 | go func() { 28 | w := new(app.Window) 29 | 30 | // allocate persistent state for interactive text. This 31 | // needs to be persisted across frames. 32 | var state richtext.InteractiveText 33 | 34 | interactColors := []color.NRGBA{black, green, blue, red} 35 | interactColorIndex := 0 36 | 37 | var ops op.Ops 38 | for { 39 | e := w.Event() 40 | switch e := e.(type) { 41 | case app.DestroyEvent: 42 | panic(e.Err) 43 | case app.FrameEvent: 44 | gtx := app.NewContext(&ops, e) 45 | 46 | // define the text that you want to present. This can be persisted 47 | // across frames, recomputed every frame, or modified in any way between 48 | // frames. 49 | var spans []richtext.SpanStyle = []richtext.SpanStyle{ 50 | { 51 | Content: "Hello ", 52 | Color: black, 53 | Size: unit.Sp(24), 54 | Font: fonts[0].Font, 55 | }, 56 | { 57 | Content: "in ", 58 | Color: green, 59 | Size: unit.Sp(36), 60 | Font: fonts[0].Font, 61 | }, 62 | { 63 | Content: "rich ", 64 | Color: blue, 65 | Size: unit.Sp(30), 66 | Font: fonts[0].Font, 67 | }, 68 | { 69 | Content: "text\n", 70 | Color: red, 71 | Size: unit.Sp(40), 72 | Font: fonts[0].Font, 73 | }, 74 | { 75 | Content: "Interact with me!", 76 | Color: interactColors[interactColorIndex%len(interactColors)], 77 | Size: unit.Sp(40), 78 | Font: fonts[0].Font, 79 | Interactive: true, 80 | }, 81 | } 82 | 83 | // process any interactions with the text since the last frame. 84 | for { 85 | span, event, ok := state.Update(gtx) 86 | if !ok { 87 | break 88 | } 89 | content, _ := span.Content() 90 | switch event.Type { 91 | case richtext.Click: 92 | log.Println(event.ClickData.Kind) 93 | if event.ClickData.Kind == gesture.KindClick { 94 | interactColorIndex++ 95 | gtx.Execute(op.InvalidateCmd{}) 96 | } 97 | case richtext.Hover: 98 | w.Option(app.Title("Hovered: " + content)) 99 | case richtext.Unhover: 100 | w.Option(app.Title("Unhovered: " + content)) 101 | case richtext.LongPress: 102 | w.Option(app.Title("Long-pressed: " + content)) 103 | } 104 | } 105 | 106 | // render the rich text into the operation list 107 | richtext.Text(&state, th.Shaper, spans...).Layout(gtx) 108 | 109 | // render the operation list 110 | e.Frame(gtx.Ops) 111 | } 112 | } 113 | }() 114 | app.Main() 115 | } 116 | -------------------------------------------------------------------------------- /richtext/richtext_test.go: -------------------------------------------------------------------------------- 1 | package richtext 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | "time" 7 | 8 | "gioui.org/font/gofont" 9 | "gioui.org/io/input" 10 | "gioui.org/layout" 11 | "gioui.org/op" 12 | "gioui.org/text" 13 | "gioui.org/unit" 14 | "gioui.org/widget/material" 15 | ) 16 | 17 | // TestNilInteractiveText ensures that it is safe to lay out 18 | // richtext with a nil state when none of the spans are 19 | // interactive. 20 | func TestNilInteractiveText(t *testing.T) { 21 | th := material.NewTheme() 22 | th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection())) 23 | spans := []SpanStyle{ 24 | { 25 | Size: 12, 26 | Content: "Hello", 27 | }, 28 | { 29 | Size: 12, 30 | Content: "world", 31 | }, 32 | } 33 | var ops op.Ops 34 | gtx := layout.Context{ 35 | Constraints: layout.Exact(image.Pt(100, 100)), 36 | Metric: unit.Metric{ 37 | PxPerDp: 1, 38 | PxPerSp: 1, 39 | }, 40 | Source: input.Source{}, 41 | Now: time.Now(), 42 | Ops: &ops, 43 | } 44 | 45 | Text(nil, th.Shaper, spans...).Layout(gtx) 46 | } 47 | -------------------------------------------------------------------------------- /stroke/refs/TestDashedPathFlatCapEllipse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/stroke/refs/TestDashedPathFlatCapEllipse.png -------------------------------------------------------------------------------- /stroke/refs/TestDashedPathFlatCapZ.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/stroke/refs/TestDashedPathFlatCapZ.png -------------------------------------------------------------------------------- /stroke/refs/TestDashedPathFlatCapZNoDash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/stroke/refs/TestDashedPathFlatCapZNoDash.png -------------------------------------------------------------------------------- /stroke/refs/TestStrokedPathArc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/stroke/refs/TestStrokedPathArc.png -------------------------------------------------------------------------------- /stroke/refs/TestStrokedPathBalloon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/stroke/refs/TestStrokedPathBalloon.png -------------------------------------------------------------------------------- /stroke/refs/TestStrokedPathBevelFlat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/stroke/refs/TestStrokedPathBevelFlat.png -------------------------------------------------------------------------------- /stroke/refs/TestStrokedPathBevelRound.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/stroke/refs/TestStrokedPathBevelRound.png -------------------------------------------------------------------------------- /stroke/refs/TestStrokedPathBevelSquare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/stroke/refs/TestStrokedPathBevelSquare.png -------------------------------------------------------------------------------- /stroke/refs/TestStrokedPathCoincidentControlPoint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/stroke/refs/TestStrokedPathCoincidentControlPoint.png -------------------------------------------------------------------------------- /stroke/refs/TestStrokedPathFlatMiter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/stroke/refs/TestStrokedPathFlatMiter.png -------------------------------------------------------------------------------- /stroke/refs/TestStrokedPathFlatMiterInf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/stroke/refs/TestStrokedPathFlatMiterInf.png -------------------------------------------------------------------------------- /stroke/refs/TestStrokedPathZeroWidth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/stroke/refs/TestStrokedPathZeroWidth.png -------------------------------------------------------------------------------- /stroke/stroke.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | // Package stroke converts complex strokes to gioui.org/op/clip operations. 4 | package stroke 5 | 6 | import ( 7 | "math" 8 | 9 | "gioui.org/f32" 10 | "gioui.org/op" 11 | "gioui.org/op/clip" 12 | "github.com/andybalholm/stroke" 13 | ) 14 | 15 | // Path defines the shape of a Stroke. 16 | type Path struct { 17 | Segments []Segment 18 | } 19 | 20 | type Segment struct { 21 | // op is the operator. 22 | op segmentOp 23 | // args is up to three (x, y) coordinates. 24 | args [3]f32.Point 25 | } 26 | 27 | // Dashes defines the dash pattern of a Stroke. 28 | type Dashes struct { 29 | Phase float32 30 | Dashes []float32 31 | } 32 | 33 | // Stroke defines a stroke. 34 | type Stroke struct { 35 | Path Path 36 | Width float32 // Width of the stroked path. 37 | 38 | // Miter is the limit to apply to a miter joint. 39 | Miter float32 40 | Cap StrokeCap // Cap describes the head or tail of a stroked path. 41 | Join StrokeJoin // Join describes how stroked paths are collated. 42 | 43 | Dashes Dashes 44 | } 45 | 46 | type segmentOp uint8 47 | 48 | const ( 49 | segOpMoveTo segmentOp = iota 50 | segOpLineTo 51 | segOpQuadTo 52 | segOpCubeTo 53 | segOpArcTo 54 | ) 55 | 56 | // StrokeCap describes the head or tail of a stroked path. 57 | type StrokeCap uint8 58 | 59 | const ( 60 | // RoundCap caps stroked paths with a round cap, joining the right-hand and 61 | // left-hand sides of a stroked path with a half disc of diameter the 62 | // stroked path's width. 63 | RoundCap StrokeCap = iota 64 | 65 | // FlatCap caps stroked paths with a flat cap, joining the right-hand 66 | // and left-hand sides of a stroked path with a straight line. 67 | FlatCap 68 | 69 | // SquareCap caps stroked paths with a square cap, joining the right-hand 70 | // and left-hand sides of a stroked path with a half square of length 71 | // the stroked path's width. 72 | SquareCap 73 | 74 | // TriangularCap caps stroked paths with a triangular cap, joining the 75 | // right-hand and left-hand sides of a stroked path with a triangle 76 | // with height half of the stroked path's width. 77 | TriangularCap 78 | ) 79 | 80 | // StrokeJoin describes how stroked paths are collated. 81 | type StrokeJoin uint8 82 | 83 | const ( 84 | // RoundJoin joins path segments with a round segment. 85 | RoundJoin StrokeJoin = iota 86 | 87 | // BevelJoin joins path segments with sharp bevels. 88 | BevelJoin 89 | 90 | // MiterJoin joins path segments with a sharp corner. 91 | // It falls back to a bevel join if the miter limit is exceeded. 92 | MiterJoin 93 | ) 94 | 95 | func MoveTo(p f32.Point) Segment { 96 | s := Segment{ 97 | op: segOpMoveTo, 98 | } 99 | s.args[0] = p 100 | return s 101 | } 102 | 103 | func LineTo(p f32.Point) Segment { 104 | s := Segment{ 105 | op: segOpLineTo, 106 | } 107 | s.args[0] = p 108 | return s 109 | } 110 | 111 | func QuadTo(ctrl, end f32.Point) Segment { 112 | s := Segment{ 113 | op: segOpQuadTo, 114 | } 115 | s.args[0] = ctrl 116 | s.args[1] = end 117 | return s 118 | } 119 | 120 | func CubeTo(ctrl0, ctrl1, end f32.Point) Segment { 121 | s := Segment{ 122 | op: segOpCubeTo, 123 | } 124 | s.args[0] = ctrl0 125 | s.args[1] = ctrl1 126 | s.args[2] = end 127 | return s 128 | } 129 | 130 | func ArcTo(center f32.Point, angle float32) Segment { 131 | s := Segment{ 132 | op: segOpArcTo, 133 | } 134 | s.args[0] = center 135 | s.args[1].X = angle 136 | return s 137 | } 138 | 139 | // Op returns a clip operation that approximates stroke. 140 | func (s Stroke) Op(ops *op.Ops) clip.Op { 141 | if len(s.Path.Segments) == 0 { 142 | return clip.Op{} 143 | } 144 | 145 | // Use the stroke package to find the outline of the stroke. 146 | var path [][]stroke.Segment 147 | var contour []stroke.Segment 148 | var pen f32.Point 149 | 150 | for _, seg := range s.Path.Segments { 151 | switch seg.op { 152 | case segOpMoveTo: 153 | if len(contour) > 0 { 154 | path = append(path, contour) 155 | contour = nil 156 | } 157 | pen = seg.args[0] 158 | case segOpLineTo: 159 | contour = append(contour, stroke.LinearSegment(stroke.Point(pen), stroke.Point(seg.args[0]))) 160 | pen = seg.args[0] 161 | case segOpQuadTo: 162 | contour = append(contour, stroke.QuadraticSegment(stroke.Point(pen), stroke.Point(seg.args[0]), stroke.Point(seg.args[1]))) 163 | pen = seg.args[1] 164 | case segOpCubeTo: 165 | contour = append(contour, stroke.Segment{stroke.Point(pen), stroke.Point(seg.args[0]), stroke.Point(seg.args[1]), stroke.Point(seg.args[2])}) 166 | pen = seg.args[2] 167 | case segOpArcTo: 168 | var ( 169 | start = stroke.Point(pen) 170 | center = stroke.Point(seg.args[0]) 171 | angle = seg.args[1].X 172 | ) 173 | switch { 174 | case absF32(angle) > math.Pi: 175 | contour = stroke.AppendArc(contour, start, center, angle) 176 | pen = f32.Point(contour[len(contour)-1].End) 177 | default: 178 | out := stroke.ArcSegment(start, center, angle) 179 | contour = append(contour, out) 180 | pen = f32.Point(out.End) 181 | } 182 | } 183 | } 184 | if len(contour) > 0 { 185 | path = append(path, contour) 186 | } 187 | 188 | if len(s.Dashes.Dashes) > 0 { 189 | path = stroke.Dash(path, s.Dashes.Dashes, s.Dashes.Phase) 190 | } 191 | 192 | var opt stroke.Options 193 | opt.Width = s.Width 194 | opt.MiterLimit = s.Miter 195 | switch s.Cap { 196 | case RoundCap: 197 | opt.Cap = stroke.RoundCap 198 | case SquareCap: 199 | opt.Cap = stroke.SquareCap 200 | case FlatCap: 201 | opt.Cap = stroke.FlatCap 202 | case TriangularCap: 203 | opt.Cap = stroke.TriangularCap 204 | } 205 | switch s.Join { 206 | case RoundJoin: 207 | opt.Join = stroke.RoundJoin 208 | case BevelJoin: 209 | opt.Join = stroke.BevelJoin 210 | case MiterJoin: 211 | opt.Join = stroke.MiterJoin 212 | } 213 | 214 | stroked := stroke.Stroke(path, opt) 215 | 216 | // Output path data. 217 | var outline clip.Path 218 | outline.Begin(ops) 219 | for _, contour := range stroked { 220 | for i, seg := range contour { 221 | if i == 0 { 222 | outline.MoveTo(f32.Point(seg.Start)) 223 | pen = f32.Point(seg.Start) 224 | } 225 | if pen != f32.Point(seg.Start) { 226 | outline.LineTo(f32.Point(seg.Start)) 227 | } 228 | outline.CubeTo(f32.Point(seg.CP1), f32.Point(seg.CP2), f32.Point(seg.End)) 229 | pen = f32.Point(seg.End) 230 | } 231 | } 232 | 233 | return clip.Outline{Path: outline.End()}.Op() 234 | } 235 | 236 | func absF32(x float32) float32 { 237 | return math.Float32frombits(math.Float32bits(x) &^ (1 << 31)) 238 | } 239 | -------------------------------------------------------------------------------- /stroke/util_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package stroke 4 | 5 | import ( 6 | "bytes" 7 | "flag" 8 | "fmt" 9 | "image" 10 | "image/color" 11 | "image/png" 12 | "io/ioutil" 13 | "path/filepath" 14 | "strconv" 15 | "testing" 16 | 17 | "golang.org/x/image/colornames" 18 | 19 | "gioui.org/gpu/headless" 20 | "gioui.org/internal/f32color" 21 | "gioui.org/op" 22 | ) 23 | 24 | var ( 25 | dumpImages = flag.Bool("saveimages", false, "save test images") 26 | ) 27 | 28 | var ( 29 | red = f32color.RGBAToNRGBA(colornames.Red) 30 | black = f32color.RGBAToNRGBA(colornames.Black) 31 | transparent = color.RGBA{} 32 | ) 33 | 34 | func drawImage(t *testing.T, size int, ops *op.Ops, draw func(o *op.Ops)) (im *image.RGBA, err error) { 35 | sz := image.Point{X: size, Y: size} 36 | w := newWindow(t, sz.X, sz.Y) 37 | defer w.Release() 38 | draw(ops) 39 | if err := w.Frame(ops); err != nil { 40 | return nil, err 41 | } 42 | im = image.NewRGBA(image.Rectangle{Max: sz}) 43 | err = w.Screenshot(im) 44 | return im, err 45 | } 46 | 47 | func run(t *testing.T, f func(o *op.Ops), c func(r result)) { 48 | // Draw a few times and check that it is correct each time, to 49 | // ensure any caching effects still generate the correct images. 50 | var img *image.RGBA 51 | var err error 52 | ops := new(op.Ops) 53 | for i := 0; i < 3; i++ { 54 | ops.Reset() 55 | img, err = drawImage(t, 128, ops, f) 56 | if err != nil { 57 | t.Error("error rendering:", err) 58 | return 59 | } 60 | // Check for a reference image and make sure it is identical. 61 | if !verifyRef(t, img, 0) { 62 | name := fmt.Sprintf("%s-%d-bad.png", t.Name(), i) 63 | saveImage(t, name, img) 64 | } 65 | c(result{t: t, img: img}) 66 | } 67 | } 68 | 69 | func verifyRef(t *testing.T, img *image.RGBA, frame int) (ok bool) { 70 | // ensure identical to ref data 71 | path := filepath.Join("refs", t.Name()+".png") 72 | if frame != 0 { 73 | path = filepath.Join("refs", t.Name()+"_"+strconv.Itoa(frame)+".png") 74 | } 75 | b, err := ioutil.ReadFile(path) 76 | if err != nil { 77 | t.Error("could not open ref:", err) 78 | return 79 | } 80 | r, err := png.Decode(bytes.NewReader(b)) 81 | if err != nil { 82 | t.Error("could not decode ref:", err) 83 | return 84 | } 85 | if img.Bounds() != r.Bounds() { 86 | t.Errorf("reference image is %v, expected %v", r.Bounds(), img.Bounds()) 87 | return false 88 | } 89 | var ref *image.RGBA 90 | switch r := r.(type) { 91 | case *image.RGBA: 92 | ref = r 93 | case *image.NRGBA: 94 | ref = image.NewRGBA(r.Bounds()) 95 | bnd := r.Bounds() 96 | for x := bnd.Min.X; x < bnd.Max.X; x++ { 97 | for y := bnd.Min.Y; y < bnd.Max.Y; y++ { 98 | ref.SetRGBA(x, y, f32color.NRGBAToRGBA(r.NRGBAAt(x, y))) 99 | } 100 | } 101 | default: 102 | t.Fatalf("reference image is a %T, expected *image.NRGBA or *image.RGBA", r) 103 | } 104 | bnd := img.Bounds() 105 | for x := bnd.Min.X; x < bnd.Max.X; x++ { 106 | for y := bnd.Min.Y; y < bnd.Max.Y; y++ { 107 | exp := ref.RGBAAt(x, y) 108 | got := img.RGBAAt(x, y) 109 | if !colorsClose(exp, got) || !alphaClose(exp, got) { 110 | t.Error("not equal to ref at", x, y, " ", got, exp) 111 | return false 112 | } 113 | } 114 | } 115 | return true 116 | } 117 | 118 | func colorsClose(c1, c2 color.RGBA) bool { 119 | const delta = 0.04 // magic value obtained from experimentation. 120 | return yiqEqApprox(c1, c2, delta) 121 | } 122 | 123 | func alphaClose(c1, c2 color.RGBA) bool { 124 | d := int8(c1.A - c2.A) 125 | return d > -60 && d < 60 126 | } 127 | 128 | // yiqEqApprox compares the colors of 2 pixels, in the NTSC YIQ color space, 129 | // as described in: 130 | // 131 | // Measuring perceived color difference using YIQ NTSC 132 | // transmission color space in mobile applications. 133 | // Yuriy Kotsarenko, Fernando Ramos. 134 | // 135 | // An electronic version is available at: 136 | // 137 | // - http://www.progmat.uaem.mx:8080/artVol2Num2/Articulo3Vol2Num2.pdf 138 | func yiqEqApprox(c1, c2 color.RGBA, d2 float64) bool { 139 | const max = 35215.0 // difference between 2 maximally different pixels. 140 | 141 | var ( 142 | r1 = float64(c1.R) 143 | g1 = float64(c1.G) 144 | b1 = float64(c1.B) 145 | 146 | r2 = float64(c2.R) 147 | g2 = float64(c2.G) 148 | b2 = float64(c2.B) 149 | 150 | y1 = r1*0.29889531 + g1*0.58662247 + b1*0.11448223 151 | i1 = r1*0.59597799 - g1*0.27417610 - b1*0.32180189 152 | q1 = r1*0.21147017 - g1*0.52261711 + b1*0.31114694 153 | 154 | y2 = r2*0.29889531 + g2*0.58662247 + b2*0.11448223 155 | i2 = r2*0.59597799 - g2*0.27417610 - b2*0.32180189 156 | q2 = r2*0.21147017 - g2*0.52261711 + b2*0.31114694 157 | 158 | y = y1 - y2 159 | i = i1 - i2 160 | q = q1 - q2 161 | 162 | diff = 0.5053*y*y + 0.299*i*i + 0.1957*q*q 163 | ) 164 | return diff <= max*d2 165 | } 166 | 167 | func (r result) expect(x, y int, col color.RGBA) { 168 | r.t.Helper() 169 | if r.img == nil { 170 | return 171 | } 172 | c := r.img.RGBAAt(x, y) 173 | if !colorsClose(c, col) { 174 | r.t.Error("expected ", col, " at ", "(", x, ",", y, ") but got ", c) 175 | } 176 | } 177 | 178 | type result struct { 179 | t *testing.T 180 | img *image.RGBA 181 | } 182 | 183 | func saveImage(t testing.TB, file string, img *image.RGBA) { 184 | if !*dumpImages { 185 | return 186 | } 187 | // Only NRGBA images are losslessly encoded by png.Encode. 188 | nrgba := image.NewNRGBA(img.Bounds()) 189 | bnd := img.Bounds() 190 | for x := bnd.Min.X; x < bnd.Max.X; x++ { 191 | for y := bnd.Min.Y; y < bnd.Max.Y; y++ { 192 | nrgba.SetNRGBA(x, y, f32color.RGBAToNRGBA(img.RGBAAt(x, y))) 193 | } 194 | } 195 | var buf bytes.Buffer 196 | if err := png.Encode(&buf, nrgba); err != nil { 197 | t.Error(err) 198 | return 199 | } 200 | if err := ioutil.WriteFile(file, buf.Bytes(), 0666); err != nil { 201 | t.Error(err) 202 | return 203 | } 204 | } 205 | 206 | func newWindow(t testing.TB, width, height int) *headless.Window { 207 | w, err := headless.NewWindow(width, height) 208 | if err != nil { 209 | t.Skipf("failed to create headless window, skipping: %v", err) 210 | } 211 | return w 212 | } 213 | -------------------------------------------------------------------------------- /styledtext/README.md: -------------------------------------------------------------------------------- 1 | # styledtext 2 | 3 | Provides a widget that renders text in different styles. 4 | -------------------------------------------------------------------------------- /styledtext/iterator.go: -------------------------------------------------------------------------------- 1 | package styledtext 2 | 3 | import ( 4 | "image" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/op" 8 | "gioui.org/op/clip" 9 | "gioui.org/op/paint" 10 | "gioui.org/text" 11 | "golang.org/x/exp/constraints" 12 | "golang.org/x/image/math/fixed" 13 | ) 14 | 15 | // textIterator computes the bounding box of and paints text. This iterator is 16 | // specialized to laying out single lines of text. 17 | type textIterator struct { 18 | // viewport is the rectangle of document coordinates that the iterator is 19 | // trying to fill with text. 20 | viewport image.Rectangle 21 | // maxLines tracks the maximum allowed number of glyphs with FlagLineBreak. 22 | maxLines int 23 | 24 | // linesSeen tracks the number of FlagLineBreak glyphs we have seen. 25 | linesSeen int 26 | // init tracks whether the iterator has processed any glyphs. 27 | init bool 28 | // firstX tracks the x offset of the first processed glyph. This is subtracted 29 | // from all glyph x offsets in order to ensure that the text is rendered at 30 | // x=0. 31 | firstX fixed.Int26_6 32 | // hasNewline tracks whether the processed glyphs contained a synthetic newline 33 | // character. 34 | hasNewline bool 35 | // lineOff tracks the origin for the glyphs in the current line. 36 | lineOff image.Point 37 | // padding is the space needed outside of the bounds of the text to ensure no 38 | // part of a glyph is clipped. 39 | padding image.Rectangle 40 | // bounds is the logical bounding box of the text. 41 | bounds image.Rectangle 42 | // runes is the count of runes represented by the processed glyphs. 43 | runes int 44 | // visible tracks whether the most recently iterated glyph is visible within 45 | // the viewport. 46 | visible bool 47 | // first tracks whether the iterator has processed a glyph yet. 48 | first bool 49 | // baseline tracks the location of the first line of text's baseline. 50 | baseline int 51 | } 52 | 53 | // processGlyph checks whether the glyph is visible within the iterator's configured 54 | // viewport and (if so) updates the iterator's text dimensions to include the glyph. 55 | func (it *textIterator) processGlyph(g text.Glyph, ok bool) (_ text.Glyph, visibleOrBefore bool) { 56 | logicalBounds := image.Rectangle{ 57 | Min: image.Pt(g.X.Floor(), int(g.Y)-g.Ascent.Ceil()), 58 | Max: image.Pt((g.X + g.Advance).Ceil(), int(g.Y)+g.Descent.Ceil()), 59 | } 60 | if g.Flags&text.FlagTruncator != 0 { 61 | // If the truncator is the first glyph, force a newline. 62 | if it.runes == 0 { 63 | it.hasNewline = true 64 | } 65 | // We always need to update the vertical bounds for the truncator glyph in case it's the only 66 | // glyph on its line. Otherwise the line will seem to have zero size. 67 | it.bounds.Min.Y = min(it.bounds.Min.Y, logicalBounds.Min.Y) 68 | it.bounds.Max.Y = max(it.bounds.Max.Y, logicalBounds.Max.Y) 69 | return g, false 70 | } 71 | it.runes += int(g.Runes) 72 | it.hasNewline = it.hasNewline || (g.Flags&text.FlagLineBreak > 0 && g.Flags&text.FlagParagraphBreak > 0) 73 | if it.maxLines > 0 { 74 | if g.Flags&text.FlagLineBreak != 0 { 75 | it.linesSeen++ 76 | } 77 | if it.linesSeen == it.maxLines && g.Flags&text.FlagParagraphBreak != 0 { 78 | return g, false 79 | } 80 | } 81 | // Compute the maximum extent to which glyphs overhang on the horizontal 82 | // axis. 83 | if d := g.Bounds.Min.X.Floor(); d < it.padding.Min.X { 84 | it.padding.Min.X = d 85 | } 86 | if d := (g.Bounds.Max.X - g.Advance).Ceil(); d > it.padding.Max.X { 87 | it.padding.Max.X = d 88 | } 89 | if !it.first { 90 | it.first = true 91 | it.baseline = int(g.Y) 92 | it.bounds = logicalBounds 93 | } 94 | 95 | above := logicalBounds.Max.Y < it.viewport.Min.Y 96 | below := logicalBounds.Min.Y > it.viewport.Max.Y 97 | left := logicalBounds.Max.X < it.viewport.Min.X 98 | right := logicalBounds.Min.X > it.viewport.Max.X 99 | it.visible = !above && !below && !left && !right 100 | if it.visible { 101 | it.bounds.Min.X = min(it.bounds.Min.X, logicalBounds.Min.X) 102 | it.bounds.Min.Y = min(it.bounds.Min.Y, logicalBounds.Min.Y) 103 | it.bounds.Max.X = max(it.bounds.Max.X, logicalBounds.Max.X) 104 | it.bounds.Max.Y = max(it.bounds.Max.Y, logicalBounds.Max.Y) 105 | } 106 | return g, ok && !below 107 | 108 | } 109 | 110 | func min[T constraints.Ordered](a, b T) T { 111 | if a < b { 112 | return a 113 | } 114 | return b 115 | } 116 | 117 | func max[T constraints.Ordered](a, b T) T { 118 | if a > b { 119 | return a 120 | } 121 | return b 122 | } 123 | 124 | // paintGlyph buffers up and paints text glyphs. It should be invoked iteratively upon each glyph 125 | // until it returns false. The line parameter should be a slice with 126 | // a backing array of sufficient size to buffer multiple glyphs. 127 | // A modified slice will be returned with each invocation, and is 128 | // expected to be passed back in on the following invocation. 129 | // This design is awkward, but prevents the line slice from escaping 130 | // to the heap. 131 | func (it *textIterator) paintGlyph(gtx layout.Context, shaper *text.Shaper, glyph text.Glyph, line []text.Glyph) ([]text.Glyph, bool) { 132 | _, visibleOrBefore := it.processGlyph(glyph, true) 133 | if it.visible { 134 | if !it.init { 135 | it.firstX = glyph.X 136 | it.init = true 137 | } 138 | if len(line) == 0 { 139 | it.lineOff = image.Point{X: (glyph.X - it.firstX).Floor(), Y: int(glyph.Y)}.Sub(it.viewport.Min) 140 | } 141 | line = append(line, glyph) 142 | } 143 | if glyph.Flags&text.FlagLineBreak > 0 || cap(line)-len(line) == 0 || !visibleOrBefore { 144 | t := op.Offset(it.lineOff).Push(gtx.Ops) 145 | op := clip.Outline{Path: shaper.Shape(line)}.Op().Push(gtx.Ops) 146 | paint.PaintOp{}.Add(gtx.Ops) 147 | op.Pop() 148 | t.Pop() 149 | line = line[:0] 150 | } 151 | return line, visibleOrBefore 152 | } 153 | -------------------------------------------------------------------------------- /styledtext/styledtext_test.go: -------------------------------------------------------------------------------- 1 | package styledtext 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | 7 | "gioui.org/app" 8 | "gioui.org/font" 9 | "gioui.org/font/gofont" 10 | "gioui.org/layout" 11 | "gioui.org/op" 12 | "gioui.org/text" 13 | "gioui.org/unit" 14 | ) 15 | 16 | // TestStyledtextRegressions checks for known regressions that have made styledtext hang in the 17 | // past. 18 | func TestStyledtextRegressions(t *testing.T) { 19 | type testcase struct { 20 | name string 21 | spans []SpanStyle 22 | space image.Point 23 | } 24 | for _, tc := range []testcase{ 25 | { 26 | name: "single newline in a span", 27 | spans: []SpanStyle{ 28 | { 29 | Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Bold}, 30 | Size: 12, 31 | Content: "Label: ", 32 | }, 33 | { 34 | Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal}, 35 | Size: 12, 36 | Content: "select", 37 | }, 38 | { 39 | Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal}, 40 | Size: 12, 41 | Content: "\n", 42 | }, 43 | { 44 | Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Bold}, 45 | Size: 12, 46 | Content: "Start: ", 47 | }, 48 | }, 49 | space: image.Point{X: 10, Y: 100}, 50 | }, 51 | { 52 | name: "paragraphs separated by double newline", 53 | spans: []SpanStyle{ 54 | { 55 | Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Bold}, 56 | Size: 12, 57 | Content: "hi", 58 | }, 59 | { 60 | Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal}, 61 | Size: 12, 62 | Content: "\n\n", 63 | }, 64 | { 65 | Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal}, 66 | Size: 12, 67 | Content: "there", 68 | }, 69 | }, 70 | space: image.Point{X: 100, Y: 100}, 71 | }, 72 | } { 73 | t.Run(tc.name, func(t *testing.T) { 74 | txt := Text(text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection())), tc.spans...) 75 | var ops op.Ops 76 | gtx := app.NewContext(&ops, app.FrameEvent{ 77 | Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, 78 | Size: tc.space, 79 | }) 80 | 81 | txt.Layout(gtx, func(gtx layout.Context, idx int, dims layout.Dimensions) {}) 82 | }) 83 | } 84 | } 85 | 86 | // TestStyledtextNewlines ensures that newlines create appropriate gaps between text. 87 | func TestStyledtextNewlines(t *testing.T) { 88 | gtx := app.NewContext(new(op.Ops), app.FrameEvent{ 89 | Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, 90 | Size: image.Point{X: 40, Y: 1000}, 91 | }) 92 | gtx.Constraints.Min = image.Point{} 93 | shaper := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection())) 94 | 95 | singleLineTxt := Text(shaper, SpanStyle{Size: 12, Content: "a"}) 96 | singleLineDims := singleLineTxt.Layout(gtx, func(gtx layout.Context, idx int, dims layout.Dimensions) {}) 97 | 98 | type testcase struct { 99 | name string 100 | spans []SpanStyle 101 | expectedLines int 102 | } 103 | for _, tc := range []testcase{ 104 | { 105 | name: "double newline between simple letters", 106 | expectedLines: 3, 107 | spans: []SpanStyle{ 108 | { 109 | Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Bold}, 110 | Size: 16, 111 | Content: "a", 112 | }, 113 | { 114 | Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal}, 115 | Size: 16, 116 | Content: "\n\n", 117 | }, 118 | { 119 | Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal}, 120 | Size: 16, 121 | Content: "b", 122 | }, 123 | }, 124 | }, 125 | { 126 | name: "double newline after a too-long word", 127 | expectedLines: 3, 128 | spans: []SpanStyle{ 129 | { 130 | Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Bold}, 131 | Size: 16, 132 | Content: "mmmmm a", 133 | }, 134 | { 135 | Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal}, 136 | Size: 16, 137 | Content: "\n\n", 138 | }, 139 | { 140 | Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal}, 141 | Size: 16, 142 | Content: "b", 143 | }, 144 | }, 145 | }, 146 | } { 147 | t.Run(tc.name, func(t *testing.T) { 148 | txt := Text(shaper, tc.spans...) 149 | txtDims := txt.Layout(gtx, func(gtx layout.Context, idx int, dims layout.Dimensions) {}) 150 | 151 | if expectedMinY := int((float32(tc.expectedLines) - .5) * float32(singleLineDims.Size.Y)); txtDims.Size.Y <= expectedMinY { 152 | t.Errorf("expected double newline to create %d lines, dimensions too small", tc.expectedLines) 153 | t.Logf("expected > %d, got %d (single line height is %d)", expectedMinY, txtDims.Size.Y, singleLineDims.Size.Y) 154 | } 155 | if expectedMaxY := int((float32(tc.expectedLines) + .5) * float32(singleLineDims.Size.Y)); txtDims.Size.Y <= expectedMaxY { 156 | t.Errorf("expected double newline to create %d lines, dimensions too large", tc.expectedLines) 157 | t.Logf("expected < %d, got %d (single line height is %d)", expectedMaxY, txtDims.Size.Y, singleLineDims.Size.Y) 158 | } 159 | }) 160 | } 161 | } 162 | --------------------------------------------------------------------------------