├── .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 | [](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 | [](https://pkg.go.dev/gioui.org/x/colorpicker)
4 |
5 | This is a simple Gio package that provides widgets for choosing colors.
6 |
7 | 
8 |
--------------------------------------------------------------------------------
/colorpicker/img/screenshot.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gioui/gio-x/c005f2ad1592a123fc5b6fb8b4e108ac5468df3e/colorpicker/img/screenshot.jpg
--------------------------------------------------------------------------------
/component/README.md:
--------------------------------------------------------------------------------
1 | # component
2 |
3 | [](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 | 
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 | 
49 |
50 | Contextual state:
51 |
52 | 
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 | 
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 | 
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 | 
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 [](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 | [](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 | [](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 | [](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 [](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 |
--------------------------------------------------------------------------------