├── .github └── workflows │ ├── platform_tests.yml │ └── static_analysis.yml ├── .gitignore ├── AUTHORS ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── cmd ├── calendar_demo │ └── main.go ├── diagramdemo │ └── main.go ├── fyne-x │ ├── FyneApp.toml │ ├── Icon.png │ └── main.go ├── hexwidget_demo │ ├── README.md │ └── main.go ├── map_demo │ └── main.go ├── portion_demo │ └── main.go ├── responsive_layout │ └── main.go ├── twostatetoolbaraction_demo │ └── main.go └── wrapper │ └── main.go ├── data ├── binding │ ├── closers.go │ ├── json.go │ ├── json_test.go │ ├── mqttstring.go │ └── webstring.go └── validation │ ├── password.go │ └── password_test.go ├── dialog └── about.go ├── go.mod ├── go.sum ├── img ├── about-dialog.png ├── about.png ├── adwaita-theme-dark.png ├── adwaita-theme-light.png ├── diagramdemo.png ├── gifwidget.gif ├── hexwidget_00abcdef.png ├── hexwidget_12345678.png ├── map.png ├── responsive-layout.png ├── widget-completion-entry.png └── widget-filetree.png ├── layout ├── layout.go ├── portion.go ├── portion_test.go ├── responsive.go └── responsive_test.go ├── theme ├── adwaita.go ├── adwaita_colors.go ├── adwaita_icons.go └── adwaita_theme_generator.go ├── widget ├── calendar.go ├── calendar_test.go ├── completionentry.go ├── completionentry_test.go ├── diagramwidget │ ├── README.md │ ├── anchoredtext.go │ ├── arrowhead.go │ ├── connectionpad.go │ ├── decoration.go │ ├── diagram.go │ ├── diagramElement.go │ ├── diagram_test.go │ ├── geometry │ │ ├── geometry.go │ │ └── r2 │ │ │ ├── box.go │ │ │ ├── box_test.go │ │ │ ├── geometry.go │ │ │ ├── line.go │ │ │ ├── vec2.go │ │ │ └── vec2_test.go │ ├── handle.go │ ├── link.go │ ├── linkpoint.go │ ├── linksegment.go │ ├── node.go │ ├── polygon.go │ └── springforcelayout.go ├── filetree.go ├── filetree_test.go ├── gif.go ├── gif_slow_test.go ├── gif_test.go ├── gridwrap.go ├── gridwrap_test.go ├── hexwidget.go ├── map.go ├── map_test.go ├── mapbutton.go ├── mapcache.go ├── numerical_entry.go ├── numerical_entry_test.go ├── testdata │ ├── filetree │ │ └── selected.png │ ├── gif │ │ ├── README.md │ │ ├── earth-once.gif │ │ ├── earth.gif │ │ └── initial.png │ └── twostatetoolbaraction │ │ ├── offstate.png │ │ └── onstate.png ├── twostatetoolbaraction.go ├── twostatetoolbaraction_test.go └── widget.go └── wrapper ├── mouseable.go ├── mouseable_test.go ├── tappable.go ├── tappable_test.go └── wrapper.go /.github/workflows/platform_tests.yml: -------------------------------------------------------------------------------- 1 | name: Platform Tests 2 | on: [push, pull_request] 3 | permissions: 4 | contents: read 5 | 6 | jobs: 7 | platform_tests: 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | go-version: ['', 'stable'] 13 | os: [ubuntu-latest, windows-latest, macos-latest] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | persist-credentials: false 19 | - uses: actions/setup-go@v5 20 | with: 21 | go-version: ${{ matrix.go-version }} 22 | go-version-file: 'go.mod' 23 | 24 | - name: Get dependencies 25 | run: >- 26 | sudo apt-get update && 27 | sudo apt-get install 28 | bc 29 | gcc 30 | libgl1-mesa-dev 31 | libwayland-dev 32 | libx11-dev 33 | libxkbcommon-dev 34 | xorg-dev 35 | if: ${{ runner.os == 'Linux' }} 36 | 37 | - name: Tests 38 | run: go test -tags ci ./... 39 | -------------------------------------------------------------------------------- /.github/workflows/static_analysis.yml: -------------------------------------------------------------------------------- 1 | name: Static Analysis 2 | on: [push, pull_request] 3 | permissions: 4 | contents: read 5 | 6 | jobs: 7 | static_analysis: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | persist-credentials: false 16 | - uses: actions/setup-go@v5 17 | with: 18 | go-version: 'stable' 19 | 20 | - name: Get dependencies 21 | run: >- 22 | sudo apt-get update && 23 | sudo apt-get install 24 | gcc 25 | libegl1-mesa-dev 26 | libgl1-mesa-dev 27 | libgles2-mesa-dev 28 | libx11-dev 29 | xorg-dev 30 | 31 | - name: Install analysis tools 32 | run: | 33 | go install golang.org/x/tools/cmd/goimports@latest 34 | go install github.com/fzipp/gocyclo/cmd/gocyclo@latest 35 | go install honnef.co/go/tools/cmd/staticcheck@latest 36 | go install github.com/mattn/goveralls@latest 37 | 38 | - name: Vet 39 | run: go vet ./... 40 | 41 | - name: Goimports 42 | run: test -z "$(goimports -e -d . | tee /dev/stderr)" 43 | 44 | - name: Gocyclo 45 | run: gocyclo -over 30 . 46 | 47 | - name: Staticcheck 48 | run: staticcheck ./... 49 | 50 | - name: Update coverage 51 | run: | 52 | set -e 53 | xvfb-run go test -covermode=atomic -coverprofile=coverage.out ./... 54 | coverage=`go tool cover -func coverage.out | grep total | tr -s '\t' | cut -f 3 | grep -o '[^%]*'` 55 | if (( $(echo "$coverage < 44" | bc) )); then echo "Test coverage lowered"; exit 1; fi 56 | 57 | - name: Update PR Coverage 58 | uses: shogo82148/actions-goveralls@v1 59 | with: 60 | path-to-profile: coverage.out 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ### Project Specific 3 | cmd/hexwidget_demo/hexwidget_demo 4 | cmd/portion_demo/portion_demo 5 | fyne-cross 6 | 7 | ### Tests 8 | **/testdata/failed 9 | 10 | ### Go 11 | # Output of the coverage tool 12 | *.out 13 | 14 | ### macOS 15 | # General 16 | .DS_Store 17 | 18 | # Thumbnails 19 | ._* 20 | 21 | ### JetBrains 22 | .idea 23 | 24 | ### VSCode 25 | .vscode 26 | 27 | ### Vim 28 | # Swap 29 | [._]*.s[a-v][a-z] 30 | [._]*.sw[a-p] 31 | [._]s[a-v][a-z] 32 | [._]sw[a-p] 33 | 34 | # Session 35 | Session.vim 36 | 37 | # Temporary 38 | .netrwhist 39 | *~ 40 | # Auto-generated tag files 41 | tags 42 | # Persistent undo 43 | [._]*.un~ 44 | *.exe 45 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Diagram Widget: Charles Daniels and Paul C. Brown -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at info@fyne.io. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (C) 2020 Fyne.io developers and community (see AUTHORS) 4 | All rights reserved. 5 | 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | * Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | * Neither the name of Fyne.io nor the names of its contributors may be 15 | used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY 22 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 25 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /cmd/calendar_demo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/app" 8 | "fyne.io/fyne/v2/container" 9 | "fyne.io/fyne/v2/widget" 10 | xwidget "fyne.io/x/fyne/widget" 11 | ) 12 | 13 | func main() { 14 | a := app.New() 15 | w := a.NewWindow("Calendar") 16 | 17 | i := widget.NewLabel("Please Choose a Date") 18 | i.Alignment = fyne.TextAlignCenter 19 | l := widget.NewLabel("") 20 | l.Alignment = fyne.TextAlignCenter 21 | d := &date{instruction: i, dateChosen: l} 22 | 23 | // Defines which date you would like the calendar to start 24 | startingDate := time.Now() 25 | calendar := xwidget.NewCalendar(startingDate, d.onSelected) 26 | 27 | c := container.NewVBox(i, l, calendar) 28 | 29 | w.SetContent(c) 30 | w.ShowAndRun() 31 | } 32 | 33 | type date struct { 34 | instruction *widget.Label 35 | dateChosen *widget.Label 36 | } 37 | 38 | func (d *date) onSelected(t time.Time) { 39 | // use time object to set text on label with given format 40 | d.instruction.SetText("Date Selected:") 41 | d.dateChosen.SetText(t.Format("Mon 02 Jan 2006")) 42 | } 43 | -------------------------------------------------------------------------------- /cmd/diagramdemo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "image/color" 6 | "time" 7 | 8 | "fyne.io/x/fyne/widget/diagramwidget" 9 | 10 | "fyne.io/fyne/v2" 11 | "fyne.io/fyne/v2/app" 12 | "fyne.io/fyne/v2/container" 13 | "fyne.io/fyne/v2/widget" 14 | ) 15 | 16 | var forceticks int = 0 17 | 18 | func forceanim(diagramWidget *diagramwidget.DiagramWidget) { 19 | 20 | for { 21 | if forceticks > 0 { 22 | fyne.Do(func() { 23 | diagramwidget.StepForceLayout(diagramWidget, 300) 24 | diagramWidget.Refresh() 25 | }) 26 | forceticks-- 27 | } 28 | 29 | time.Sleep(time.Millisecond * (1000 / 30)) 30 | } 31 | } 32 | 33 | func main() { 34 | app := app.New() 35 | w := app.NewWindow("Diagram Demo") 36 | 37 | w.SetMaster() 38 | 39 | diagramWidget := diagramwidget.NewDiagramWidget("Diagram1") 40 | 41 | scrollContainer := container.NewScroll(diagramWidget) 42 | 43 | go forceanim(diagramWidget) 44 | 45 | // Node 0 46 | node0Label := widget.NewLabel("Node0") 47 | node0 := diagramwidget.NewDiagramNode(diagramWidget, node0Label, "Node0") 48 | node0.Move(fyne.NewPos(300, 0)) 49 | 50 | // Node 1 51 | node1Button := widget.NewButton("Node1 Button", func() { fmt.Printf("tapped Node1!\n") }) 52 | node1 := diagramwidget.NewDiagramNode(diagramWidget, node1Button, "Node1") 53 | node1.Move(fyne.Position{X: 100, Y: 100}) 54 | 55 | // Node 2 56 | node2 := diagramwidget.NewDiagramNode(diagramWidget, nil, "Node2") 57 | node2Container := container.NewVBox( 58 | widget.NewLabel("Node2 - with structure"), 59 | widget.NewButton("Up", func() { 60 | node2.GetDiagram().DisplaceNode(node2, fyne.Position{X: 0, Y: -10}) 61 | node2.Refresh() 62 | }), 63 | widget.NewButton("Down", func() { 64 | node2.GetDiagram().DisplaceNode(node2, fyne.Position{X: 0, Y: 10}) 65 | node2.Refresh() 66 | }), 67 | container.NewHBox( 68 | widget.NewButton("Left", func() { 69 | node2.GetDiagram().DisplaceNode(node2, fyne.Position{X: -10, Y: 0}) 70 | node2.Refresh() 71 | }), 72 | widget.NewButton("Right", func() { 73 | node2.GetDiagram().DisplaceNode(node2, fyne.Position{X: 10, Y: 0}) 74 | node2.Refresh() 75 | }), 76 | ), 77 | ) 78 | node2.SetInnerObject(node2Container) 79 | node2.Move(fyne.Position{X: 100, Y: 300}) 80 | 81 | // Node 3 82 | node3 := diagramwidget.NewDiagramNode(diagramWidget, widget.NewButton("Node3: Force layout step", func() { 83 | diagramwidget.StepForceLayout(diagramWidget, 300) 84 | diagramWidget.Refresh() 85 | }), "Node3") 86 | node3.Move(fyne.Position{X: 400, Y: 100}) 87 | 88 | // Node 4 89 | node4 := diagramwidget.NewDiagramNode(diagramWidget, widget.NewButton("Node4: auto layout", func() { 90 | forceticks += 100 91 | diagramWidget.Refresh() 92 | }), "Node4") 93 | node4.Move(fyne.Position{X: 400, Y: 400}) 94 | 95 | node5 := diagramwidget.NewDiagramNode(diagramWidget, widget.NewLabel("Node5"), "Node5") 96 | node5.Move(fyne.NewPos(600, 200)) 97 | 98 | // Link0 99 | link0 := diagramwidget.NewDiagramLink(diagramWidget, "Link0") 100 | link0.SetSourcePad(node0.GetEdgePad()) 101 | link0.SetTargetPad(node1.GetEdgePad()) 102 | link0.AddSourceAnchoredText("sourceRole", "sourceRole") 103 | link0.AddMidpointAnchoredText("linkName", "Link 0") 104 | solidDiamond := createDiamondDecoration() 105 | solidDiamond.SetSolid(true) 106 | link0.AddSourceDecoration(solidDiamond) 107 | 108 | // Link1 109 | link1 := diagramwidget.NewDiagramLink(diagramWidget, "Link1") 110 | link1.SetSourcePad(node2.GetEdgePad()) 111 | link1.SetTargetPad(node1.GetEdgePad()) 112 | link1.SetForegroundColor(color.RGBA{255, 64, 64, 255}) 113 | link1.AddTargetDecoration(diagramwidget.NewArrowhead()) 114 | link1.AddTargetDecoration(diagramwidget.NewArrowhead()) 115 | link1.AddMidpointDecoration(diagramwidget.NewArrowhead()) 116 | link1.AddMidpointDecoration(diagramwidget.NewArrowhead()) 117 | link1.AddMidpointAnchoredText("linkName", "Link 1") 118 | link1.AddSourceDecoration(diagramwidget.NewArrowhead()) 119 | link1.AddSourceDecoration(diagramwidget.NewArrowhead()) 120 | 121 | // Link2 122 | link2 := diagramwidget.NewDiagramLink(diagramWidget, "Link2") 123 | link2.SetSourcePad(node0.GetEdgePad()) 124 | link2.SetTargetPad(node3.GetEdgePad()) 125 | link2.AddMidpointAnchoredText("linkName", "Link 2") 126 | link2.AddSourceDecoration(createHalfArrowDecoration()) 127 | 128 | // Link3 129 | link3 := diagramwidget.NewDiagramLink(diagramWidget, "Link3") 130 | link3.SetSourcePad(node2.GetEdgePad()) 131 | link3.SetTargetPad(node3.GetEdgePad()) 132 | link3.AddSourceAnchoredText("sourceRole", "sourceRole") 133 | link3.AddMidpointAnchoredText("linkName", "Link 3") 134 | link3.AddTargetAnchoredText("targetRole", "targetRole") 135 | link3.AddMidpointDecoration(createTriangleDecoration()) 136 | 137 | // Link4 138 | link4 := diagramwidget.NewDiagramLink(diagramWidget, "Link4") 139 | link4.SetSourcePad(node4.GetEdgePad()) 140 | link4.SetTargetPad(node3.GetEdgePad()) 141 | link4.AddMidpointAnchoredText("linkName", "Link 4") 142 | 143 | // Link5 144 | link5 := diagramwidget.NewDiagramLink(diagramWidget, "Link5") 145 | link5.SetSourcePad(link4.GetMidPad()) 146 | link5.SetTargetPad(node5.GetEdgePad()) 147 | link5.AddMidpointAnchoredText("linkName", "Link 5") 148 | link5.AddTargetDecoration(diagramwidget.NewArrowhead()) 149 | 150 | w.SetContent(scrollContainer) 151 | 152 | w.Resize(fyne.NewSize(600, 400)) 153 | w.ShowAndRun() 154 | } 155 | 156 | func createTriangleDecoration() diagramwidget.Decoration { 157 | points := []fyne.Position{ 158 | {X: 0, Y: 15}, 159 | {X: 15, Y: 0}, 160 | {X: 0, Y: -15}, 161 | } 162 | polygon := diagramwidget.NewPolygon(points) 163 | return polygon 164 | } 165 | 166 | func createDiamondDecoration() diagramwidget.Decoration { 167 | points := []fyne.Position{ 168 | {X: 0, Y: 0}, 169 | {X: 8, Y: 4}, 170 | {X: 16, Y: 0}, 171 | {X: 8, Y: -4}, 172 | } 173 | polygon := diagramwidget.NewPolygon(points) 174 | return polygon 175 | } 176 | 177 | func createHalfArrowDecoration() diagramwidget.Decoration { 178 | points := []fyne.Position{ 179 | {X: 0, Y: 0}, 180 | {X: 16, Y: 8}, 181 | {X: 16, Y: 0}, 182 | } 183 | polygon := diagramwidget.NewPolygon(points) 184 | return polygon 185 | } 186 | -------------------------------------------------------------------------------- /cmd/fyne-x/FyneApp.toml: -------------------------------------------------------------------------------- 1 | [Details] 2 | Icon = "Icon.png" 3 | Version = "0.1.0" 4 | Build = 5 5 | -------------------------------------------------------------------------------- /cmd/fyne-x/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/cmd/fyne-x/Icon.png -------------------------------------------------------------------------------- /cmd/fyne-x/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/url" 5 | 6 | "fyne.io/fyne/v2/app" 7 | "fyne.io/fyne/v2/container" 8 | "fyne.io/fyne/v2/widget" 9 | "fyne.io/x/fyne/dialog" 10 | ) 11 | 12 | func main() { 13 | a := app.New() 14 | w := a.NewWindow("Fyne-x demo") 15 | 16 | docURL, _ := url.Parse("https://docs.fyne.io") 17 | links := []*widget.Hyperlink{ 18 | widget.NewHyperlink("Docs", docURL), 19 | } 20 | w.SetContent(container.NewGridWithColumns(1, 21 | widget.NewButton("About", func() { 22 | dialog.ShowAbout("Some **cool** stuff", links, a, w) 23 | }), 24 | widget.NewButton("About window", func() { 25 | dialog.ShowAboutWindow("Some **cool** stuff", links, a) 26 | }), 27 | )) 28 | 29 | w.ShowAndRun() 30 | } 31 | -------------------------------------------------------------------------------- /cmd/hexwidget_demo/README.md: -------------------------------------------------------------------------------- 1 | # Hexwidget Demo 2 | 3 | This [demo program](./main.go) shows how to use the [Fyne-X 4 | hexwidget](../../widget/hexwidget). 5 | -------------------------------------------------------------------------------- /cmd/hexwidget_demo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fyne.io/fyne/v2/app" 5 | "fyne.io/fyne/v2/container" 6 | "fyne.io/fyne/v2/dialog" 7 | "fyne.io/fyne/v2/widget" 8 | 9 | xwidget "fyne.io/x/fyne/widget" 10 | 11 | "image/color" 12 | "strconv" 13 | ) 14 | 15 | func main() { 16 | app := app.New() 17 | 18 | h1 := xwidget.NewHexWidget() 19 | h2 := xwidget.NewHexWidget() 20 | h3 := xwidget.NewHexWidget() 21 | h4 := xwidget.NewHexWidget() 22 | h5 := xwidget.NewHexWidget() 23 | h6 := xwidget.NewHexWidget() 24 | h7 := xwidget.NewHexWidget() 25 | h8 := xwidget.NewHexWidget() 26 | 27 | hexes := []*xwidget.HexWidget{h1, h2, h3, h4, h5, h6, h7, h8} 28 | 29 | e := widget.NewEntry() 30 | e.PlaceHolder = "enter a 32-bit number" 31 | e.Validator = func(s string) error { 32 | _, err := strconv.Atoi(s) 33 | return err 34 | } 35 | 36 | b := widget.NewButton("update", func() { 37 | i, _ := strconv.Atoi(e.Text) 38 | u := uint(i) 39 | h1.Set((u & 0x0000000f) >> 0) 40 | h2.Set((u & 0x000000f0) >> 4) 41 | h3.Set((u & 0x00000f00) >> 8) 42 | h4.Set((u & 0x0000f000) >> 12) 43 | h5.Set((u & 0x000f0000) >> 16) 44 | h6.Set((u & 0x00f00000) >> 20) 45 | h7.Set((u & 0x0f000000) >> 24) 46 | h8.Set((u & 0xf0000000) >> 28) 47 | }, 48 | ) 49 | 50 | slantSlider := widget.NewSlider(0, 30) 51 | slantSlider.SetValue(10) 52 | slantSlider.OnChanged = func(v float64) { 53 | for _, w := range hexes { 54 | w.SetSlant(float32(v)) 55 | } 56 | } 57 | 58 | w := app.NewWindow("Hex Widget Demo") 59 | 60 | colorOnButton := widget.NewButton("change active color", func() { 61 | cd := dialog.NewColorPicker( 62 | "choose a new active color", 63 | "choose a new active color", 64 | func(c color.Color) { 65 | for _, w := range hexes { 66 | r, g, b, a := c.RGBA() 67 | w.SetOnColor(color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)}) 68 | } 69 | }, 70 | w) 71 | 72 | cd.Advanced = true 73 | cd.Show() 74 | }) 75 | 76 | colorOffButton := widget.NewButton("change inactive color", func() { 77 | cd := dialog.NewColorPicker( 78 | "choose a new inactive color", 79 | "choose a new inactive color", 80 | func(c color.Color) { 81 | for _, w := range hexes { 82 | r, g, b, a := c.RGBA() 83 | w.SetOffColor(color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)}) 84 | } 85 | }, 86 | w) 87 | 88 | cd.Advanced = true 89 | cd.Show() 90 | }) 91 | 92 | size := h1.MinSize() 93 | 94 | widthSlider := widget.NewSlider(10, 200) 95 | widthSlider.SetValue(float64(size.Width)) 96 | widthSlider.OnChanged = func(v float64) { 97 | size.Width = float32(v) 98 | for _, w := range hexes { 99 | w.SetSize(size) 100 | } 101 | } 102 | 103 | heightSlider := widget.NewSlider(10, 200) 104 | heightSlider.SetValue(float64(size.Height)) 105 | heightSlider.OnChanged = func(v float64) { 106 | size.Height = float32(v) 107 | for _, w := range hexes { 108 | w.SetSize(size) 109 | } 110 | } 111 | 112 | w.SetContent( 113 | container.NewVBox( 114 | container.NewHBox(h8, h7, h6, h5, h4, h3, h2, h1), 115 | container.NewAdaptiveGrid(2, e, b), 116 | container.NewAdaptiveGrid(2, widget.NewLabel("Slide to change hex slant:"), slantSlider), 117 | container.NewAdaptiveGrid(2, colorOnButton, colorOffButton), 118 | container.NewAdaptiveGrid(2, widget.NewLabel("Hex width"), widthSlider), 119 | container.NewAdaptiveGrid(2, widget.NewLabel("Hex height"), heightSlider), 120 | ), 121 | ) 122 | w.ShowAndRun() 123 | } 124 | -------------------------------------------------------------------------------- /cmd/map_demo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/app" 6 | 7 | xwidget "fyne.io/x/fyne/widget" 8 | ) 9 | 10 | func main() { 11 | w := app.New().NewWindow("Map Widget") 12 | 13 | m := xwidget.NewMapWithOptions( 14 | xwidget.WithOsmTiles(), 15 | xwidget.WithZoomButtons(true), 16 | xwidget.WithScrollButtons(true), 17 | ) 18 | m.ZoomIn() 19 | w.SetContent(m) 20 | 21 | w.SetPadded(false) 22 | w.Resize(fyne.NewSize(512, 320)) 23 | w.ShowAndRun() 24 | } 25 | -------------------------------------------------------------------------------- /cmd/portion_demo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fyne.io/fyne/v2/app" 5 | "fyne.io/fyne/v2/container" 6 | "fyne.io/fyne/v2/widget" 7 | "fyne.io/x/fyne/layout" 8 | ) 9 | 10 | func main() { 11 | a := app.New() 12 | w := a.NewWindow("Portions") 13 | 14 | long := widget.NewButton("A very long", nil) 15 | shorter := widget.NewButton("Short", nil) 16 | long2 := widget.NewButton("I am also long", nil) 17 | btn := widget.NewButton("123", nil) 18 | w.SetContent(container.New(layout.NewHPortion([]float64{30, 20, 30, 10}), long, shorter, long2, btn)) 19 | w.ShowAndRun() 20 | } 21 | -------------------------------------------------------------------------------- /cmd/responsive_layout/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "fyne.io/fyne/v2" 8 | "fyne.io/fyne/v2/app" 9 | "fyne.io/fyne/v2/container" 10 | "fyne.io/fyne/v2/dialog" 11 | "fyne.io/fyne/v2/widget" 12 | "fyne.io/x/fyne/layout" 13 | ) 14 | 15 | func main() { 16 | app := app.New() 17 | 18 | window := app.NewWindow("Responsive") 19 | window.Resize(fyne.Size{Width: 320, Height: 480}) 20 | 21 | // just a button 22 | button := widget.NewButton("Click me", func() { 23 | dialog.NewInformation("Hello", "Hello World", window).Show() 24 | }) 25 | 26 | resp := layout.NewResponsiveLayout( 27 | presentation(), // 100% by default 28 | winSizeLabel(window), // 100% by default 29 | layout.Responsive( 30 | widget.NewButton("One !", func() {}), 31 | 1, .33, 32 | ), 33 | layout.Responsive( 34 | widget.NewButton("Two !", func() {}), 35 | 1, .33, 36 | ), 37 | layout.Responsive( 38 | widget.NewButton("Three !", func() {}), 39 | 1, .34, 40 | ), 41 | layout.Responsive(fromLayout(), 1, .5), // 100% for small, 50% for others 42 | layout.Responsive(fromLayout(), 1, .5), // 100% for small, 50% for others 43 | button, // 100% by default 44 | ) 45 | 46 | window.SetContent( 47 | container.NewVScroll(resp), 48 | ) 49 | 50 | window.ShowAndRun() 51 | } 52 | 53 | // winSizeLabel returns a label with the current window size inside 54 | func winSizeLabel(window fyne.Window) fyne.CanvasObject { 55 | label := widget.NewLabel("") 56 | label.Wrapping = fyne.TextWrapWord 57 | label.Alignment = fyne.TextAlignCenter 58 | 59 | go func() { 60 | // when window is resized, the label will be updated 61 | time.Sleep(time.Millisecond * 1000) 62 | canvas := window.Canvas() 63 | for { 64 | time.Sleep(time.Millisecond * 100) 65 | 66 | fyne.Do(func() { 67 | newText := "" 68 | if canvas.Size().Width <= float32(layout.SMALL) { 69 | newText = fmt.Sprintf("Extra small devicce %v <= %v", canvas.Size().Width, layout.SMALL) 70 | } else if canvas.Size().Width <= float32(layout.MEDIUM) { 71 | newText = fmt.Sprintf("Small device %v <= %v", canvas.Size().Width, layout.MEDIUM) 72 | } else if canvas.Size().Width <= float32(layout.LARGE) { 73 | newText = fmt.Sprintf("Medium device %v <= %v", canvas.Size().Width, layout.LARGE) 74 | } else if canvas.Size().Width <= float32(layout.XLARGE) { 75 | newText = fmt.Sprintf("Large device %v <= %v", canvas.Size().Width, layout.XLARGE) 76 | } else { 77 | newText = fmt.Sprintf("Extra large device %v > %v", canvas.Size().Width, layout.LARGE) 78 | } 79 | 80 | label.SetText(newText) 81 | }) 82 | } 83 | }() 84 | 85 | return label 86 | } 87 | 88 | // presentation returns a container with a title text in bold / italic 89 | func presentation() fyne.CanvasObject { 90 | label := widget.NewLabel("Example of responsive layout") 91 | label.TextStyle = fyne.TextStyle{Bold: true, Italic: true} 92 | label.Alignment = fyne.TextAlignCenter 93 | return label 94 | } 95 | 96 | // fromLayout returns responsive layout where label and entries width ratios are set. 97 | // Each label will: 98 | // - be 100% width for small device 99 | // - be 25% for medium device 100 | // - be 33% for larger device 101 | // And to make entry to be adapted 102 | // - be 100% width for small device 103 | // - be 75% for medium device (100 - 25% from the label) 104 | // - be 67% for larger device (100 - 33% from the label) 105 | func fromLayout() fyne.CanvasObject { 106 | title := widget.NewLabel( 107 | "This container should be 100% width of small device and 50% for larger.\n" + 108 | "The labels are sized to 100% width for small devices, 25% for medium and 33% for larger") 109 | title.Alignment = fyne.TextAlignCenter 110 | title.Wrapping = fyne.TextWrapWord 111 | 112 | label := widget.NewLabel("Give your name") 113 | label.Wrapping = fyne.TextWrapWord 114 | entry := widget.NewEntry() 115 | label2 := widget.NewLabel("Give your age") 116 | label2.Wrapping = fyne.TextWrapWord 117 | entry2 := widget.NewEntry() 118 | label3 := widget.NewLabel("Give your email") 119 | label3.Wrapping = fyne.TextWrapWord 120 | entry3 := widget.NewEntry() 121 | 122 | labelw := float32(.25) 123 | entryw := float32(.75) 124 | labelx := 1 / float32(3) 125 | entryx := 1 - labelx 126 | return layout.NewResponsiveLayout( 127 | title, 128 | layout.Responsive(label, 1, 1, labelw, labelx), 129 | layout.Responsive(entry, 1, 1, entryw, entryx), 130 | layout.Responsive(label2, 1, 1, labelw, labelx), 131 | layout.Responsive(entry2, 1, 1, entryw, entryx), 132 | layout.Responsive(label3, 1, 1, labelw, labelx), 133 | layout.Responsive(entry3, 1, 1, entryw, entryx), 134 | ) 135 | } 136 | -------------------------------------------------------------------------------- /cmd/twostatetoolbaraction_demo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "fyne.io/fyne/v2/app" 7 | "fyne.io/fyne/v2/container" 8 | "fyne.io/fyne/v2/theme" 9 | "fyne.io/fyne/v2/widget" 10 | xwidget "fyne.io/x/fyne/widget" 11 | ) 12 | 13 | func main() { 14 | a := app.New() 15 | w := a.NewWindow("Two State Demo") 16 | 17 | twoState0 := xwidget.NewTwoStateToolbarAction(nil, 18 | nil, func(on bool) { 19 | fmt.Println(on) 20 | }) 21 | sep := widget.NewToolbarSeparator() 22 | tb := widget.NewToolbar(twoState0, sep) 23 | 24 | toggleButton := widget.NewButton("Toggle State", func() { 25 | on := twoState0.GetOn() 26 | twoState0.SetOn(!on) 27 | }) 28 | offIconButton := widget.NewButton("Set OffIcon", func() { 29 | twoState0.SetOffIcon(theme.MediaPlayIcon()) 30 | }) 31 | onIconButton := widget.NewButton("Set OnIcon", func() { 32 | twoState0.SetOnIcon(theme.MediaStopIcon()) 33 | }) 34 | vc := container.NewVBox(toggleButton, offIconButton, onIconButton) 35 | c := container.NewBorder(tb, vc, nil, nil) 36 | w.SetContent(c) 37 | w.ShowAndRun() 38 | } 39 | -------------------------------------------------------------------------------- /cmd/wrapper/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/app" 8 | "fyne.io/fyne/v2/container" 9 | "fyne.io/fyne/v2/dialog" 10 | "fyne.io/fyne/v2/driver/desktop" 11 | "fyne.io/fyne/v2/widget" 12 | "fyne.io/x/fyne/wrapper" 13 | ) 14 | 15 | func main() { 16 | app := app.New() 17 | win := app.NewWindow("Wrapper example") 18 | win.Resize(fyne.NewSize(400, 400)) 19 | 20 | label1 := widget.NewLabel("Label 1, not wrapped") 21 | label2 := widget.NewLabel("Label 2, click me") 22 | label3 := widget.NewLabel("Label 3, move mouse over me") 23 | positionLabel := widget.NewLabel("Informations will be displayed here") 24 | 25 | wrapped := wrapper.MakeTappable(label2, func(e *fyne.PointEvent) { 26 | dialog.ShowInformation("Tapped", "Label 1 was tapped", win) 27 | }) 28 | 29 | mousable := wrapper.MakeHoverable(label3, 30 | func(e *desktop.MouseEvent) { 31 | positionLabel.SetText("Mouse in") 32 | }, func(e *desktop.MouseEvent) { 33 | posx := strconv.FormatFloat(float64(e.Position.X), 'f', 2, 32) 34 | posy := strconv.FormatFloat(float64(e.Position.Y), 'f', 2, 32) 35 | positionLabel.SetText("Mouse moved at:" + posx + ", " + posy) 36 | }, func() { 37 | positionLabel.SetText("Mouse out") 38 | }, 39 | ) 40 | 41 | mainContainer := container.NewGridWithColumns(2, label1, wrapped, mousable, positionLabel) 42 | 43 | win.SetContent(mainContainer) 44 | win.ShowAndRun() 45 | } 46 | -------------------------------------------------------------------------------- /data/binding/closers.go: -------------------------------------------------------------------------------- 1 | // Package binding provides extended sources of data binding. 2 | package binding 3 | 4 | import ( 5 | "io" 6 | 7 | "fyne.io/fyne/v2/data/binding" 8 | ) 9 | 10 | // StringCloser is an extension of the String interface that allows resources to be freed 11 | // using the standard `Close()` method. 12 | type StringCloser interface { 13 | binding.String 14 | io.Closer 15 | } 16 | -------------------------------------------------------------------------------- /data/binding/json_test.go: -------------------------------------------------------------------------------- 1 | package binding_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "fyne.io/fyne/v2/data/binding" 8 | "fyne.io/fyne/v2/test" 9 | xbinding "fyne.io/x/fyne/data/binding" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func waitOnChan(t *testing.T, propagated chan bool) { 15 | select { 16 | case <-propagated: 17 | case <-time.After(1 * time.Second): 18 | assert.Fail(t, "The test should not have timedout") 19 | } 20 | } 21 | 22 | func NewListener(data binding.DataItem) chan bool { 23 | propagated := make(chan bool) 24 | listener := binding.NewDataListener(func() { 25 | go func() { 26 | propagated <- true 27 | }() 28 | }) 29 | data.AddListener(listener) 30 | <-propagated // ignore init callback 31 | 32 | return propagated 33 | } 34 | 35 | func TestJSONFromStringWithString(t *testing.T) { 36 | _ = test.NewTempApp(t) 37 | s := binding.NewString() 38 | 39 | assert.NotNil(t, s) 40 | 41 | json, err := xbinding.NewJSONFromString(s) 42 | 43 | assert.NoError(t, err) 44 | assert.NotNil(t, json) 45 | 46 | propagatedJSON := NewListener(json) 47 | 48 | childV, err := json.GetItemString("value") 49 | 50 | assert.NoError(t, err) 51 | assert.NotNil(t, childV) 52 | 53 | propagatedCV := NewListener(childV) 54 | 55 | childA, err := json.GetItemString("array", 0) 56 | 57 | assert.NoError(t, err) 58 | assert.NotNil(t, childA) 59 | 60 | propagatedCA := NewListener(childA) 61 | 62 | intChildV := binding.StringToInt(childV) 63 | 64 | assert.NotNil(t, intChildV) 65 | 66 | propagatedICV := NewListener(intChildV) 67 | 68 | assert.Equal(t, true, json.IsEmpty()) 69 | 70 | err = s.Set(`{ "value": "7", "array": [ "test" ] }`) 71 | 72 | assert.NoError(t, err) 73 | 74 | waitOnChan(t, propagatedJSON) 75 | waitOnChan(t, propagatedCV) 76 | waitOnChan(t, propagatedCA) 77 | waitOnChan(t, propagatedICV) 78 | 79 | assert.Equal(t, false, json.IsEmpty()) 80 | 81 | vs, err := childV.Get() 82 | 83 | assert.NoError(t, err) 84 | assert.NotNil(t, vs) 85 | assert.Equal(t, "7", vs) 86 | 87 | vs, err = childA.Get() 88 | 89 | assert.NoError(t, err) 90 | assert.NotNil(t, vs) 91 | assert.Equal(t, "test", vs) 92 | 93 | vi, err := intChildV.Get() 94 | 95 | assert.NoError(t, err) 96 | assert.NotNil(t, vi) 97 | assert.Equal(t, 7, vi) 98 | } 99 | 100 | func TestJSONFromStringWithFloat(t *testing.T) { 101 | s := binding.NewString() 102 | 103 | assert.NotNil(t, s) 104 | 105 | json, err := xbinding.NewJSONFromString(s) 106 | 107 | assert.NoError(t, err) 108 | assert.NotNil(t, json) 109 | 110 | propagatedJSON := NewListener(json) 111 | 112 | childV, err := json.GetItemFloat("value") 113 | 114 | assert.NoError(t, err) 115 | assert.NotNil(t, childV) 116 | 117 | propagatedCV := NewListener(childV) 118 | 119 | childA, err := json.GetItemFloat("array", 0) 120 | 121 | assert.NoError(t, err) 122 | assert.NotNil(t, childA) 123 | 124 | propagatedCA := NewListener(childA) 125 | 126 | err = s.Set(`{ "value": 7, "array": [ 42.8 ] }`) 127 | 128 | assert.NoError(t, err) 129 | 130 | waitOnChan(t, propagatedJSON) 131 | waitOnChan(t, propagatedCV) 132 | waitOnChan(t, propagatedCA) 133 | 134 | vs, err := childV.Get() 135 | 136 | assert.NoError(t, err) 137 | assert.Equal(t, 7.0, vs) 138 | 139 | vs, err = childA.Get() 140 | 141 | assert.NoError(t, err) 142 | assert.Equal(t, 42.8, vs) 143 | } 144 | 145 | func TestJSONFromStringWithInt(t *testing.T) { 146 | s := binding.NewString() 147 | 148 | assert.NotNil(t, s) 149 | 150 | json, err := xbinding.NewJSONFromString(s) 151 | 152 | assert.NoError(t, err) 153 | assert.NotNil(t, json) 154 | 155 | propagatedJSON := NewListener(json) 156 | 157 | childV, err := json.GetItemInt("value") 158 | 159 | assert.NoError(t, err) 160 | assert.NotNil(t, childV) 161 | 162 | propagatedCV := NewListener(childV) 163 | 164 | childA, err := json.GetItemInt("array", 0) 165 | 166 | assert.NoError(t, err) 167 | assert.NotNil(t, childA) 168 | 169 | propagatedCA := NewListener(childA) 170 | 171 | err = s.Set(`{ "value": 7, "array": [ 42 ] }`) 172 | 173 | assert.NoError(t, err) 174 | 175 | waitOnChan(t, propagatedJSON) 176 | waitOnChan(t, propagatedCV) 177 | waitOnChan(t, propagatedCA) 178 | 179 | vs, err := childV.Get() 180 | 181 | assert.NoError(t, err) 182 | assert.Equal(t, 7, vs) 183 | 184 | vs, err = childA.Get() 185 | 186 | assert.NoError(t, err) 187 | assert.Equal(t, 42, vs) 188 | } 189 | 190 | func TestJSONFromStringWithBool(t *testing.T) { 191 | s := binding.NewString() 192 | 193 | assert.NotNil(t, s) 194 | 195 | json, err := xbinding.NewJSONFromString(s) 196 | 197 | assert.NoError(t, err) 198 | assert.NotNil(t, json) 199 | 200 | propagatedJSON := NewListener(json) 201 | 202 | childV, err := json.GetItemBool("value") 203 | 204 | assert.NoError(t, err) 205 | assert.NotNil(t, childV) 206 | 207 | propagatedCV := NewListener(childV) 208 | 209 | childA, err := json.GetItemBool("array", 0) 210 | 211 | assert.NoError(t, err) 212 | assert.NotNil(t, childA) 213 | 214 | propagatedCA := NewListener(childA) 215 | 216 | err = s.Set(`{ "value": true, "array": [ true ] }`) 217 | 218 | assert.NoError(t, err) 219 | 220 | waitOnChan(t, propagatedJSON) 221 | waitOnChan(t, propagatedCV) 222 | waitOnChan(t, propagatedCA) 223 | 224 | vs, err := childV.Get() 225 | 226 | assert.NoError(t, err) 227 | assert.Equal(t, true, vs) 228 | 229 | vs, err = childA.Get() 230 | 231 | assert.NoError(t, err) 232 | assert.Equal(t, true, vs) 233 | } 234 | -------------------------------------------------------------------------------- /data/binding/mqttstring.go: -------------------------------------------------------------------------------- 1 | package binding 2 | 3 | import ( 4 | "fyne.io/fyne/v2/data/binding" 5 | 6 | mqtt "github.com/eclipse/paho.mqtt.golang" 7 | ) 8 | 9 | type mqttString struct { 10 | binding.String 11 | conn mqtt.Client 12 | topic string 13 | err error 14 | } 15 | 16 | // NewMqttString returns a `String` binding to a MQTT topic specified by combining a connected 17 | // mqtt.Client and a `topic`. 18 | // The resulting string will be set to the content of the latest message sent through the socket. 19 | // You should also call `Close()` on the binding once you are done to free the connection. 20 | func NewMqttString(conn mqtt.Client, topic string) (StringCloser, error) { 21 | ret := &mqttString{String: binding.NewString(), conn: conn, topic: topic} 22 | 23 | token := conn.Subscribe(topic, 1, func(c mqtt.Client, m mqtt.Message) { 24 | ret.String.Set(string(m.Payload())) 25 | }) 26 | 27 | token.Wait() 28 | 29 | if err := token.Error(); err != nil { 30 | return nil, err 31 | } 32 | 33 | return ret, nil 34 | } 35 | 36 | func (s *mqttString) Set(val string) error { 37 | token := s.conn.Publish(s.topic, 0, false, val) 38 | 39 | token.Wait() 40 | s.err = token.Error() 41 | return s.err 42 | } 43 | 44 | func (s *mqttString) Get() (string, error) { 45 | if err := s.err; err != nil { 46 | return "", err 47 | } 48 | 49 | return s.String.Get() 50 | } 51 | 52 | func (s *mqttString) Close() error { 53 | if s.conn == nil { 54 | return nil 55 | } 56 | 57 | s.conn.Unsubscribe(s.topic) 58 | s.conn = nil 59 | 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /data/binding/webstring.go: -------------------------------------------------------------------------------- 1 | package binding 2 | 3 | import ( 4 | "net/http" 5 | 6 | "fyne.io/fyne/v2/data/binding" 7 | 8 | "github.com/gorilla/websocket" 9 | ) 10 | 11 | type webSocketString struct { 12 | binding.String 13 | conn *websocket.Conn 14 | prev error 15 | } 16 | 17 | // NewWebSocketString returns a `String` binding to a web socket server specified as `url`. 18 | // The resulting string will be set to the content of the latest message sent through the socket. 19 | // You should also call `Close()` on the binding once you are done to free the connection. 20 | func NewWebSocketString(url string) (StringCloser, error) { 21 | conn, _, err := websocket.DefaultDialer.Dial(url, http.Header{}) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | ret := &webSocketString{String: binding.NewString(), conn: conn} 27 | go ret.readMessages() 28 | return ret, nil 29 | } 30 | 31 | func (s *webSocketString) Close() error { 32 | if s.conn == nil { 33 | return nil 34 | } 35 | 36 | return s.conn.Close() 37 | } 38 | 39 | func (s *webSocketString) Get() (string, error) { 40 | if err := s.prev; err != nil { 41 | return "", err 42 | } 43 | 44 | return s.String.Get() 45 | } 46 | 47 | func (s *webSocketString) readMessages() { 48 | for { 49 | _, p, err := s.conn.ReadMessage() 50 | s.prev = err // if no error we clear the state 51 | if err != nil { // permanent (could be connection closed) 52 | return 53 | } 54 | 55 | _ = s.Set(string(p)) // we control s, Set will not error 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /data/validation/password.go: -------------------------------------------------------------------------------- 1 | // Package validation provides validation for data inside widgets 2 | package validation // import "fyne.io/x/fyne/data/validation" 3 | 4 | import ( 5 | "fyne.io/fyne/v2" 6 | gpv "github.com/wagslane/go-password-validator" 7 | ) 8 | 9 | // NewPassword returns a new validator for validating passwords. 10 | // Validate returns nil if the password entropy is greater than or equal 11 | // to the minimum entropy. If not, an error is returned that explains 12 | // how the password can be strengthened. Advice on entropy value: 13 | // https://github.com/wagslane/go-password-validator/tree/main#what-entropy-value-should-i-use 14 | func NewPassword(minEntropy float64) fyne.StringValidator { 15 | return func(text string) error { 16 | return gpv.Validate(text, minEntropy) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /data/validation/password_test.go: -------------------------------------------------------------------------------- 1 | package validation_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "fyne.io/x/fyne/data/validation" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestPassword(t *testing.T) { 12 | pw := validation.NewPassword(100) 13 | 14 | assert.NoError(t, pw("5 horses Ran around")) 15 | assert.Error(t, pw("bad-password")) 16 | 17 | pw = validation.NewPassword(150) 18 | 19 | assert.NoError(t, pw("7-BreaD-Crumbs.^_SpeciaL")) 20 | assert.Error(t, pw("12345--12345")) 21 | } 22 | -------------------------------------------------------------------------------- /dialog/about.go: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | import ( 4 | "image/color" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/canvas" 8 | "fyne.io/fyne/v2/container" 9 | "fyne.io/fyne/v2/dialog" 10 | "fyne.io/fyne/v2/layout" 11 | "fyne.io/fyne/v2/theme" 12 | "fyne.io/fyne/v2/widget" 13 | ) 14 | 15 | // NewAbout creates a parallax about dialog using the app metadata along with the 16 | // markdown content and links passed into this method. 17 | // You should call Show on the returned dialog to display it. 18 | func NewAbout(content string, links []*widget.Hyperlink, a fyne.App, w fyne.Window) dialog.Dialog { 19 | d := dialog.NewCustom("About", "OK", aboutContent(content, links, a), w) 20 | d.Resize(fyne.NewSize(400, 360)) 21 | 22 | return d 23 | } 24 | 25 | // NewAboutWindow creates a parallax about window using the app metadata along with the 26 | // markdown content and links passed into this method. 27 | // You should call Show on the returned window to display it. 28 | func NewAboutWindow(content string, links []*widget.Hyperlink, a fyne.App) fyne.Window { 29 | w := a.NewWindow("About") 30 | w.SetContent(aboutContent(content, links, a)) 31 | w.Resize(fyne.NewSize(360, 300)) 32 | 33 | return w 34 | } 35 | 36 | // ShowAbout opens a parallax about dialog using the app metadata along with the 37 | // markdown content and links passed into this method. 38 | func ShowAbout(content string, links []*widget.Hyperlink, a fyne.App, w fyne.Window) { 39 | d := NewAbout(content, links, a, w) 40 | d.Show() 41 | } 42 | 43 | // ShowAboutWindow opens a parallax about window using the app metadata along with the 44 | // markdown content and links passed into this method. 45 | func ShowAboutWindow(content string, links []*widget.Hyperlink, a fyne.App) { 46 | w := NewAboutWindow(content, links, a) 47 | w.Show() 48 | } 49 | 50 | func aboutContent(content string, links []*widget.Hyperlink, a fyne.App) fyne.CanvasObject { 51 | rich := widget.NewRichTextFromMarkdown(content) 52 | footer := aboutFooter(links) 53 | 54 | logo := canvas.NewImageFromResource(a.Metadata().Icon) 55 | logo.FillMode = canvas.ImageFillContain 56 | logo.SetMinSize(fyne.NewSize(128, 128)) 57 | 58 | appData := widget.NewRichTextFromMarkdown( 59 | "## " + a.Metadata().Name + "\n**Version:** " + a.Metadata().Version) 60 | centerText(appData) 61 | space := canvas.NewRectangle(color.Transparent) 62 | space.SetMinSize(fyne.NewSquareSize(theme.Padding() * 4)) 63 | body := container.NewVBox( 64 | space, 65 | logo, 66 | appData, 67 | container.NewCenter(rich)) 68 | scroll := container.NewScroll(body) 69 | 70 | bgColor := withAlpha(theme.Color(theme.ColorNameBackground), 0xe0) 71 | shadowColor := withAlpha(theme.Color(theme.ColorNameBackground), 0x33) 72 | 73 | underlay := canvas.NewImageFromResource(a.Metadata().Icon) 74 | bg := canvas.NewRectangle(bgColor) 75 | underlayer := underLayout{} 76 | slideBG := container.New(underlayer, underlay) 77 | footerBG := canvas.NewRectangle(shadowColor) 78 | watchTheme(bg, footerBG) 79 | 80 | underlay.Resize(fyne.NewSize(512, 512)) 81 | scroll.OnScrolled = func(p fyne.Position) { 82 | underlayer.offset = -p.Y / 3 83 | underlayer.Layout(slideBG.Objects, slideBG.Size()) 84 | } 85 | 86 | bgClip := container.NewScroll(slideBG) 87 | bgClip.Direction = container.ScrollNone 88 | return container.NewStack(container.New(unpad{top: true}, bgClip, bg), 89 | container.NewBorder(nil, 90 | container.NewStack(footerBG, footer), nil, nil, 91 | container.New(unpad{top: true, bottom: true}, scroll))) 92 | } 93 | 94 | func aboutFooter(links []*widget.Hyperlink) fyne.CanvasObject { 95 | footer := container.NewHBox(layout.NewSpacer()) 96 | for i, a := range links { 97 | footer.Add(a) 98 | if i < len(links)-1 { 99 | footer.Add(widget.NewLabel("-")) 100 | } 101 | } 102 | footer.Add(layout.NewSpacer()) 103 | 104 | return footer 105 | } 106 | 107 | func centerText(rich *widget.RichText) { 108 | for _, s := range rich.Segments { 109 | if text, ok := s.(*widget.TextSegment); ok { 110 | text.Style.Alignment = fyne.TextAlignCenter 111 | } 112 | } 113 | } 114 | 115 | func watchTheme(bg, footer *canvas.Rectangle) { 116 | fyne.CurrentApp().Settings().AddListener(func(_ fyne.Settings) { 117 | bgColor := withAlpha(theme.Color(theme.ColorNameBackground), 0xe0) 118 | bg.FillColor = bgColor 119 | bg.Refresh() 120 | 121 | shadowColor := withAlpha(theme.Color(theme.ColorNameBackground), 0x33) 122 | footer.FillColor = shadowColor 123 | footer.Refresh() 124 | }) 125 | } 126 | 127 | func withAlpha(c color.Color, alpha uint8) color.Color { 128 | r, g, b, _ := c.RGBA() 129 | return color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: alpha} 130 | } 131 | 132 | type underLayout struct { 133 | offset float32 134 | } 135 | 136 | func (u underLayout) Layout(objs []fyne.CanvasObject, size fyne.Size) { 137 | under := objs[0] 138 | left := size.Width/2 - under.Size().Width/2 139 | under.Move(fyne.NewPos(left, u.offset-50)) 140 | } 141 | 142 | func (u underLayout) MinSize(_ []fyne.CanvasObject) fyne.Size { 143 | return fyne.Size{} 144 | } 145 | 146 | type unpad struct { 147 | top, bottom bool 148 | } 149 | 150 | func (u unpad) Layout(objs []fyne.CanvasObject, s fyne.Size) { 151 | pad := theme.Padding() 152 | var pos fyne.Position 153 | if u.top { 154 | pos.Y = -pad 155 | } 156 | size := s 157 | if u.top { 158 | size.Height += pad 159 | } 160 | if u.bottom { 161 | size.Height += pad 162 | } 163 | for _, o := range objs { 164 | o.Move(pos) 165 | o.Resize(size) 166 | } 167 | } 168 | 169 | func (u unpad) MinSize(_ []fyne.CanvasObject) fyne.Size { 170 | return fyne.NewSize(100, 100) 171 | } 172 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module fyne.io/x/fyne 2 | 3 | go 1.19 4 | 5 | require ( 6 | fyne.io/fyne/v2 v2.6.0 7 | github.com/Andrew-M-C/go.jsonvalue v1.4.1 8 | github.com/eclipse/paho.mqtt.golang v1.3.5 9 | github.com/gorilla/websocket v1.5.3 10 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 11 | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 12 | github.com/stretchr/testify v1.10.0 13 | github.com/twpayne/go-geom v1.0.0 14 | github.com/wagslane/go-password-validator v0.3.0 15 | golang.org/x/image v0.24.0 16 | ) 17 | 18 | require ( 19 | fyne.io/systray v1.11.0 // indirect 20 | github.com/BurntSushi/toml v1.4.0 // indirect 21 | github.com/davecgh/go-spew v1.1.1 // indirect 22 | github.com/fredbi/uri v1.1.0 // indirect 23 | github.com/fsnotify/fsnotify v1.7.0 // indirect 24 | github.com/fyne-io/gl-js v0.1.0 // indirect 25 | github.com/fyne-io/glfw-js v0.2.0 // indirect 26 | github.com/fyne-io/image v0.1.1 // indirect 27 | github.com/fyne-io/oksvg v0.1.0 // indirect 28 | github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect 29 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect 30 | github.com/go-text/render v0.2.0 // indirect 31 | github.com/go-text/typesetting v0.2.1 // indirect 32 | github.com/godbus/dbus/v5 v5.1.0 // indirect 33 | github.com/gopherjs/gopherjs v1.17.2 // indirect 34 | github.com/hack-pad/go-indexeddb v0.3.2 // indirect 35 | github.com/hack-pad/safejs v0.1.0 // indirect 36 | github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect 37 | github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect 38 | github.com/kr/text v0.2.0 // indirect 39 | github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect 40 | github.com/pmezard/go-difflib v1.0.0 // indirect 41 | github.com/rymdport/portal v0.4.1 // indirect 42 | github.com/shopspring/decimal v1.4.0 // indirect 43 | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect 44 | github.com/yuin/goldmark v1.7.8 // indirect 45 | golang.org/x/net v0.35.0 // indirect 46 | golang.org/x/sys v0.30.0 // indirect 47 | golang.org/x/text v0.22.0 // indirect 48 | gopkg.in/yaml.v3 v3.0.1 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /img/about-dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/img/about-dialog.png -------------------------------------------------------------------------------- /img/about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/img/about.png -------------------------------------------------------------------------------- /img/adwaita-theme-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/img/adwaita-theme-dark.png -------------------------------------------------------------------------------- /img/adwaita-theme-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/img/adwaita-theme-light.png -------------------------------------------------------------------------------- /img/diagramdemo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/img/diagramdemo.png -------------------------------------------------------------------------------- /img/gifwidget.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/img/gifwidget.gif -------------------------------------------------------------------------------- /img/hexwidget_00abcdef.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/img/hexwidget_00abcdef.png -------------------------------------------------------------------------------- /img/hexwidget_12345678.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/img/hexwidget_12345678.png -------------------------------------------------------------------------------- /img/map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/img/map.png -------------------------------------------------------------------------------- /img/responsive-layout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/img/responsive-layout.png -------------------------------------------------------------------------------- /img/widget-completion-entry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/img/widget-completion-entry.png -------------------------------------------------------------------------------- /img/widget-filetree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/img/widget-filetree.png -------------------------------------------------------------------------------- /layout/layout.go: -------------------------------------------------------------------------------- 1 | // Package layout contains community extensions for Fyne layouts 2 | package layout // import "fyne.io/x/fyne/layout" 3 | -------------------------------------------------------------------------------- /layout/portion.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "log" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/theme" 8 | ) 9 | 10 | var _ fyne.Layout = (*HPortion)(nil) 11 | 12 | // HPortion allows the canvas objects to be divided into portions of the width. 13 | // The length of the Portions slice needs to be equal to the amount of canvas objects. 14 | type HPortion struct { 15 | Portions []float64 16 | } 17 | 18 | // Layout sets the size and position of the canvas objects. 19 | func (p *HPortion) Layout(objects []fyne.CanvasObject, size fyne.Size) { 20 | if len(p.Portions) != len(objects) { 21 | log.Println("Mismatch between partitions and objects") 22 | return 23 | } 24 | 25 | sum := float64(0) 26 | for _, child := range p.Portions { 27 | sum += float64(child) 28 | } 29 | 30 | padding := theme.Padding() 31 | width := size.Width - padding*float32(len(objects)-1) 32 | xpos := float32(0) 33 | 34 | for i, child := range objects { 35 | width := float32(p.Portions[i]/sum) * width 36 | child.Resize(fyne.NewSize(width, size.Height)) 37 | child.Move(fyne.NewPos(xpos, 0)) 38 | 39 | xpos += width + padding 40 | } 41 | } 42 | 43 | // MinSize calculates the minimum required size to fit all objects. 44 | // It is equal to the largest width MinSize divided by the corresponding portion. 45 | func (p *HPortion) MinSize(objects []fyne.CanvasObject) fyne.Size { 46 | if len(p.Portions) != len(objects) { 47 | log.Println("Mismatch between partitions and objects") 48 | return fyne.NewSize(0, 0) 49 | } 50 | 51 | if len(objects) == 0 { 52 | return fyne.NewSize(0, 0) 53 | } 54 | 55 | sum := float64(0) 56 | for _, child := range p.Portions { 57 | sum += float64(child) 58 | } 59 | 60 | maxMinWidth := float32(0) 61 | maxIndex := -1 62 | height := float32(0) 63 | 64 | for i := 0; i < len(objects); i++ { 65 | min := objects[i].MinSize() 66 | height = fyne.Max(height, min.Height) 67 | 68 | if min.Width > maxMinWidth { 69 | maxMinWidth = min.Width 70 | maxIndex = i 71 | } 72 | } 73 | 74 | totalPadding := float32(len(objects)-1) * theme.Padding() 75 | return fyne.NewSize(maxMinWidth/float32(p.Portions[maxIndex]/sum)+totalPadding, height) 76 | } 77 | 78 | // NewHPortion creates a layout that partitions objects horizontally taking up 79 | // as large of a portion of the space as defined by the given slice. 80 | // The length of the Portions slice needs to be equal to the amount of objects. 81 | func NewHPortion(Portions []float64) *HPortion { 82 | return &HPortion{Portions: Portions} 83 | } 84 | 85 | var _ fyne.Layout = (*VPortion)(nil) 86 | 87 | // VPortion allows the canvas objects to be divided into portions of the height. 88 | // The length of the Portions slice needs to be equal to the amount of canvas objects. 89 | type VPortion struct { 90 | Portions []float64 91 | } 92 | 93 | // Layout sets the size and position of the canvas objects. 94 | func (p *VPortion) Layout(objects []fyne.CanvasObject, size fyne.Size) { 95 | if len(p.Portions) != len(objects) { 96 | log.Println("Mismatch between partitions and objects") 97 | return 98 | } 99 | 100 | sum := float64(0) 101 | for _, child := range p.Portions { 102 | sum += float64(child) 103 | } 104 | 105 | padding := theme.Padding() 106 | height := size.Width - padding*float32(len(objects)-1) 107 | ypos := float32(0) 108 | 109 | for i, child := range objects { 110 | height := float32(p.Portions[i]/sum) * height 111 | child.Resize(fyne.NewSize(ypos, height)) 112 | child.Move(fyne.NewPos(ypos, 0)) 113 | 114 | ypos += height + padding 115 | } 116 | } 117 | 118 | // MinSize calculates the minimum required size to fit all objects. 119 | // It is equal to the largest height MinSize divided by the corresponding portion. 120 | func (p *VPortion) MinSize(objects []fyne.CanvasObject) fyne.Size { 121 | if len(p.Portions) != len(objects) { 122 | log.Println("Mismatch between partitions and objects") 123 | return fyne.NewSize(0, 0) 124 | } 125 | 126 | if len(objects) == 0 { 127 | return fyne.NewSize(0, 0) 128 | } 129 | 130 | sum := float64(0) 131 | for _, child := range p.Portions { 132 | sum += float64(child) 133 | } 134 | 135 | maxMinHeight := float32(0) 136 | maxIndex := -1 137 | width := float32(0) 138 | 139 | for i := 0; i < len(objects); i++ { 140 | min := objects[i].MinSize() 141 | width = fyne.Max(width, min.Width) 142 | 143 | if min.Height > maxMinHeight { 144 | maxMinHeight = min.Height 145 | maxIndex = i 146 | } 147 | } 148 | 149 | totalPadding := float32(len(objects)-1) * theme.Padding() 150 | return fyne.NewSize(width, maxMinHeight/float32(p.Portions[maxIndex]/sum)+totalPadding) 151 | } 152 | 153 | // NewVPortion creates a layout that partitions objects verticaly taking up 154 | // as large of a portion of the space as defined by the given slice. 155 | // The length of the Portions slice needs to be equal to the amount of objects. 156 | func NewVPortion(portion []float64) *VPortion { 157 | return &VPortion{Portions: portion} 158 | } 159 | -------------------------------------------------------------------------------- /layout/portion_test.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "testing" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/container" 8 | "fyne.io/fyne/v2/theme" 9 | "fyne.io/fyne/v2/widget" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestHPortion(t *testing.T) { 14 | cont := container.New(&HPortion{Portions: []float64{50, 50}}, widget.NewEntry(), widget.NewEntry()) 15 | cont.Resize(fyne.NewSize(100, 100)) 16 | 17 | assert.Equal(t, (100-theme.Padding())/2, cont.Objects[0].Size().Width) 18 | assert.Equal(t, (100-theme.Padding())/2, cont.Objects[1].Size().Width) 19 | 20 | assert.Equal(t, cont.Objects[0].MinSize().Height, cont.MinSize().Height) 21 | assert.Equal(t, cont.Objects[1].MinSize().Height, cont.MinSize().Height) 22 | 23 | // Using 0.5 and 0.5 should be the same as 50 and 50. 24 | cont.Layout = NewHPortion([]float64{0.5, 0.5}) 25 | 26 | assert.Equal(t, (100-theme.Padding())/2, cont.Objects[0].Size().Width) 27 | assert.Equal(t, (100-theme.Padding())/2, cont.Objects[1].Size().Width) 28 | 29 | assert.Equal(t, cont.Objects[0].MinSize().Height, cont.MinSize().Height) 30 | assert.Equal(t, cont.Objects[1].MinSize().Height, cont.MinSize().Height) 31 | 32 | // Mismatch in length should error out. 33 | cont.Layout = NewHPortion([]float64{}) 34 | cont.Resize(fyne.NewSize(50, 50)) 35 | assert.Equal(t, fyne.NewSize(0, 0), cont.MinSize()) 36 | 37 | // Having no objects should result in zero size. 38 | cont.Objects = nil 39 | cont.Resize(fyne.NewSize(100, 100)) 40 | assert.Equal(t, fyne.NewSize(0, 0), cont.MinSize()) 41 | } 42 | 43 | func TestVPortion(t *testing.T) { 44 | cont := container.New(&VPortion{Portions: []float64{50, 50}}, widget.NewEntry(), widget.NewEntry()) 45 | cont.Resize(fyne.NewSize(100, 100)) 46 | 47 | assert.Equal(t, (100-theme.Padding())/2, cont.Objects[0].Size().Height) 48 | assert.Equal(t, (100-theme.Padding())/2, cont.Objects[1].Size().Height) 49 | 50 | assert.Equal(t, cont.Objects[0].MinSize().Width, cont.MinSize().Width) 51 | assert.Equal(t, cont.Objects[1].MinSize().Width, cont.MinSize().Width) 52 | 53 | // Using 0.5 and 0.5 should be the same as 50 and 50. 54 | cont.Layout = NewVPortion([]float64{0.5, 0.5}) 55 | 56 | assert.Equal(t, (100-theme.Padding())/2, cont.Objects[0].Size().Height) 57 | assert.Equal(t, (100-theme.Padding())/2, cont.Objects[1].Size().Height) 58 | 59 | assert.Equal(t, cont.Objects[0].MinSize().Width, cont.MinSize().Width) 60 | assert.Equal(t, cont.Objects[1].MinSize().Width, cont.MinSize().Width) 61 | 62 | // Mismatch in length should error out. 63 | cont.Layout = NewVPortion([]float64{}) 64 | cont.Resize(fyne.NewSize(50, 50)) 65 | assert.Equal(t, fyne.NewSize(0, 0), cont.MinSize()) 66 | 67 | // Having no objects should result in zero size. 68 | cont.Objects = nil 69 | cont.Resize(fyne.NewSize(100, 100)) 70 | assert.Equal(t, fyne.NewSize(0, 0), cont.MinSize()) 71 | } 72 | -------------------------------------------------------------------------------- /layout/responsive.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math" 7 | 8 | "fyne.io/fyne/v2" 9 | "fyne.io/fyne/v2/container" 10 | "fyne.io/fyne/v2/theme" 11 | "fyne.io/fyne/v2/widget" 12 | ) 13 | 14 | // responsive layout provides a fyne.Layout that is responsive to the window size. 15 | // All fyne.CanvasObject are resized and positionned following the rules you decide. 16 | // 17 | // It is strongly inspired by Bootstrap's grid system. But instead of using 12 columns, 18 | // we use width ratio. 19 | // By default, a standard fyne.CanvasObject will always be width to 1 * containerSize and place in vertical. 20 | // If you want to change the behavior, you can use Responsive() function that registers the layout configuration. 21 | // 22 | // Example: 23 | // layout := NewResponsiveLayout( 24 | // Responsive(label, 1, .5, .25, .5), // small, medium, large, xlarge ratio 25 | // } 26 | // Note that a responsive layout can handle others layouts, responsive or not. 27 | 28 | // responsiveBreakpoint is a integer representing a breakpoint size as defined in Bootstrap. 29 | // 30 | // See: https://getbootstrap.com/docs/4.0/layout/overview/#responsive-breakpoints 31 | type responsiveBreakpoint uint16 32 | 33 | const ( 34 | // SMALL is the smallest breakpoint (mobile vertical). 35 | SMALL responsiveBreakpoint = 576 36 | 37 | // MEDIUM is the medium breakpoint (mobile horizontal, tablet vertical). 38 | MEDIUM responsiveBreakpoint = 768 39 | 40 | // LARGE is the largest breakpoint (tablet horizontal, small desktop). 41 | LARGE responsiveBreakpoint = 992 42 | 43 | // XLARGE is the largest breakpoint (large desktop). 44 | XLARGE responsiveBreakpoint = 1200 45 | 46 | // SM is an alias for SMALL 47 | SM responsiveBreakpoint = SMALL 48 | 49 | // MD is an alias for MEDIUM 50 | MD responsiveBreakpoint = MEDIUM 51 | 52 | // LG is an alias for LARGE 53 | LG responsiveBreakpoint = LARGE 54 | 55 | // XL is an alias for XLARGE 56 | XL responsiveBreakpoint = XLARGE 57 | ) 58 | 59 | // ResponsiveConfiguration is the configuration for a responsive object. It's 60 | // a simple map from the breakpoint to the size ratio from it's container. 61 | // Breakpoint is a uint16 that should be set from const SMALL, MEDIUM, LARGE and XLARGE. 62 | type responsiveConfig map[responsiveBreakpoint]float32 63 | 64 | // newResponsiveConf return a new responsive configuration. 65 | // The optional ratios must 66 | // be 0 < ratio <= 1 and passed in this order: 67 | // 68 | // Responsive(object, smallRatio, mediumRatio, largeRatio, xlargeRatio) 69 | // 70 | // They are set to previous value if a value is not passed, or 1.0 if there is no previous value. 71 | func newResponsiveConf(ratios ...float32) responsiveConfig { 72 | responsive := responsiveConfig{} 73 | 74 | if len(ratios) > 4 { 75 | log.Println("Responsive: you declared more than 4 ratios, only the first 4 will be used") 76 | } 77 | 78 | // basic check 79 | for _, i := range ratios { 80 | if i <= 0 || i > 1 { 81 | message := "Responsive: size must be > 0 and <= 1, got: %f" 82 | panic(fmt.Errorf(message, i)) 83 | } 84 | } 85 | 86 | // Set default values 87 | for index, bp := range []responsiveBreakpoint{SMALL, MEDIUM, LARGE, XLARGE} { 88 | if len(ratios) <= index { 89 | if index == 0 { 90 | ratios = append(ratios, 1) 91 | } else { 92 | ratios = append(ratios, ratios[index-1]) 93 | } 94 | } 95 | responsive[bp] = ratios[index] 96 | } 97 | return responsive 98 | } 99 | 100 | // ResponsiveLayout is the layout that will adapt objects with the responsive rules. See NewResponsiveLayout 101 | // for details. 102 | type ResponsiveLayout struct{} 103 | 104 | // Layout will place the size and place the objects following the configured reponsive rules. 105 | // 106 | // Implements: fyne.Layout 107 | func (resp *ResponsiveLayout) Layout(objects []fyne.CanvasObject, containerSize fyne.Size) { 108 | // yes, it may happen 109 | if len(objects) == 0 || objects[0] == nil { 110 | return 111 | } 112 | 113 | // Responsive is based on the window size, so we need to get it 114 | window := fyne.CurrentApp().Driver().CanvasForObject(objects[0]) 115 | if window == nil { 116 | return 117 | } 118 | 119 | // this will be updatad for each element to know where to place 120 | // the next object. 121 | pos := fyne.NewPos(0, 0) 122 | 123 | // to calculate the next pos.Y when a new line is needed 124 | maxHeight := float32(0) 125 | 126 | // objects in a line 127 | line := []fyne.CanvasObject{} 128 | 129 | // cast windowSize.Width to responsiveBreakpoint (uint16) 130 | ww := responsiveBreakpoint(window.Size().Width) 131 | 132 | // For each object, place it at the right position (pos) and resize it. 133 | for _, o := range objects { 134 | if o == nil || !o.Visible() { 135 | continue 136 | } 137 | 138 | // get tht configuration 139 | ro, ok := o.(*responsiveWidget) 140 | if !ok { 141 | log.Fatal("A non responsive object has been packed inside a ResponsibleLayout. This is impossible.") 142 | } 143 | conf := ro.responsiveConfig 144 | 145 | line = append(line, o) // add the container to the line 146 | size := o.MinSize() // get some informations 147 | 148 | // adapt object witdh from the configuration 149 | if ww <= SMALL { 150 | size.Width = conf[SMALL] * containerSize.Width 151 | } else if ww <= MEDIUM { 152 | size.Width = conf[MEDIUM] * containerSize.Width 153 | } else if ww <= LARGE { 154 | size.Width = conf[LARGE] * containerSize.Width 155 | } else { 156 | size.Width = conf[XLARGE] * containerSize.Width 157 | } 158 | 159 | // place and resize the element 160 | o.Resize(size) 161 | o.Move(pos) 162 | 163 | // next element X position 164 | pos = pos.Add(fyne.NewPos(size.Width+theme.Padding(), 0)) 165 | 166 | maxHeight = resp.maxFloat32(maxHeight, size.Height) 167 | 168 | // Manage end of line, the next position overflows, so go to next line. 169 | if pos.X >= containerSize.Width-theme.Padding() { 170 | // we now know the number of object in a line, fix padding 171 | resp.fixPaddingOnLine(line) 172 | line = []fyne.CanvasObject{} 173 | pos.X = 0 // back to left 174 | pos.Y += maxHeight // move to the next line 175 | maxHeight = 0 176 | } 177 | } 178 | resp.fixPaddingOnLine(line) // fix padding for the last line 179 | } 180 | 181 | // MinSize return the minimum size ot the layout. 182 | // 183 | // Implements: fyne.Layout 184 | func (resp *ResponsiveLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { 185 | if len(objects) == 0 { 186 | return fyne.NewSize(0, 0) 187 | } 188 | 189 | var h, w, maxHeight float32 190 | currentY := objects[0].Position().Y 191 | 192 | for _, o := range objects { 193 | if o == nil || !o.Visible() { 194 | continue 195 | } 196 | w = resp.maxFloat32(o.MinSize().Width, w) + theme.Padding() 197 | if o.Position().Y != currentY { 198 | currentY = o.Position().Y 199 | // new line, so we can add the maxHeight to h 200 | h += maxHeight 201 | 202 | // drop the line 203 | maxHeight = 0 204 | } 205 | maxHeight = resp.maxFloat32(maxHeight, o.MinSize().Height) 206 | } 207 | h += maxHeight + theme.Padding() 208 | return fyne.NewSize(w, h) 209 | } 210 | 211 | // fixPaddingOnLine fix the space between the objects in a line. 212 | func (resp *ResponsiveLayout) fixPaddingOnLine(line []fyne.CanvasObject) { 213 | if len(line) <= 1 { 214 | return 215 | } 216 | for i, o := range line { 217 | s := o.Size() 218 | s.Width -= theme.Padding() / float32(len(line)-1) 219 | o.Resize(s) 220 | if i > 0 { 221 | p := o.Position() 222 | p.X -= theme.Padding() * float32(i) 223 | o.Move(p) 224 | } 225 | } 226 | } 227 | 228 | // math.Max only works with float64, so let's make our own 229 | func (resp *ResponsiveLayout) maxFloat32(a, b float32) float32 { 230 | return float32(math.Max(float64(a), float64(b))) 231 | } 232 | 233 | // NewResponsiveLayout return a responsive layout that will adapt objects with the responsive rules. To 234 | // configure the rule, each object could be encapsulated by a "Responsive" object. 235 | // 236 | // Example: 237 | // 238 | // container := NewResponsiveLayout( 239 | // Responsive(label, 1, .5, .25), // 100% for small, 50% for medium, 25% for large 240 | // Responsive(button, 1, .5, .25), // ... 241 | // label2, // this will be placed and resized with default behaviors 242 | // // => 1, 1, 1 243 | // ) 244 | func NewResponsiveLayout(o ...fyne.CanvasObject) *fyne.Container { 245 | r := &ResponsiveLayout{} 246 | 247 | objects := []fyne.CanvasObject{} 248 | for _, unknowObject := range o { 249 | if _, ok := unknowObject.(*responsiveWidget); !ok { 250 | unknowObject = Responsive(unknowObject) 251 | } 252 | objects = append(objects, unknowObject) 253 | } 254 | 255 | return container.New(r, objects...) 256 | } 257 | 258 | type responsiveWidget struct { 259 | widget.BaseWidget 260 | 261 | render fyne.CanvasObject 262 | responsiveConfig responsiveConfig 263 | } 264 | 265 | var _ fyne.Widget = (*responsiveWidget)(nil) 266 | 267 | // Responsive register the object with a responsive configuration. 268 | // The optional ratios must 269 | // be 0 < ratio <= 1 and passed in this order: 270 | // 271 | // Responsive(object, smallRatio, mediumRatio, largeRatio, xlargeRatio) 272 | // 273 | // They are set to previous value if a value is not passed, or 1.0 if there is no previous value. 274 | // The returned object is not modified. 275 | func Responsive(object fyne.CanvasObject, breakpointRatio ...float32) fyne.CanvasObject { 276 | ro := &responsiveWidget{render: object, responsiveConfig: newResponsiveConf(breakpointRatio...)} 277 | ro.ExtendBaseWidget(ro) 278 | return ro 279 | } 280 | 281 | func (ro *responsiveWidget) CreateRenderer() fyne.WidgetRenderer { 282 | if ro.render == nil { 283 | return nil 284 | } 285 | return widget.NewSimpleRenderer(ro.render) 286 | } 287 | -------------------------------------------------------------------------------- /layout/responsive_test.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | 7 | "fyne.io/fyne/v2" 8 | "fyne.io/fyne/v2/test" 9 | "fyne.io/fyne/v2/theme" 10 | "fyne.io/fyne/v2/widget" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | // Check if a simple widget is responsive to fill 100% of the layout. 15 | func TestResponsive_SimpleLayout(t *testing.T) { 16 | padding := theme.Padding() 17 | w, h := float32(SMALL), float32(300) 18 | 19 | // build 20 | label := widget.NewLabel("Hello World") 21 | layout := NewResponsiveLayout(label) 22 | 23 | win := test.NewWindow(layout) 24 | defer win.Close() 25 | win.Resize(fyne.NewSize(w, h)) 26 | 27 | size := layout.Size() 28 | 29 | assert.Equal(t, w-padding*2, size.Width) 30 | 31 | } 32 | 33 | // Test is a basic responsive layout is correctly configured. This test 2 widgets 34 | // with 100% for small size and 50% for medium size or taller. 35 | func TestResponsive_Responsive(t *testing.T) { 36 | padding := theme.Padding() 37 | 38 | // build 39 | label1 := Responsive(widget.NewLabel("Hello World"), 1, .5) 40 | label2 := Responsive(widget.NewLabel("Hello World"), 1, .5) 41 | 42 | win := test.NewWindow( 43 | NewResponsiveLayout(label1, label2), 44 | ) 45 | win.SetPadded(true) 46 | defer win.Close() 47 | 48 | // First, we are at w < SMALL so the labels should be sized to 100% of the layout 49 | w, h := float32(SMALL), float32(300) 50 | win.Resize(fyne.NewSize(w, h)) 51 | size1 := label1.Size() 52 | size2 := label2.Size() 53 | assert.Equal(t, w-padding*2, size1.Width) 54 | assert.Equal(t, w-padding*2, size2.Width) 55 | 56 | // Then resize to w > SMALL so the labels should be sized to 50% of the layout 57 | w = float32(MEDIUM) 58 | win.Resize(fyne.NewSize(w, h)) 59 | size1 = label1.Size() 60 | size2 = label2.Size() 61 | // remove 2 * padding as there is 2 objects in a line 62 | assert.Equal(t, w/2-padding*2, size1.Width) 63 | assert.Equal(t, w/2-padding*2, size2.Width) 64 | } 65 | 66 | // Check if a widget that overflows the container goes to the next line. 67 | func TestResponsive_GoToNextLine(t *testing.T) { 68 | w, h := float32(200), float32(300) 69 | 70 | // build 71 | label1 := Responsive(widget.NewLabel("Hello World"), .5) 72 | label2 := Responsive(widget.NewLabel("Hello World"), .5) 73 | label3 := Responsive(widget.NewLabel("Hello World"), .5) 74 | 75 | layout := NewResponsiveLayout(label1, label2, label3) 76 | win := test.NewWindow(layout) 77 | defer win.Close() 78 | w = float32(MEDIUM) 79 | win.Resize(fyne.NewSize(w, h)) 80 | 81 | // label 1 and 2 are on the same line 82 | assert.Equal(t, label1.Position().Y, label2.Position().Y) 83 | 84 | // but not the label 3 85 | assert.NotEqual(t, label1.Position().Y, label3.Position().Y) 86 | 87 | // just to be sure... 88 | // the label3 should be at label1.Position().Y + label1.Size().Height 89 | assert.Equal(t, label1.Position().Y+label1.Size().Height, label3.Position().Y) 90 | } 91 | 92 | // Check if sizes are correctly computed for responsive widgets when the window size 93 | // changes. There are some corner cases with padding. So, it needs to be improved... 94 | func TestResponsive_SwitchAllSizes(t *testing.T) { 95 | // build 96 | n := 4 97 | labels := make([]fyne.CanvasObject, n) 98 | for i := 0; i < n; i++ { 99 | labels[i] = Responsive(widget.NewLabel("Hello World"), 1, 1/float32(2), 1/float32(3), 1/float32(4)) 100 | } 101 | layout := NewResponsiveLayout(labels...) 102 | win := test.NewWindow(layout) 103 | defer win.Close() 104 | 105 | h := float32(1200) 106 | p := theme.Padding() 107 | // First, we are at w < SMALL so the labels should be sized to 100% of the layout 108 | w := float32(SMALL) 109 | win.Resize(fyne.NewSize(w, h)) 110 | win.Content().Refresh() 111 | w = w - 2*p 112 | for i := 0; i < n; i++ { 113 | size := labels[i].Size() 114 | assert.Equal(t, w, size.Width) 115 | } 116 | 117 | // Then resize to w > SMALL so the labels should be sized to 50% of the layout 118 | w = float32(MEDIUM) 119 | win.Resize(fyne.NewSize(w, h)) 120 | w = w - 2*p 121 | for i := 0; i < n; i++ { 122 | size := labels[i].Size() 123 | assert.Equal(t, w/2-p, size.Width) // 1 padding between 2 widgets 124 | } 125 | 126 | // Then resize to w > MEDIUM so the labels should be sized to 33% of the layout 127 | w = float32(LARGE) 128 | win.Resize(fyne.NewSize(w, h)) 129 | w = w - 2*p 130 | for i := 0; i < n-1; i++ { // note: n-1 because the last element seems to be resized 131 | size := labels[i].Size() 132 | floor := math.Floor(float64(w)/3 - float64(p)/3) 133 | assert.Equal(t, float32(floor), size.Width) // 2 paddings between 3 widgets 134 | } 135 | 136 | // Then resize to w > LARGE so the labels should be sized to 25% of the layout 137 | w = float32(XLARGE) 138 | win.Resize(fyne.NewSize(w, h)) 139 | w = w - 2*p 140 | for i := 0; i < n; i++ { 141 | size := labels[i].Size() 142 | // note: 1px is added to the size to avoid rounding errors 143 | assert.Equal(t, w/4-p/4-1, float32(math.Floor(float64(size.Width)))) 144 | } 145 | } 146 | 147 | // Test if a widget is responsive to fill 100% of the layout 148 | // when we don't provides rsponsive ratios. 149 | func TestResponsive_NoArgs(t *testing.T) { 150 | label := widget.NewLabel("Hello World") 151 | resp := NewResponsiveLayout(Responsive(label)) 152 | for _, child := range resp.Objects { 153 | ro, ok := child.(*responsiveWidget) 154 | assert.Equal(t, true, ok) 155 | for _, s := range []responsiveBreakpoint{SMALL, MEDIUM, LARGE, XLARGE} { 156 | assert.Equal(t, float32(1), ro.responsiveConfig[s]) 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /theme/adwaita.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "image/color" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/theme" 8 | ) 9 | 10 | //go:generate go run ./adwaita_theme_generator.go 11 | 12 | var _ fyne.Theme = (*Adwaita)(nil) 13 | 14 | // Adwaita is a theme that follows the Adwaita theme. It provides a light and dark theme + icons. 15 | // See: https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/named-colors.html 16 | type Adwaita struct{} 17 | 18 | // AdwaitaTheme returns a new Adwaita theme. 19 | func AdwaitaTheme() fyne.Theme { 20 | return &Adwaita{} 21 | } 22 | 23 | // Color returns the named color for the current theme. 24 | func (a *Adwaita) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color { 25 | switch variant { 26 | case theme.VariantLight: 27 | if c, ok := adwaitaLightScheme[name]; ok { 28 | return c 29 | } 30 | case theme.VariantDark: 31 | if c, ok := adwaitaDarkScheme[name]; ok { 32 | return c 33 | } 34 | } 35 | return theme.DefaultTheme().Color(name, variant) 36 | } 37 | 38 | // Font returns the named font for the current theme. 39 | func (a *Adwaita) Font(style fyne.TextStyle) fyne.Resource { 40 | return theme.DefaultTheme().Font(style) 41 | } 42 | 43 | // Icon returns the named resource for the current theme. 44 | func (a *Adwaita) Icon(name fyne.ThemeIconName) fyne.Resource { 45 | if icon, ok := adwaitaIcons[name]; ok { 46 | return icon 47 | } 48 | return theme.DefaultTheme().Icon(name) 49 | } 50 | 51 | // Size returns the size of the named resource for the current theme. 52 | func (a *Adwaita) Size(name fyne.ThemeSizeName) float32 { 53 | return theme.DefaultTheme().Size(name) 54 | } 55 | -------------------------------------------------------------------------------- /theme/adwaita_colors.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | // This file is generated by adwaita_theme_generator.go 4 | // Please do not edit manually, use: 5 | // go generate ./theme/... 6 | // 7 | // The colors are taken from: https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.0/named-colors.html 8 | 9 | import ( 10 | "image/color" 11 | 12 | "fyne.io/fyne/v2" 13 | "fyne.io/fyne/v2/theme" 14 | ) 15 | 16 | var adwaitaDarkScheme = map[fyne.ThemeColorName]color.Color{ 17 | theme.ColorBlue: color.NRGBA{R: 0x35, G: 0x84, B: 0xe4, A: 0xff}, // Adwaita color name @blue_3 18 | theme.ColorBrown: color.NRGBA{R: 0x98, G: 0x6a, B: 0x44, A: 0xff}, // Adwaita color name @brown_3 19 | theme.ColorGray: color.NRGBA{R: 0x5e, G: 0x5c, B: 0x64, A: 0xff}, // Adwaita color name @dark_2 20 | theme.ColorGreen: color.NRGBA{R: 0x26, G: 0xa2, B: 0x69, A: 0xff}, // Adwaita color name @green_5 21 | theme.ColorNameBackground: color.NRGBA{R: 0x24, G: 0x24, B: 0x24, A: 0xff}, // Adwaita color name @window_bg_color 22 | theme.ColorNameButton: color.NRGBA{R: 0x30, G: 0x30, B: 0x30, A: 0xff}, // Adwaita color name @headerbar_bg_color 23 | theme.ColorNameError: color.NRGBA{R: 0xc0, G: 0x1c, B: 0x28, A: 0xff}, // Adwaita color name @error_bg_color 24 | theme.ColorNameForeground: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}, // Adwaita color name @window_fg_color 25 | theme.ColorNameInputBackground: color.NRGBA{R: 0x1e, G: 0x1e, B: 0x1e, A: 0xff}, // Adwaita color name @view_bg_color 26 | theme.ColorNameMenuBackground: color.NRGBA{R: 0x38, G: 0x38, B: 0x38, A: 0xff}, // Adwaita color name @popover_bg_color 27 | theme.ColorNameOverlayBackground: color.NRGBA{R: 0x1e, G: 0x1e, B: 0x1e, A: 0xff}, // Adwaita color name @view_bg_color 28 | theme.ColorNamePrimary: color.NRGBA{R: 0x35, G: 0x84, B: 0xe4, A: 0xff}, // Adwaita color name @accent_bg_color 29 | theme.ColorNameScrollBar: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0x5b}, // Adwaita color name @light_1 30 | theme.ColorNameSelection: color.NRGBA{R: 0x30, G: 0x30, B: 0x30, A: 0xff}, // Adwaita color name @headerbar_bg_color 31 | theme.ColorNameShadow: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x5b}, // Adwaita color name @shade_color 32 | theme.ColorNameSuccess: color.NRGBA{R: 0x26, G: 0xa2, B: 0x69, A: 0xff}, // Adwaita color name @success_bg_color 33 | theme.ColorNameWarning: color.NRGBA{R: 0xcd, G: 0x93, B: 0x09, A: 0xff}, // Adwaita color name @warning_bg_color 34 | theme.ColorOrange: color.NRGBA{R: 0xff, G: 0x78, B: 0x00, A: 0xff}, // Adwaita color name @orange_3 35 | theme.ColorPurple: color.NRGBA{R: 0x91, G: 0x41, B: 0xac, A: 0xff}, // Adwaita color name @purple_3 36 | theme.ColorRed: color.NRGBA{R: 0xc0, G: 0x1c, B: 0x28, A: 0xff}, // Adwaita color name @red_4 37 | theme.ColorYellow: color.NRGBA{R: 0xf6, G: 0xd3, B: 0x2d, A: 0xff}, // Adwaita color name @yellow_3 38 | } 39 | 40 | var adwaitaLightScheme = map[fyne.ThemeColorName]color.Color{ 41 | theme.ColorBlue: color.NRGBA{R: 0x35, G: 0x84, B: 0xe4, A: 0xff}, // Adwaita color name @blue_3 42 | theme.ColorBrown: color.NRGBA{R: 0x98, G: 0x6A, B: 0x44, A: 0xff}, // Adwaita color name @brown_3 43 | theme.ColorGray: color.NRGBA{R: 0x5e, G: 0x5C, B: 0x64, A: 0xff}, // Adwaita color name @dark_2 44 | theme.ColorGreen: color.NRGBA{R: 0x2e, G: 0xC2, B: 0x7e, A: 0xff}, // Adwaita color name @green_4 45 | theme.ColorNameBackground: color.NRGBA{R: 0xfa, G: 0xFA, B: 0xfa, A: 0xff}, // Adwaita color name @window_bg_color 46 | theme.ColorNameButton: color.NRGBA{R: 0xeb, G: 0xEB, B: 0xeb, A: 0xff}, // Adwaita color name @headerbar_bg_color 47 | theme.ColorNameError: color.NRGBA{R: 0xe0, G: 0x1B, B: 0x24, A: 0xff}, // Adwaita color name @error_bg_color 48 | theme.ColorNameForeground: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xcc}, // Adwaita color name @window_fg_color 49 | theme.ColorNameInputBackground: color.NRGBA{R: 0xff, G: 0xFF, B: 0xff, A: 0xff}, // Adwaita color name @view_bg_color 50 | theme.ColorNameMenuBackground: color.NRGBA{R: 0xff, G: 0xFF, B: 0xff, A: 0xff}, // Adwaita color name @popover_bg_color 51 | theme.ColorNameOverlayBackground: color.NRGBA{R: 0xff, G: 0xFF, B: 0xff, A: 0xff}, // Adwaita color name @view_bg_color 52 | theme.ColorNamePrimary: color.NRGBA{R: 0x35, G: 0x84, B: 0xe4, A: 0xff}, // Adwaita color name @accent_bg_color 53 | theme.ColorNameScrollBar: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x5b}, // Adwaita color name @dark_5 54 | theme.ColorNameSelection: color.NRGBA{R: 0xeb, G: 0xEB, B: 0xeb, A: 0xff}, // Adwaita color name @headerbar_bg_color 55 | theme.ColorNameShadow: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x11}, // Adwaita color name @shade_color 56 | theme.ColorNameSuccess: color.NRGBA{R: 0x2e, G: 0xC2, B: 0x7e, A: 0xff}, // Adwaita color name @success_bg_color 57 | theme.ColorNameWarning: color.NRGBA{R: 0xe5, G: 0xA5, B: 0x0a, A: 0xff}, // Adwaita color name @warning_bg_color 58 | theme.ColorOrange: color.NRGBA{R: 0xff, G: 0x78, B: 0x00, A: 0xff}, // Adwaita color name @orange_3 59 | theme.ColorPurple: color.NRGBA{R: 0x91, G: 0x41, B: 0xac, A: 0xff}, // Adwaita color name @purple_3 60 | theme.ColorRed: color.NRGBA{R: 0xe0, G: 0x1B, B: 0x24, A: 0xff}, // Adwaita color name @red_3 61 | theme.ColorYellow: color.NRGBA{R: 0xf6, G: 0xD3, B: 0x2d, A: 0xff}, // Adwaita color name @yellow_3 62 | } 63 | -------------------------------------------------------------------------------- /widget/calendar.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "math" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "fyne.io/fyne/v2" 10 | "fyne.io/fyne/v2/container" 11 | "fyne.io/fyne/v2/layout" 12 | "fyne.io/fyne/v2/theme" 13 | "fyne.io/fyne/v2/widget" 14 | ) 15 | 16 | // Declare conformity with Layout interface 17 | var _ fyne.Layout = (*calendarLayout)(nil) 18 | 19 | const ( 20 | daysPerWeek = 7 21 | maxWeeksPerMonth = 6 22 | ) 23 | 24 | type calendarLayout struct { 25 | cellSize fyne.Size 26 | } 27 | 28 | func newCalendarLayout() fyne.Layout { 29 | return &calendarLayout{} 30 | } 31 | 32 | // Get the leading edge position of a grid cell. 33 | // The row and col specify where the cell is in the calendar. 34 | func (g *calendarLayout) getLeading(row, col int) fyne.Position { 35 | x := (g.cellSize.Width) * float32(col) 36 | y := (g.cellSize.Height) * float32(row) 37 | 38 | return fyne.NewPos(float32(math.Round(float64(x))), float32(math.Round(float64(y)))) 39 | } 40 | 41 | // Get the trailing edge position of a grid cell. 42 | // The row and col specify where the cell is in the calendar. 43 | func (g *calendarLayout) getTrailing(row, col int) fyne.Position { 44 | return g.getLeading(row+1, col+1) 45 | } 46 | 47 | // Layout is called to pack all child objects into a specified size. 48 | // For a GridLayout this will pack objects into a table format with the number 49 | // of columns specified in our constructor. 50 | func (g *calendarLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { 51 | weeks := 1 52 | day := 0 53 | for i, child := range objects { 54 | if !child.Visible() { 55 | continue 56 | } 57 | 58 | if day%daysPerWeek == 0 && i >= daysPerWeek { 59 | weeks++ 60 | } 61 | day++ 62 | } 63 | 64 | g.cellSize = fyne.NewSize(size.Width/float32(daysPerWeek), 65 | size.Height/float32(weeks)) 66 | row, col := 0, 0 67 | i := 0 68 | for _, child := range objects { 69 | if !child.Visible() { 70 | continue 71 | } 72 | 73 | lead := g.getLeading(row, col) 74 | trail := g.getTrailing(row, col) 75 | child.Move(lead) 76 | child.Resize(fyne.NewSize(trail.X, trail.Y).Subtract(lead)) 77 | 78 | if (i+1)%daysPerWeek == 0 { 79 | row++ 80 | col = 0 81 | } else { 82 | col++ 83 | } 84 | i++ 85 | } 86 | } 87 | 88 | // MinSize sets the minimum size for the calendar 89 | func (g *calendarLayout) MinSize(_ []fyne.CanvasObject) fyne.Size { 90 | pad := theme.Padding() 91 | largestMin := widget.NewLabel("22").MinSize() 92 | return fyne.NewSize(largestMin.Width*daysPerWeek+pad*(daysPerWeek-1), 93 | largestMin.Height*maxWeeksPerMonth+pad*(maxWeeksPerMonth-1)) 94 | } 95 | 96 | // Calendar creates a new date time picker which returns a time object 97 | type Calendar struct { 98 | widget.BaseWidget 99 | currentTime time.Time 100 | 101 | monthPrevious *widget.Button 102 | monthNext *widget.Button 103 | monthLabel *widget.Label 104 | 105 | dates *fyne.Container 106 | 107 | onSelected func(time.Time) 108 | } 109 | 110 | func (c *Calendar) daysOfMonth() []fyne.CanvasObject { 111 | start := time.Date(c.currentTime.Year(), c.currentTime.Month(), 1, 0, 0, 0, 0, c.currentTime.Location()) 112 | buttons := []fyne.CanvasObject{} 113 | 114 | //account for Go time pkg starting on sunday at index 0 115 | dayIndex := int(start.Weekday()) 116 | if dayIndex == 0 { 117 | dayIndex += daysPerWeek 118 | } 119 | 120 | //add spacers if week doesn't start on Monday 121 | for i := 0; i < dayIndex-1; i++ { 122 | buttons = append(buttons, layout.NewSpacer()) 123 | } 124 | 125 | for d := start; d.Month() == start.Month(); d = d.AddDate(0, 0, 1) { 126 | 127 | dayNum := d.Day() 128 | s := strconv.Itoa(dayNum) 129 | b := widget.NewButton(s, func() { 130 | 131 | selectedDate := c.dateForButton(dayNum) 132 | 133 | c.onSelected(selectedDate) 134 | }) 135 | b.Importance = widget.LowImportance 136 | 137 | buttons = append(buttons, b) 138 | } 139 | 140 | return buttons 141 | } 142 | 143 | func (c *Calendar) dateForButton(dayNum int) time.Time { 144 | oldName, off := c.currentTime.Zone() 145 | return time.Date(c.currentTime.Year(), c.currentTime.Month(), dayNum, c.currentTime.Hour(), c.currentTime.Minute(), 0, 0, time.FixedZone(oldName, off)).In(c.currentTime.Location()) 146 | } 147 | 148 | func (c *Calendar) monthYear() string { 149 | return c.currentTime.Format("January 2006") 150 | } 151 | 152 | func (c *Calendar) calendarObjects() []fyne.CanvasObject { 153 | columnHeadings := []fyne.CanvasObject{} 154 | for i := 0; i < daysPerWeek; i++ { 155 | j := i + 1 156 | if j == daysPerWeek { 157 | j = 0 158 | } 159 | 160 | t := widget.NewLabel(strings.ToUpper(time.Weekday(j).String()[:3])) 161 | t.Alignment = fyne.TextAlignCenter 162 | columnHeadings = append(columnHeadings, t) 163 | } 164 | columnHeadings = append(columnHeadings, c.daysOfMonth()...) 165 | 166 | return columnHeadings 167 | } 168 | 169 | // CreateRenderer returns a new WidgetRenderer for this widget. 170 | // This should not be called by regular code, it is used internally to render a widget. 171 | func (c *Calendar) CreateRenderer() fyne.WidgetRenderer { 172 | c.monthPrevious = widget.NewButtonWithIcon("", theme.NavigateBackIcon(), func() { 173 | c.currentTime = c.currentTime.AddDate(0, -1, 0) 174 | // Dates are 'normalised', forcing date to start from the start of the month ensures move from March to February 175 | c.currentTime = time.Date(c.currentTime.Year(), c.currentTime.Month(), 1, 0, 0, 0, 0, c.currentTime.Location()) 176 | c.monthLabel.SetText(c.monthYear()) 177 | c.dates.Objects = c.calendarObjects() 178 | }) 179 | c.monthPrevious.Importance = widget.LowImportance 180 | 181 | c.monthNext = widget.NewButtonWithIcon("", theme.NavigateNextIcon(), func() { 182 | c.currentTime = c.currentTime.AddDate(0, 1, 0) 183 | c.monthLabel.SetText(c.monthYear()) 184 | c.dates.Objects = c.calendarObjects() 185 | }) 186 | c.monthNext.Importance = widget.LowImportance 187 | 188 | c.monthLabel = widget.NewLabel(c.monthYear()) 189 | 190 | nav := container.New(layout.NewBorderLayout(nil, nil, c.monthPrevious, c.monthNext), 191 | c.monthPrevious, c.monthNext, container.NewCenter(c.monthLabel)) 192 | 193 | c.dates = container.New(newCalendarLayout(), c.calendarObjects()...) 194 | 195 | dateContainer := container.NewBorder(nav, nil, nil, nil, c.dates) 196 | 197 | return widget.NewSimpleRenderer(dateContainer) 198 | } 199 | 200 | // NewCalendar creates a calendar instance 201 | func NewCalendar(cT time.Time, onSelected func(time.Time)) *Calendar { 202 | c := &Calendar{ 203 | currentTime: cT, 204 | onSelected: onSelected, 205 | } 206 | 207 | c.ExtendBaseWidget(c) 208 | 209 | return c 210 | } 211 | -------------------------------------------------------------------------------- /widget/calendar_test.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "fyne.io/fyne/v2" 11 | "fyne.io/fyne/v2/test" 12 | "fyne.io/fyne/v2/widget" 13 | ) 14 | 15 | func TestNewCalendar(t *testing.T) { 16 | now := time.Now() 17 | c := NewCalendar(now, func(time.Time) {}) 18 | assert.Equal(t, now.Day(), c.currentTime.Day()) 19 | assert.Equal(t, int(now.Month()), int(c.currentTime.Month())) 20 | assert.Equal(t, now.Year(), c.currentTime.Year()) 21 | 22 | _ = test.WidgetRenderer(c) // and render 23 | assert.Equal(t, now.Format("January 2006"), c.monthLabel.Text) 24 | } 25 | 26 | func TestNewCalendar_ButtonDate(t *testing.T) { 27 | date := time.Now() 28 | c := NewCalendar(date, func(time.Time) {}) 29 | _ = test.WidgetRenderer(c) // and render 30 | 31 | endNextMonth := date.AddDate(0, 1, 0).AddDate(0, 0, -(date.Day() - 1)) 32 | last := endNextMonth.AddDate(0, 0, -1) 33 | 34 | firstDate := firstDateButton(c.dates) 35 | assert.Equal(t, "1", firstDate.Text) 36 | lastDate := c.dates.Objects[len(c.dates.Objects)-1].(*widget.Button) 37 | assert.Equal(t, strconv.Itoa(last.Day()), lastDate.Text) 38 | } 39 | 40 | func TestNewCalendar_Next(t *testing.T) { 41 | date := time.Now() 42 | c := NewCalendar(date, func(time.Time) {}) 43 | _ = test.WidgetRenderer(c) // and render 44 | 45 | assert.Equal(t, date.Format("January 2006"), c.monthLabel.Text) 46 | 47 | test.Tap(c.monthNext) 48 | date = date.AddDate(0, 1, 0) 49 | assert.Equal(t, date.Format("January 2006"), c.monthLabel.Text) 50 | } 51 | 52 | func TestNewCalendar_Previous(t *testing.T) { 53 | date := time.Now() 54 | c := NewCalendar(date, func(time.Time) {}) 55 | _ = test.WidgetRenderer(c) // and render 56 | 57 | assert.Equal(t, date.Format("January 2006"), c.monthLabel.Text) 58 | 59 | test.Tap(c.monthPrevious) 60 | date = date.AddDate(0, -1, 0) 61 | assert.Equal(t, date.Format("January 2006"), c.monthLabel.Text) 62 | } 63 | 64 | func TestNewCalendar_Resize(t *testing.T) { 65 | date := time.Now() 66 | c := NewCalendar(date, func(time.Time) {}) 67 | r := test.WidgetRenderer(c) // and render 68 | layout := c.dates.Layout.(*calendarLayout) 69 | 70 | baseSize := c.MinSize() 71 | r.Layout(baseSize) 72 | min := layout.cellSize 73 | 74 | r.Layout(baseSize.AddWidthHeight(100, 0)) 75 | assert.Greater(t, layout.cellSize.Width, min.Width) 76 | assert.Equal(t, layout.cellSize.Height, min.Height) 77 | 78 | r.Layout(baseSize.AddWidthHeight(0, 100)) 79 | assert.Equal(t, layout.cellSize.Width, min.Width) 80 | assert.Greater(t, layout.cellSize.Height, min.Height) 81 | 82 | r.Layout(baseSize.AddWidthHeight(100, 100)) 83 | assert.Greater(t, layout.cellSize.Width, min.Width) 84 | assert.Greater(t, layout.cellSize.Height, min.Height) 85 | } 86 | 87 | func firstDateButton(c *fyne.Container) *widget.Button { 88 | for _, b := range c.Objects { 89 | if nonBlank, ok := b.(*widget.Button); ok { 90 | return nonBlank 91 | } 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /widget/completionentry.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/theme" 6 | "fyne.io/fyne/v2/widget" 7 | ) 8 | 9 | // CompletionEntry is an Entry with options displayed in a PopUpMenu. 10 | type CompletionEntry struct { 11 | widget.Entry 12 | popupMenu *widget.PopUp 13 | navigableList *navigableList 14 | Options []string 15 | pause bool 16 | itemHeight float32 17 | 18 | CustomCreate func() fyne.CanvasObject 19 | CustomUpdate func(id widget.ListItemID, object fyne.CanvasObject) 20 | } 21 | 22 | // NewCompletionEntry creates a new CompletionEntry which creates a popup menu that responds to keystrokes to navigate through the items without losing the editing ability of the text input. 23 | func NewCompletionEntry(options []string) *CompletionEntry { 24 | c := &CompletionEntry{Options: options} 25 | c.ExtendBaseWidget(c) 26 | return c 27 | } 28 | 29 | // HideCompletion hides the completion menu. 30 | func (c *CompletionEntry) HideCompletion() { 31 | if c.popupMenu != nil { 32 | c.popupMenu.Hide() 33 | } 34 | } 35 | 36 | // Move changes the relative position of the select entry. 37 | // 38 | // Implements: fyne.Widget 39 | func (c *CompletionEntry) Move(pos fyne.Position) { 40 | c.Entry.Move(pos) 41 | if c.popupMenu != nil { 42 | c.popupMenu.Resize(c.maxSize()) 43 | c.popupMenu.Move(c.popUpPos()) 44 | } 45 | } 46 | 47 | // Refresh the list to update the options to display. 48 | func (c *CompletionEntry) Refresh() { 49 | c.Entry.Refresh() 50 | if c.navigableList != nil { 51 | c.navigableList.SetOptions(c.Options) 52 | } 53 | } 54 | 55 | // Resize sets a new size for a widget. 56 | // Note this should not be used if the widget is being managed by a Layout within a Container. 57 | func (c *CompletionEntry) Resize(size fyne.Size) { 58 | c.Entry.Resize(size) 59 | if c.popupMenu != nil { 60 | c.popupMenu.Resize(c.maxSize()) 61 | } 62 | } 63 | 64 | // SetOptions set the completion list with itemList and update the view. 65 | func (c *CompletionEntry) SetOptions(itemList []string) { 66 | c.Options = itemList 67 | c.Refresh() 68 | } 69 | 70 | // ShowCompletion displays the completion menu 71 | func (c *CompletionEntry) ShowCompletion() { 72 | if c.pause { 73 | return 74 | } 75 | if len(c.Options) == 0 { 76 | c.HideCompletion() 77 | return 78 | } 79 | 80 | if c.navigableList == nil { 81 | c.navigableList = newNavigableList(c.Options, &c.Entry, c.setTextFromMenu, c.HideCompletion, 82 | c.CustomCreate, c.CustomUpdate) 83 | } else { 84 | c.navigableList.UnselectAll() 85 | c.navigableList.selected = -1 86 | } 87 | holder := fyne.CurrentApp().Driver().CanvasForObject(c) 88 | 89 | if c.popupMenu == nil { 90 | c.popupMenu = widget.NewPopUp(c.navigableList, holder) 91 | } 92 | c.popupMenu.Resize(c.maxSize()) 93 | c.popupMenu.ShowAtPosition(c.popUpPos()) 94 | holder.Focus(c.navigableList) 95 | } 96 | 97 | // calculate the max size to make the popup to cover everything below the entry 98 | func (c *CompletionEntry) maxSize() fyne.Size { 99 | cnv := fyne.CurrentApp().Driver().CanvasForObject(c) 100 | 101 | if c.itemHeight == 0 { 102 | // set item height to cache 103 | c.itemHeight = c.navigableList.CreateItem().MinSize().Height 104 | } 105 | 106 | canvasSize := cnv.Size() 107 | entrySize := c.Size() 108 | entryPos := fyne.CurrentApp().Driver().AbsolutePositionForObject(c) 109 | listHeight := float32(len(c.Options))*(c.itemHeight+2*theme.Padding()+theme.SeparatorThicknessSize()) + 2*theme.Padding() 110 | maxHeight := canvasSize.Height - entryPos.Y - entrySize.Height - 2*theme.Padding() 111 | 112 | if listHeight > maxHeight { 113 | listHeight = maxHeight 114 | } 115 | 116 | return fyne.NewSize(entrySize.Width, listHeight) 117 | } 118 | 119 | // calculate where the popup should appear 120 | func (c *CompletionEntry) popUpPos() fyne.Position { 121 | entryPos := fyne.CurrentApp().Driver().AbsolutePositionForObject(c) 122 | return entryPos.Add(fyne.NewPos(0, c.Size().Height)) 123 | } 124 | 125 | // Prevent the menu to open when the user validate value from the menu. 126 | func (c *CompletionEntry) setTextFromMenu(s string) { 127 | c.pause = true 128 | c.Entry.SetText(s) 129 | c.Entry.CursorColumn = len([]rune(s)) 130 | c.Entry.Refresh() 131 | c.pause = false 132 | c.popupMenu.Hide() 133 | } 134 | 135 | type navigableList struct { 136 | widget.List 137 | entry *widget.Entry 138 | selected int 139 | setTextFromMenu func(string) 140 | hide func() 141 | navigating bool 142 | items []string 143 | 144 | customCreate func() fyne.CanvasObject 145 | customUpdate func(id widget.ListItemID, object fyne.CanvasObject) 146 | } 147 | 148 | func newNavigableList(items []string, entry *widget.Entry, setTextFromMenu func(string), hide func(), 149 | create func() fyne.CanvasObject, update func(id widget.ListItemID, object fyne.CanvasObject)) *navigableList { 150 | n := &navigableList{ 151 | entry: entry, 152 | selected: -1, 153 | setTextFromMenu: setTextFromMenu, 154 | hide: hide, 155 | items: items, 156 | customCreate: create, 157 | customUpdate: update, 158 | } 159 | 160 | n.List = widget.List{ 161 | Length: func() int { 162 | return len(n.items) 163 | }, 164 | CreateItem: func() fyne.CanvasObject { 165 | if fn := n.customCreate; fn != nil { 166 | return fn() 167 | } 168 | return widget.NewLabel("") 169 | }, 170 | UpdateItem: func(i widget.ListItemID, o fyne.CanvasObject) { 171 | if fn := n.customUpdate; fn != nil { 172 | fn(i, o) 173 | return 174 | } 175 | o.(*widget.Label).SetText(n.items[i]) 176 | }, 177 | OnSelected: func(id widget.ListItemID) { 178 | if !n.navigating && id > -1 { 179 | setTextFromMenu(n.items[id]) 180 | } 181 | n.navigating = false 182 | }, 183 | } 184 | n.ExtendBaseWidget(n) 185 | return n 186 | } 187 | 188 | // Implements: fyne.Focusable 189 | func (n *navigableList) FocusGained() { 190 | } 191 | 192 | // Implements: fyne.Focusable 193 | func (n *navigableList) FocusLost() { 194 | } 195 | 196 | func (n *navigableList) SetOptions(items []string) { 197 | n.Unselect(n.selected) 198 | n.items = items 199 | n.Refresh() 200 | n.selected = -1 201 | } 202 | 203 | func (n *navigableList) TypedKey(event *fyne.KeyEvent) { 204 | switch event.Name { 205 | case fyne.KeyDown: 206 | if n.selected < len(n.items)-1 { 207 | n.selected++ 208 | } else { 209 | n.selected = 0 210 | } 211 | n.navigating = true 212 | n.Select(n.selected) 213 | 214 | case fyne.KeyUp: 215 | if n.selected > 0 { 216 | n.selected-- 217 | } else { 218 | n.selected = len(n.items) - 1 219 | } 220 | n.navigating = true 221 | n.Select(n.selected) 222 | case fyne.KeyReturn, fyne.KeyEnter: 223 | if n.selected == -1 { // so the user want to submit the entry 224 | n.hide() 225 | n.entry.TypedKey(event) 226 | } else { 227 | n.navigating = false 228 | n.OnSelected(n.selected) 229 | } 230 | case fyne.KeyEscape: 231 | n.hide() 232 | default: 233 | n.entry.TypedKey(event) 234 | 235 | } 236 | } 237 | 238 | func (n *navigableList) TypedRune(r rune) { 239 | n.entry.TypedRune(r) 240 | } 241 | -------------------------------------------------------------------------------- /widget/completionentry_test.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "testing" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/test" 8 | "fyne.io/fyne/v2/widget" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | var entryData = []string{"foo", "bar", "baz"} 13 | 14 | // Create the test entry with 3 completion items. 15 | func createEntry() *CompletionEntry { 16 | entry := NewCompletionEntry([]string{"zoo", "boo"}) 17 | entry.OnChanged = func(s string) { 18 | entry.SetOptions(entryData) 19 | entry.ShowCompletion() 20 | } 21 | return entry 22 | } 23 | 24 | // Check if the data is filled with corresponding options. 25 | func TestCompletionEntry(t *testing.T) { 26 | entry := createEntry() 27 | win := test.NewWindow(entry) 28 | win.Resize(fyne.NewSize(500, 300)) 29 | defer win.Close() 30 | 31 | entry.SetText("init") 32 | assert.Equal(t, 3, len(entry.Options)) 33 | } 34 | 35 | // Check if custom create/update is called 36 | func TestCompletionEntry_Custom(t *testing.T) { 37 | entry := createEntry() 38 | entry.CustomCreate = func() fyne.CanvasObject { 39 | return widget.NewCheck("thing", func(bool) {}) 40 | } 41 | entry.CustomUpdate = func(id widget.ListItemID, o fyne.CanvasObject) { 42 | o.(*widget.Check).Text = entryData[id] 43 | o.Refresh() 44 | } 45 | win := test.NewWindow(entry) 46 | win.Resize(fyne.NewSize(500, 300)) 47 | defer win.Close() 48 | 49 | entry.SetText("init") 50 | scroll := test.WidgetRenderer(entry.navigableList).Objects()[0].(fyne.Widget) 51 | list := test.WidgetRenderer(scroll).Objects()[0].(*fyne.Container).Objects[0].(fyne.Widget) 52 | item1 := test.WidgetRenderer(list).Objects()[1] 53 | assert.Equal(t, "foo", item1.(*widget.Check).Text) // ensure the item is a Check not Label 54 | } 55 | 56 | // Show the completion menu 57 | func TestCompletionEntry_ShowMenu(t *testing.T) { 58 | entry := createEntry() 59 | win := test.NewWindow(entry) 60 | win.Resize(fyne.NewSize(500, 300)) 61 | defer win.Close() 62 | 63 | entry.SetText("init") 64 | assert.True(t, entry.popupMenu.Visible()) 65 | } 66 | 67 | // Navigate in menu and select an entry. 68 | func TestCompletionEntry_Navigate(t *testing.T) { 69 | entry := createEntry() 70 | win := test.NewWindow(entry) 71 | win.Resize(fyne.NewSize(500, 300)) 72 | defer win.Close() 73 | 74 | entry.SetText("init") 75 | 76 | // navigate to "bar" and press enter, the entry should contain 77 | // "bar" in value 78 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyDown}) 79 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyDown}) 80 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyReturn}) 81 | 82 | assert.Equal(t, "bar", entry.Text) 83 | assert.False(t, entry.popupMenu.Visible()) 84 | } 85 | 86 | // Ensure the cursor is set to the end of entry after completion. 87 | func TestCompletionEntry_CursorPosition(t *testing.T) { 88 | entry := createEntry() 89 | win := test.NewWindow(entry) 90 | win.Resize(fyne.NewSize(500, 300)) 91 | defer win.Close() 92 | 93 | entry.OnChanged = func(s string) { 94 | entry.SetOptions([]string{"foofoo", "barbar", "bazbaz"}) 95 | entry.ShowCompletion() 96 | } 97 | entry.SetText("barb") 98 | 99 | // navigate to "bar" and press enter, the entry should contain 100 | // "bar" in value 101 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyDown}) 102 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyDown}) 103 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyReturn}) 104 | 105 | assert.Equal(t, 6, entry.CursorColumn) 106 | } 107 | 108 | // Hide the menu on Escape key. 109 | func TestCompletionEntry_Escape(t *testing.T) { 110 | entry := createEntry() 111 | win := test.NewWindow(entry) 112 | win.Resize(fyne.NewSize(500, 300)) 113 | defer win.Close() 114 | 115 | entry.SetText("init") 116 | 117 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyDown}) 118 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyEscape}) 119 | 120 | assert.False(t, entry.popupMenu.Visible()) 121 | } 122 | 123 | // Hide the menu on rune pressed. 124 | func TestCompletionEntry_Rune(t *testing.T) { 125 | entry := createEntry() 126 | win := test.NewWindow(entry) 127 | win.Resize(fyne.NewSize(500, 300)) 128 | defer win.Close() 129 | 130 | entry.SetText("foobar") 131 | entry.CursorColumn = 6 // place the cursor after the text 132 | 133 | // type some chars... 134 | win.Canvas().Focused().TypedRune('x') 135 | win.Canvas().Focused().TypedRune('y') 136 | assert.Equal(t, "foobarxy", entry.Text) 137 | 138 | // make a move and type other char 139 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyDown}) 140 | win.Canvas().Focused().TypedRune('z') 141 | assert.Equal(t, "foobarxyz", entry.Text) 142 | 143 | assert.True(t, entry.popupMenu.Visible()) 144 | } 145 | 146 | // Hide the menu on rune pressed. 147 | func TestCompletionEntry_Rotation(t *testing.T) { 148 | entry := createEntry() 149 | win := test.NewWindow(entry) 150 | win.Resize(fyne.NewSize(500, 300)) 151 | defer win.Close() 152 | 153 | entry.SetText("foobar") 154 | entry.CursorColumn = 6 // place the cursor after the text 155 | 156 | // loop one time (nb items + 1) to go back to the first element 157 | for i := 0; i <= len(entry.Options); i++ { 158 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyDown}) 159 | } 160 | 161 | assert.Equal(t, 0, entry.navigableList.selected) 162 | 163 | // Do the same in reverse order, here, onlh one time to go on the last item 164 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyUp}) 165 | assert.Equal(t, len(entry.Options)-1, entry.navigableList.selected) 166 | } 167 | 168 | // Test if completion is hidden when there is no options. 169 | func TestCompletionEntry_WithEmptyOptions(t *testing.T) { 170 | entry := createEntry() 171 | win := test.NewWindow(entry) 172 | win.Resize(fyne.NewSize(500, 300)) 173 | defer win.Close() 174 | 175 | entry.OnChanged = func(s string) { 176 | entry.SetOptions([]string{}) 177 | entry.ShowCompletion() 178 | } 179 | 180 | entry.SetText("foo") 181 | assert.Nil(t, entry.popupMenu) // popupMenu should not being created 182 | } 183 | 184 | // Test sumbission with opened completion. 185 | func TestCompletionEntry_OnSubmit(t *testing.T) { 186 | entry := createEntry() 187 | win := test.NewWindow(entry) 188 | win.Resize(fyne.NewSize(500, 300)) 189 | defer win.Close() 190 | 191 | submitted := false 192 | entry.OnSubmitted = func(s string) { 193 | submitted = true 194 | entry.HideCompletion() 195 | assert.True(t, entry.popupMenu.Hidden) 196 | } 197 | entry.OnChanged = func(s string) { 198 | entry.ShowCompletion() 199 | } 200 | 201 | entry.SetText("foo") 202 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyReturn}) 203 | assert.True(t, submitted) 204 | } 205 | 206 | // Test double submission issue, when the user select an option in list and press "Enter", then 207 | // the "OnSubmitted" method should not be called. It should be called only after the user pressed a 208 | // second time. 209 | func TestCompletionEntry_DoubleSubmissionIssue(t *testing.T) { 210 | entry := createEntry() 211 | entry.SetOptions([]string{"foofoo", "bar", "baz"}) 212 | win := test.NewWindow(entry) 213 | win.Resize(fyne.NewSize(500, 300)) 214 | defer win.Close() 215 | 216 | submitted := false 217 | entry.OnSubmitted = func(s string) { 218 | submitted = true 219 | } 220 | 221 | win.Canvas().Focus(entry) 222 | entry.SetText("foo") 223 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyDown}) // select foofoo 224 | assert.False(t, submitted) 225 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyReturn}) // OnSubmitted should NOT be called 226 | assert.False(t, submitted) 227 | assert.False(t, entry.popupMenu.Visible()) 228 | win.Canvas().Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeyReturn}) // OnSubmitted should be called 229 | assert.True(t, submitted) 230 | } 231 | -------------------------------------------------------------------------------- /widget/diagramwidget/anchoredtext.go: -------------------------------------------------------------------------------- 1 | package diagramwidget 2 | 3 | import ( 4 | "image/color" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/container" 8 | "fyne.io/fyne/v2/data/binding" 9 | "fyne.io/fyne/v2/driver/desktop" 10 | "fyne.io/fyne/v2/theme" 11 | "fyne.io/fyne/v2/widget" 12 | "fyne.io/x/fyne/widget/diagramwidget/geometry/r2" 13 | ) 14 | 15 | // AnchoredText provides a text annotation for a path that is anchored to one 16 | // of the path's reference points (e.g. end or middle). The anchored text may 17 | // be moved independently, but it keeps track of its position relative to the 18 | // reference point. If the reference point moves, the AnchoredText will also 19 | // move by the same amount 20 | type AnchoredText struct { 21 | widget.BaseWidget 22 | link *BaseDiagramLink 23 | offset r2.Vec2 24 | referencePosition fyne.Position 25 | displayedTextBinding binding.String 26 | ForegroundColor color.Color 27 | textEntry *widget.Entry 28 | } 29 | 30 | // NewAnchoredText creates an textual annotation for a link. After it is created, one of the 31 | // three AddAnchoredText methods must be called on the link to actually associate the 32 | // anchored text with the appropriate reference point on the link. 33 | func NewAnchoredText(text string) *AnchoredText { 34 | at := &AnchoredText{ 35 | offset: r2.MakeVec2(0, 0), 36 | ForegroundColor: theme.Color(theme.ColorNameForeground), 37 | referencePosition: fyne.Position{X: 0, Y: 0}, 38 | } 39 | at.displayedTextBinding = binding.NewString() 40 | at.displayedTextBinding.Set(text) 41 | at.textEntry = widget.NewEntryWithData(at.displayedTextBinding) 42 | at.displayedTextBinding.AddListener(at) 43 | at.textEntry.Wrapping = fyne.TextWrapOff 44 | at.textEntry.Scroll = container.ScrollNone 45 | at.textEntry.Validator = nil 46 | at.ExtendBaseWidget(at) 47 | return at 48 | } 49 | 50 | // CreateRenderer is the required method for a widget extension 51 | func (at *AnchoredText) CreateRenderer() fyne.WidgetRenderer { 52 | atr := &anchoredTextRenderer{ 53 | widget: at, 54 | } 55 | atr.Refresh() 56 | 57 | return atr 58 | } 59 | 60 | // DataChanged is the callback function for the displayedTextBinding. 61 | func (at *AnchoredText) DataChanged() { 62 | at.Refresh() 63 | } 64 | 65 | // Displace moves the anchored text relative to its reference position. 66 | func (at *AnchoredText) Displace(delta fyne.Position) { 67 | at.Move(at.Position().Add(delta)) 68 | } 69 | 70 | // DragEnd is one of the required methods for a draggable widget. It just refreshes the widget. 71 | func (at *AnchoredText) DragEnd() { 72 | at.Refresh() 73 | } 74 | 75 | // Dragged is the required method for a draggable widget. It moves the anchored text 76 | // relative to its reference position 77 | func (at *AnchoredText) Dragged(event *fyne.DragEvent) { 78 | delta := fyne.Position{X: event.Dragged.DX, Y: event.Dragged.DY} 79 | at.Move(at.Position().Add(delta)) 80 | at.Refresh() 81 | } 82 | 83 | // GetDisplayedTextBinding returns the binding for the displayed text 84 | func (at *AnchoredText) GetDisplayedTextBinding() binding.String { 85 | return at.displayedTextBinding 86 | } 87 | 88 | // GetTextEntry returns the entry widget 89 | func (at *AnchoredText) GetTextEntry() *widget.Entry { 90 | return at.textEntry 91 | } 92 | 93 | // MinSize returns the size of the entry widget plus a one-pixel border 94 | func (at *AnchoredText) MinSize() fyne.Size { 95 | textEntryMinSize := at.textEntry.MinSize() 96 | minSize := fyne.NewSize(textEntryMinSize.Width+10, textEntryMinSize.Height+10) 97 | return minSize 98 | } 99 | 100 | // MouseIn is one of the required methods for a mouseable widget. 101 | func (at *AnchoredText) MouseIn(event *desktop.MouseEvent) { 102 | } 103 | 104 | // MouseMoved is one of the required methods for a mouseable widget 105 | func (at *AnchoredText) MouseMoved(event *desktop.MouseEvent) { 106 | 107 | } 108 | 109 | // MouseOut is one of the required methods for a mouseable widget 110 | func (at *AnchoredText) MouseOut() { 111 | } 112 | 113 | // Move overrides the BaseWidget's Move method. It updates the anchored text's offset 114 | // and then calls the normal BaseWidget.Move method. 115 | func (at *AnchoredText) Move(position fyne.Position) { 116 | delta := r2.MakeVec2(float64(position.X-at.Position().X), float64(position.Y-at.Position().Y)) 117 | at.offset = at.offset.Add(delta) 118 | at.BaseWidget.Move(position) 119 | } 120 | 121 | // SetForegroundColor sets the text color 122 | func (at *AnchoredText) SetForegroundColor(fc color.Color) { 123 | at.ForegroundColor = fc 124 | at.Refresh() 125 | } 126 | 127 | // SetReferencePosition sets the reference position of the anchored text and calls 128 | // the BaseWidget.Move() method to actually move the displayed text 129 | func (at *AnchoredText) SetReferencePosition(position fyne.Position) { 130 | delta := fyne.Delta{DX: float32(position.X - at.referencePosition.X), DY: float32(position.Y - at.referencePosition.Y)} 131 | // We don't want to change the offset here, so we call the BaseWidget.Move directly 132 | at.BaseWidget.Move(at.Position().Add(delta)) 133 | at.referencePosition = position 134 | } 135 | 136 | // anchoredTextRenderer 137 | type anchoredTextRenderer struct { 138 | widget *AnchoredText 139 | } 140 | 141 | func (atr *anchoredTextRenderer) Destroy() { 142 | 143 | } 144 | 145 | func (atr *anchoredTextRenderer) Layout(size fyne.Size) { 146 | } 147 | 148 | func (atr *anchoredTextRenderer) MinSize() fyne.Size { 149 | return atr.widget.textEntry.MinSize() 150 | } 151 | 152 | func (atr *anchoredTextRenderer) Objects() []fyne.CanvasObject { 153 | canvasObjects := []fyne.CanvasObject{ 154 | atr.widget.textEntry, 155 | } 156 | return canvasObjects 157 | } 158 | 159 | func (atr *anchoredTextRenderer) Refresh() { 160 | atr.widget.Resize(atr.widget.MinSize()) 161 | atr.widget.textEntry.Resize(atr.widget.textEntry.MinSize()) 162 | atr.widget.textEntry.Move(fyne.NewPos(5, 5)) 163 | atr.widget.textEntry.Refresh() 164 | } 165 | -------------------------------------------------------------------------------- /widget/diagramwidget/arrowhead.go: -------------------------------------------------------------------------------- 1 | package diagramwidget 2 | 3 | import ( 4 | "image/color" 5 | "math" 6 | 7 | "fyne.io/x/fyne/widget/diagramwidget/geometry/r2" 8 | 9 | "fyne.io/fyne/v2" 10 | "fyne.io/fyne/v2/canvas" 11 | "fyne.io/fyne/v2/theme" 12 | "fyne.io/fyne/v2/widget" 13 | ) 14 | 15 | const ( 16 | defaultTheta float64 = 0.5235 // 30 degrees in radians 17 | defaultLength int = 15 18 | ) 19 | 20 | // Arrowhead defines a canvas object which renders an arrow. The arrowhead is defined with reference 21 | // to X axis, with the tip of the arrow being at the origin. When rendered, the arrowhead is rotated 22 | // to match the angle of the link's line segment with which it is oriented, indicated by the baseAngle. 23 | type Arrowhead struct { 24 | widget.BaseWidget 25 | link *BaseDiagramLink 26 | // baseAngle is used to define direction in which the arrowhead points 27 | // Base fyne.Position 28 | baseAngle float64 29 | // Position() is the point at which the tip of the arrow will be placed. 30 | // StrokeWidth is the width of the arrowhead lines 31 | StrokeWidth float32 32 | // StrokeColor is the color of the arrowhead 33 | StrokeColor color.Color 34 | // Theta is the angle between each of the tails and the nominal reference axis. 35 | // This angle is in radians. 36 | Theta float64 37 | // Length is the length of the two "tails" that intersect at the tip. 38 | Length int 39 | // central *canvas.Line 40 | // left *canvas.Line 41 | // right *canvas.Line 42 | visible bool 43 | } 44 | 45 | // NewArrowhead creates an arrowhead with defaults 46 | func NewArrowhead() *Arrowhead { 47 | a := &Arrowhead{ 48 | baseAngle: 0.0, 49 | StrokeWidth: defaultStrokeWidth, 50 | StrokeColor: theme.Color(theme.ColorNameForeground), 51 | Theta: defaultTheta, 52 | Length: defaultLength, 53 | visible: true, 54 | } 55 | a.ExtendBaseWidget(a) 56 | return a 57 | } 58 | 59 | // CreateRenderer creates a renderer for the Arrowhead 60 | func (a *Arrowhead) CreateRenderer() fyne.WidgetRenderer { 61 | ar := arrowheadRenderer{ 62 | arrowhead: a, 63 | left: canvas.NewLine(a.link.properties.ForegroundColor), 64 | right: canvas.NewLine(a.link.properties.ForegroundColor), 65 | } 66 | return &ar 67 | } 68 | 69 | // GetReferenceLength returns the length of the decoration along the reference axis 70 | func (a *Arrowhead) GetReferenceLength() float32 { 71 | return float32(math.Abs(math.Cos(float64(a.Theta)) * float64(a.Length))) 72 | } 73 | 74 | // LeftPoint returns the position of the end of the left half of the arrowhead 75 | func (a *Arrowhead) LeftPoint() fyne.Position { 76 | leftAngle := r2.AddAngles(a.baseAngle, -a.Theta) 77 | // We have to change the sign of Y because the window coordinate Y axis goes down rather than up 78 | leftPosition := fyne.Position{ 79 | X: float32(float64(a.Length) * math.Cos(leftAngle)), 80 | Y: -float32(float64(a.Length) * math.Sin(leftAngle)), 81 | } 82 | return leftPosition 83 | } 84 | 85 | // MinSize returns the minimum size which is the actual size of the arrowhead 86 | func (a *Arrowhead) MinSize() fyne.Size { 87 | return a.Size() 88 | } 89 | 90 | // Resize scales the arrowhead 91 | func (a *Arrowhead) Resize(s fyne.Size) { 92 | // We get the current size and scale the length based on the difference between sizes 93 | currentSize := a.Size() 94 | currentLengthVector := r2.V2(float64(currentSize.Width), float64(currentSize.Height)) 95 | currentLength := currentLengthVector.Length() 96 | newLengthVector := r2.V2(float64(s.Width), float64(s.Height)) 97 | newLength := newLengthVector.Length() 98 | a.Length = int(float64(a.Length) * newLength / currentLength) 99 | } 100 | 101 | // RightPoint returns the position of the end of the right half of the arrowhead 102 | func (a *Arrowhead) RightPoint() fyne.Position { 103 | rightAngle := r2.AddAngles(a.baseAngle, a.Theta) 104 | // We have to change the sign of Y because the window coordinate Y axis goes down rather than up 105 | rightPosition := fyne.Position{ 106 | X: float32(float64(a.Length) * math.Cos(rightAngle)), 107 | Y: -float32(float64(a.Length) * math.Sin(rightAngle)), 108 | } 109 | return rightPosition 110 | } 111 | 112 | // setBaseAngle sets the angle (in radians) of the reference axis 113 | func (a *Arrowhead) setBaseAngle(angle float64) { 114 | a.baseAngle = angle 115 | } 116 | 117 | // setLink sets the DiagramLink on which this arrowhead appears 118 | func (a *Arrowhead) setLink(link *BaseDiagramLink) { 119 | a.link = link 120 | } 121 | 122 | // SetFillColor is a noop for the arrowhead 123 | func (a *Arrowhead) SetFillColor(fillColor color.Color) { 124 | 125 | } 126 | 127 | // SetSolid is a noop because the arrowhead is an open structure 128 | func (a *Arrowhead) SetSolid(bool) { 129 | } 130 | 131 | // SetStrokeColor sets the color used to draw the arrowhead 132 | func (a *Arrowhead) SetStrokeColor(strokeColor color.Color) { 133 | a.StrokeColor = strokeColor 134 | } 135 | 136 | // SetStrokeWidth sets the width of the lines used to render the arrowhead 137 | func (a *Arrowhead) SetStrokeWidth(strokeWidth float32) { 138 | a.StrokeWidth = strokeWidth 139 | } 140 | 141 | // Size returns the size of the arrowhead 142 | func (a *Arrowhead) Size() fyne.Size { 143 | lp := a.LeftPoint() 144 | rp := a.RightPoint() 145 | points := []r2.Vec2{ 146 | {X: float64(a.Position().X), Y: float64(a.Position().Y)}, 147 | {X: float64(lp.X), Y: float64(lp.Y)}, 148 | {X: float64(rp.X), Y: float64(rp.Y)}, 149 | } 150 | 151 | bounding := r2.BoundingBox(points) 152 | return fyne.Size{ 153 | Width: float32(bounding.Width()), 154 | Height: float32(bounding.Height()), 155 | } 156 | } 157 | 158 | type arrowheadRenderer struct { 159 | arrowhead *Arrowhead 160 | left *canvas.Line 161 | right *canvas.Line 162 | } 163 | 164 | func (ar *arrowheadRenderer) Destroy() { 165 | } 166 | 167 | func (ar *arrowheadRenderer) MinSize() fyne.Size { 168 | return ar.arrowhead.Size() 169 | } 170 | 171 | func (ar *arrowheadRenderer) Layout(size fyne.Size) { 172 | ar.left.Position1 = fyne.Position{X: 0, Y: 0} 173 | ar.left.Position2 = ar.arrowhead.LeftPoint() 174 | ar.right.Position1 = fyne.Position{X: 0, Y: 0} 175 | ar.right.Position2 = ar.arrowhead.RightPoint() 176 | } 177 | 178 | func (ar *arrowheadRenderer) Objects() []fyne.CanvasObject { 179 | obj := []fyne.CanvasObject{ 180 | ar.left, 181 | ar.right, 182 | } 183 | return obj 184 | } 185 | 186 | func (ar *arrowheadRenderer) Refresh() { 187 | ar.left.StrokeWidth = ar.arrowhead.StrokeWidth 188 | ar.right.StrokeWidth = ar.arrowhead.StrokeWidth 189 | ar.left.StrokeColor = ar.arrowhead.StrokeColor 190 | ar.right.StrokeColor = ar.arrowhead.StrokeColor 191 | ar.left.Position1 = fyne.Position{X: 0, Y: 0} 192 | ar.left.Position2 = ar.arrowhead.LeftPoint() 193 | ar.right.Position1 = fyne.Position{X: 0, Y: 0} 194 | ar.right.Position2 = ar.arrowhead.RightPoint() 195 | ar.left.Refresh() 196 | ar.right.Refresh() 197 | } 198 | -------------------------------------------------------------------------------- /widget/diagramwidget/decoration.go: -------------------------------------------------------------------------------- 1 | package diagramwidget 2 | 3 | import ( 4 | "image/color" 5 | 6 | "fyne.io/fyne/v2" 7 | ) 8 | 9 | const ( 10 | defaultStrokeWidth float32 = 1 11 | ) 12 | 13 | // Decoration is a widget intended to be used as a decoration on a Link widget 14 | // The graphical representation of the widget is defined along a reference axis with 15 | // one point on that axis designated as the reference point (generally the origin). 16 | // Depending on the Link widget's use of the decoration, the reference point will either 17 | // be aligned with one of the endpoints of the link or with some intermediate point on the 18 | // link. The Link will move the Decoration's reference point as the link itself is modified. 19 | // The Link will also determine the slope of the Link's line at the reference point and 20 | // direct the Decoration to rotate about the reference point to achieve the correct alignment 21 | // of the decoration with respect to the Link's line. 22 | // The Link may have more than one decoration stacked along the line at the reference point. 23 | // To accomplish this, it needs to know the length of the decoration along the reference axis 24 | // so that it can adjust the position of the next decoration appropriately. 25 | type Decoration interface { 26 | fyne.Widget 27 | setLink(link *BaseDiagramLink) 28 | // setBaseAngle sets the angle of the reference axis 29 | setBaseAngle(angle float64) // Angle in radians 30 | SetFillColor(color color.Color) 31 | // SetSolid determines whether the stroke color is used to fill the decoration 32 | // It has no impact if the decoration is open 33 | SetSolid(bool) 34 | // SetStrokeColor sets the color to be used for lines in the decoration 35 | SetStrokeColor(color color.Color) 36 | // SetStrokeWidth sets the width of the lines to be used in the decoration 37 | SetStrokeWidth(width float32) 38 | // GetReferenceLength returns the length of the decoration along the reference axis 39 | GetReferenceLength() float32 40 | } 41 | -------------------------------------------------------------------------------- /widget/diagramwidget/diagramElement.go: -------------------------------------------------------------------------------- 1 | package diagramwidget 2 | 3 | import ( 4 | "image/color" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/widget" 8 | ) 9 | 10 | // DiagramElementProperties are the rendering properties of a DiagramElement 11 | type DiagramElementProperties struct { 12 | ForegroundColor color.Color 13 | BackgroundColor color.Color 14 | HandleColor color.Color 15 | PadColor color.Color 16 | TextSize float32 17 | CaptionTextSize float32 18 | Padding float32 19 | StrokeWidth float32 20 | PadStrokeWidth float32 21 | HandleStrokeWidth float32 22 | } 23 | 24 | // DiagramElement is a widget that can be placed directly in a diagram. The most common 25 | // elements are Node and Link widgets. 26 | type DiagramElement interface { 27 | fyne.Widget 28 | // GetBackgroundColor returns the background color for the widget 29 | GetBackgroundColor() color.Color 30 | // GetConnectionPads() returns all of the connection pads on the element 31 | GetConnectionPads() map[string]ConnectionPad 32 | // GetForegroundColor returns the foreground color for the widget 33 | GetForegroundColor() color.Color 34 | // GetDefaultConnectionPad returns the default pad for the DiagramElement 35 | GetDefaultConnectionPad() ConnectionPad 36 | // GetDiagram returns the DiagramWidget to which the DiagramElement belongs 37 | GetDiagram() *DiagramWidget 38 | // GetDiagramElementID returns the string identifier provided at the time the DiagramElement was created 39 | GetDiagramElementID() string 40 | // GetHandle returns the handle with the indicated index name 41 | GetHandle(string) *Handle 42 | // GetHandleColor returns the color for the element's handles 43 | GetHandleColor() color.Color 44 | // GetPadColor returns the color for the element's pads 45 | GetPadColor() color.Color 46 | // GetProperties returns the properties of the DiagramElement 47 | GetProperties() DiagramElementProperties 48 | // handleDragged responds to drag events 49 | handleDragged(handle *Handle, event *fyne.DragEvent) 50 | // handleDragEnd responds to the end of a drag 51 | handleDragEnd(handle *Handle) 52 | // HideHandles hides the handles on the DiagramElement 53 | HideHandles() 54 | // IsLink returns true if the diagram element is a link 55 | IsLink() bool 56 | // IsNode returns true of the diagram element is a node 57 | IsNode() bool 58 | // Position returns the position of the diagram element 59 | Position() fyne.Position 60 | // SetForegroundColor sets the foreground color for the widget 61 | SetForegroundColor(color.Color) 62 | // SetBackgroundColor sets the background color for the widget 63 | SetBackgroundColor(color.Color) 64 | // SetProperties sets the foreground, background, and handle colors 65 | SetProperties(DiagramElementProperties) 66 | // ShowHandles shows the handles on the DiagramElement 67 | ShowHandles() 68 | // Size returns the size of the diagram element 69 | Size() fyne.Size 70 | } 71 | 72 | type diagramElement struct { 73 | widget.BaseWidget 74 | diagram *DiagramWidget 75 | properties DiagramElementProperties 76 | // foregroundColor color.Color 77 | // backgroundColor color.Color 78 | // handleColor color.Color 79 | id string 80 | handles map[string]*Handle 81 | pads map[string]ConnectionPad 82 | } 83 | 84 | func (de *diagramElement) GetDiagram() *DiagramWidget { 85 | return de.diagram 86 | } 87 | 88 | func (de *diagramElement) GetDiagramElementID() string { 89 | return de.id 90 | } 91 | 92 | func (de *diagramElement) GetBackgroundColor() color.Color { 93 | return de.properties.BackgroundColor 94 | } 95 | 96 | func (de *diagramElement) GetConnectionPads() map[string]ConnectionPad { 97 | return de.pads 98 | } 99 | 100 | func (de *diagramElement) GetForegroundColor() color.Color { 101 | return de.properties.ForegroundColor 102 | } 103 | 104 | func (de *diagramElement) GetHandle(handleName string) *Handle { 105 | return de.handles[handleName] 106 | } 107 | 108 | func (de *diagramElement) GetHandleColor() color.Color { 109 | return de.properties.HandleColor 110 | } 111 | 112 | func (de *diagramElement) GetPadColor() color.Color { 113 | return de.properties.PadColor 114 | } 115 | 116 | func (de *diagramElement) GetProperties() DiagramElementProperties { 117 | return de.properties 118 | } 119 | 120 | func (de *diagramElement) HideHandles() { 121 | for _, handle := range de.handles { 122 | handle.Hide() 123 | } 124 | } 125 | 126 | func (de *diagramElement) initialize(diagram *DiagramWidget, id string) { 127 | de.diagram = diagram 128 | de.id = id 129 | de.handles = make(map[string]*Handle) 130 | de.properties = de.diagram.DefaultDiagramElementProperties 131 | de.pads = make(map[string]ConnectionPad) 132 | } 133 | 134 | func (de *diagramElement) SetBackgroundColor(backgroundColor color.Color) { 135 | de.properties.BackgroundColor = backgroundColor 136 | de.Refresh() 137 | } 138 | 139 | func (de *diagramElement) SetForegroundColor(foregroundColor color.Color) { 140 | de.properties.ForegroundColor = foregroundColor 141 | de.Refresh() 142 | } 143 | 144 | func (de *diagramElement) SetHandleColor(handleColor color.Color) { 145 | de.properties.HandleColor = handleColor 146 | de.Refresh() 147 | } 148 | 149 | func (de *diagramElement) SetProperties(properties DiagramElementProperties) { 150 | de.properties = properties 151 | } 152 | 153 | func (de *diagramElement) ShowHandles() { 154 | for _, handle := range de.handles { 155 | handle.Show() 156 | de.Refresh() 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /widget/diagramwidget/diagram_test.go: -------------------------------------------------------------------------------- 1 | package diagramwidget 2 | 3 | import ( 4 | "testing" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/test" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestDependencies(t *testing.T) { 12 | app := test.NewApp() 13 | assert.NotNil(t, app) 14 | diagram := NewDiagramWidget("Diagram1") 15 | node1ID := "Node1" 16 | node1 := NewDiagramNode(diagram, nil, node1ID) 17 | node1.Move(fyne.NewPos(100, 100)) 18 | node2ID := "Node2" 19 | node2 := NewDiagramNode(diagram, nil, node2ID) 20 | node2.Move(fyne.NewPos(200, 100)) 21 | assert.Equal(t, 0, len(diagram.diagramElementLinkDependencies)) 22 | linkID := "Link1" 23 | link := NewDiagramLink(diagram, linkID) 24 | link.SetSourcePad(node1.GetDefaultConnectionPad()) 25 | link.SetTargetPad(node2.GetDefaultConnectionPad()) 26 | assert.NotNil(t, link) 27 | assert.Equal(t, 2, len(diagram.diagramElementLinkDependencies)) 28 | 29 | node1Dependencies := diagram.diagramElementLinkDependencies[node1ID] 30 | assert.Equal(t, 1, len(node1Dependencies)) 31 | assert.Equal(t, link, node1Dependencies[0].link) 32 | assert.Equal(t, node1.GetDefaultConnectionPad(), node1Dependencies[0].pad) 33 | 34 | node2Dependencies := diagram.diagramElementLinkDependencies[node2ID] 35 | assert.Equal(t, 1, len(node2Dependencies)) 36 | assert.Equal(t, link, node2Dependencies[0].link) 37 | assert.Equal(t, node2.GetDefaultConnectionPad(), node2Dependencies[0].pad) 38 | 39 | // Now test the dependency management when a node is deleted 40 | diagram.RemoveElement(node2ID) 41 | assert.Nil(t, diagram.GetDiagramElement(node2ID)) 42 | assert.Nil(t, diagram.GetDiagramElement(linkID)) 43 | assert.Equal(t, 0, len(diagram.diagramElementLinkDependencies)) 44 | 45 | } 46 | -------------------------------------------------------------------------------- /widget/diagramwidget/geometry/geometry.go: -------------------------------------------------------------------------------- 1 | // Package geometry implements various useful geometric primitives. 2 | package geometry 3 | -------------------------------------------------------------------------------- /widget/diagramwidget/geometry/r2/box.go: -------------------------------------------------------------------------------- 1 | package r2 2 | 3 | // Box defines a box in R2 4 | // 5 | // A 6 | // | 7 | // | 8 | // | 9 | // v 10 | // (1) A.X,A.Y +------+ A.X+S.X,A.Y (2) 11 | // |\ | 12 | // | \ | 13 | // | \ | 14 | // | \S | 15 | // | \ | 16 | // | \| 17 | // 18 | // (3)A.X,A.Y+S.Y +------+ A.X+S.X,A.Y+S.Y (4) 19 | type Box struct { 20 | 21 | // A defines the top-left corner of the box 22 | A Vec2 23 | 24 | // S defines the size of the box 25 | S Vec2 26 | } 27 | 28 | // MakeBox creates an r2 Box 29 | func MakeBox(a, s Vec2) Box { 30 | return Box{ 31 | A: a, 32 | S: s, 33 | } 34 | } 35 | 36 | // Area returns the area of the Box 37 | func (b Box) Area() float64 { 38 | return b.S.X * b.S.Y 39 | } 40 | 41 | // FindPerimeterPointNearestContainedPoint returns the perimiter point closest to the contained point. 42 | // If the point is not actually within the Box, it returns a (0,0) vector 43 | func (b Box) FindPerimeterPointNearestContainedPoint(containedPoint Vec2) Vec2 { 44 | if !b.Contains(containedPoint) { 45 | return MakeVec2(0, 0) 46 | } 47 | top := b.GetCorner1().Y 48 | left := b.GetCorner1().X 49 | bottom := b.GetCorner4().Y 50 | right := b.GetCorner4().X 51 | topDistance := containedPoint.Y - top 52 | leftDistance := containedPoint.X - left 53 | bottomDistance := bottom - containedPoint.Y 54 | rightDistance := right - containedPoint.X 55 | if bottomDistance > topDistance { 56 | // top is closer 57 | if rightDistance > leftDistance { 58 | // left is closer 59 | if leftDistance > topDistance { 60 | // top is the closest 61 | return MakeVec2(containedPoint.X, top) 62 | } 63 | // left is the closest 64 | return MakeVec2(left, containedPoint.Y) 65 | } 66 | // right is closer 67 | if rightDistance > topDistance { 68 | // top is the closest 69 | return MakeVec2(containedPoint.X, top) 70 | } 71 | // right is the closest 72 | return MakeVec2(right, containedPoint.Y) 73 | } 74 | // bottom is closer 75 | if rightDistance > leftDistance { 76 | // left is closer 77 | if leftDistance > bottomDistance { 78 | // bottom is the closest 79 | return MakeVec2(containedPoint.X, bottom) 80 | } 81 | // left is the closest 82 | return MakeVec2(left, containedPoint.Y) 83 | } 84 | // right is closer 85 | if rightDistance > bottomDistance { 86 | // bottom is the closest 87 | return MakeVec2(containedPoint.X, bottom) 88 | } 89 | // right is the closest 90 | return MakeVec2(right, containedPoint.Y) 91 | } 92 | 93 | // GetCorner1 returns the top left corner of the box 94 | func (b Box) GetCorner1() Vec2 { 95 | return b.A 96 | } 97 | 98 | // GetCorner2 returns the top right corner of the box 99 | func (b Box) GetCorner2() Vec2 { 100 | return b.A.Add(V2(b.S.X, 0)) 101 | } 102 | 103 | // GetCorner3 returns the bottom left corner of the box. 104 | func (b Box) GetCorner3() Vec2 { 105 | return b.A.Add(V2(0, b.S.Y)) 106 | } 107 | 108 | // GetCorner4 returns the bottom right corner of the box. 109 | func (b Box) GetCorner4() Vec2 { 110 | return b.A.Add(V2(b.S.X, b.S.Y)) 111 | } 112 | 113 | // Intersect returns the intersection of the box and the line, and a Boolean indicating 114 | // if the box and vector intersect. If they do not collide, the zero vector is 115 | // returned. 116 | func (b Box) Intersect(l Line) (Vec2, bool) { 117 | // This is transliterated in part from: 118 | // 119 | // https://github.com/JulNadeauCA/libagar/blob/master/gui/primitive.c 120 | 121 | faces := []Line{ 122 | b.Top(), 123 | b.Left(), 124 | b.Right(), 125 | b.Bottom(), 126 | } 127 | 128 | dists := []float64{-1, -1, -1, -1} 129 | intersects := []bool{false, false, false, false} 130 | intersectPoints := make([]Vec2, 4) 131 | 132 | shortestDist := float64(-1.0) 133 | best := -1 134 | 135 | for i := range faces { 136 | in, ok := IntersectLines(faces[i], l) 137 | if !ok { 138 | continue 139 | } 140 | dists[i] = in.Length() 141 | intersects[i] = ok 142 | intersectPoints[i] = in 143 | 144 | if (dists[i] < shortestDist) || (shortestDist == float64(-1)) { 145 | shortestDist = dists[i] 146 | best = i 147 | } 148 | } 149 | 150 | if shortestDist < 0 { 151 | return V2(0, 0), false 152 | } 153 | 154 | return intersectPoints[best], true 155 | } 156 | 157 | // Top returns the top face of the box. 158 | func (b Box) Top() Line { 159 | return MakeLineFromEndpoints(b.GetCorner1(), b.GetCorner2()) 160 | } 161 | 162 | // Left returns the left face of the box. 163 | func (b Box) Left() Line { 164 | return MakeLineFromEndpoints(b.GetCorner1(), b.GetCorner3()) 165 | } 166 | 167 | // Right returns the right face of the box. 168 | func (b Box) Right() Line { 169 | return MakeLineFromEndpoints(b.GetCorner2(), b.GetCorner4()) 170 | } 171 | 172 | // Bottom returns the bottom face of the box. 173 | func (b Box) Bottom() Line { 174 | return MakeLineFromEndpoints(b.GetCorner3(), b.GetCorner4()) 175 | } 176 | 177 | // Center returns the center of the Box as an r2 vector 178 | func (b Box) Center() Vec2 { 179 | return b.A.Add(b.S.Scale(0.5)) 180 | } 181 | 182 | // Contains returns true if the point v is within the box b. 183 | func (b Box) Contains(v Vec2) bool { 184 | if (v.X < b.GetCorner1().X) || (v.X > b.GetCorner2().X) { 185 | return false 186 | } 187 | 188 | if (v.Y < b.GetCorner1().Y) || (v.Y > b.GetCorner3().Y) { 189 | return false 190 | } 191 | 192 | return true 193 | } 194 | 195 | // BoundingBox creates a minimum axis-aligned bounding box for the given list 196 | // of points. 197 | func BoundingBox(points []Vec2) Box { 198 | if len(points) < 2 { 199 | return MakeBox(V2(0, 0), V2(0, 0)) 200 | } 201 | var xMin, xMax, yMin, yMax float64 202 | for i, p := range points { 203 | if i == 0 { 204 | xMin = p.X 205 | xMax = p.X 206 | yMin = p.Y 207 | yMax = p.Y 208 | } else { 209 | if p.X < xMin { 210 | xMin = p.X 211 | } 212 | if p.Y < yMin { 213 | yMin = p.Y 214 | } 215 | if p.X > xMax { 216 | xMax = p.X 217 | } 218 | if p.Y > yMax { 219 | yMax = p.Y 220 | } 221 | } 222 | } 223 | // MakeBox expects the first point to be the upper left, second the bottom right 224 | return MakeBox(V2(xMin, yMax), V2(xMax-xMin, yMin-yMax)) 225 | } 226 | 227 | // Width returns the width of the Box 228 | func (b Box) Width() float64 { 229 | return b.Top().Length() 230 | } 231 | 232 | // Height returns the height of the box 233 | func (b Box) Height() float64 { 234 | return b.Left().Length() 235 | } 236 | -------------------------------------------------------------------------------- /widget/diagramwidget/geometry/r2/box_test.go: -------------------------------------------------------------------------------- 1 | package r2 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestContains(t *testing.T) { 8 | var top, bottom, left, right float64 9 | top = 100 10 | left = 100 11 | right = 200 12 | bottom = 200 13 | upperLeft := MakeVec2(left, top) 14 | sVector := MakeVec2(right-left, bottom-top) 15 | box := MakeBox(upperLeft, sVector) 16 | // test point not being contained 17 | cp := MakeVec2(50, 50) 18 | if box.Contains(cp) { 19 | // this should have returned false 20 | t.Errorf("Contains returned true for a point not in the box") 21 | } 22 | cp.X = 150 23 | cp.Y = 150 24 | if !box.Contains(cp) { 25 | // this should have returned true 26 | t.Errorf("Contains returned false for a point in the box") 27 | } 28 | } 29 | 30 | func TestFindPerimeterPointNearestContainedPoint(t *testing.T) { 31 | var top, bottom, left, right float64 32 | top = 100 33 | left = 100 34 | right = 200 35 | bottom = 200 36 | upperLeft := MakeVec2(left, top) 37 | sVector := MakeVec2(right-left, bottom-top) 38 | box := MakeBox(upperLeft, sVector) 39 | // test point not being contained 40 | cp := MakeVec2(50, 50) 41 | result := box.FindPerimeterPointNearestContainedPoint(cp) 42 | if result.X != 0 || result.Y != 0 { 43 | t.Errorf("Non-contained point did not return 0,0, got %f, %f", result.X, result.Y) 44 | } 45 | // test top 46 | cp.X = 140 47 | cp.Y = 125 48 | result = box.FindPerimeterPointNearestContainedPoint(cp) 49 | if result.X != 140 || result.Y != top { 50 | t.Errorf("Point on top not returned, expected 140, 100, got %f, %f", result.X, result.Y) 51 | } 52 | // test left 53 | cp.X = 125 54 | cp.Y = 140 55 | result = box.FindPerimeterPointNearestContainedPoint(cp) 56 | if result.X != left || result.Y != 140 { 57 | t.Errorf("Point on left not returned, expected 100, 140, got %f, %f", result.X, result.Y) 58 | } 59 | // test bottom 60 | cp.X = 140 61 | cp.Y = 175 62 | result = box.FindPerimeterPointNearestContainedPoint(cp) 63 | if result.X != 140 || result.Y != bottom { 64 | t.Errorf("Point on bottom not returned, expected 140, 200, got %f, %f", result.X, result.Y) 65 | } 66 | // test right 67 | cp.X = 175 68 | cp.Y = 140 69 | result = box.FindPerimeterPointNearestContainedPoint(cp) 70 | if result.X != right || result.Y != 140 { 71 | t.Errorf("Point on right not returned, expected 200, 140, got %f, %f", result.X, result.Y) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /widget/diagramwidget/geometry/r2/geometry.go: -------------------------------------------------------------------------------- 1 | package r2 2 | -------------------------------------------------------------------------------- /widget/diagramwidget/geometry/r2/line.go: -------------------------------------------------------------------------------- 1 | package r2 2 | 3 | // Line describes a line in R2 4 | // 5 | // (1) A.X,A.Y + 6 | // \ 7 | // \ 8 | // \ 9 | // \ 10 | // + A.X+S.X,A.Y+S.Y (2) 11 | type Line struct { 12 | // A defines the basis point of the line 13 | A Vec2 14 | 15 | // S defines the direction and length of the line 16 | S Vec2 17 | } 18 | 19 | // MakeLine crates an r2 Line 20 | func MakeLine(a, s Vec2) Line { 21 | return Line{ 22 | A: a, 23 | S: s, 24 | } 25 | } 26 | 27 | // MakeLineFromEndpoints returns a line which has endpoints a, b 28 | func MakeLineFromEndpoints(a, b Vec2) Line { 29 | s := b.Add(a.Scale(-1)) 30 | 31 | return MakeLine(a, s) 32 | } 33 | 34 | // Endpoint1 returns the first endpoint of the line 35 | func (l Line) Endpoint1() Vec2 { 36 | return l.A 37 | } 38 | 39 | // Endpoint2 returns the second endpoint of the line 40 | func (l Line) Endpoint2() Vec2 { 41 | return l.A.Add(l.S) 42 | } 43 | 44 | func samesign(a, b float64) bool { 45 | if (a < 0) && (b < 0) { 46 | return true 47 | } 48 | 49 | if (a > 0) && (b > 0) { 50 | return true 51 | } 52 | 53 | if a == b { 54 | return true 55 | } 56 | 57 | return false 58 | } 59 | 60 | // Length returns the length of the line 61 | func (l Line) Length() float64 { 62 | return l.S.Length() 63 | } 64 | 65 | // IntersectLines This code is transliterated from here: 66 | // 67 | // https://github.com/JulNadeauCA/libagar/blob/master/gui/primitive.co 68 | // 69 | // Which is in turn based on Gem I.2 in Graphics Gems II by James Arvo. 70 | func IntersectLines(l1, l2 Line) (Vec2, bool) { 71 | x1 := l1.Endpoint1().X 72 | y1 := l1.Endpoint1().Y 73 | x2 := l1.Endpoint2().X 74 | y2 := l1.Endpoint2().Y 75 | x3 := l2.Endpoint1().X 76 | y3 := l2.Endpoint1().Y 77 | x4 := l2.Endpoint2().X 78 | y4 := l2.Endpoint2().Y 79 | 80 | a1 := y2 - y1 81 | b1 := x1 - x2 82 | c1 := x2*y1 - x1*y2 83 | 84 | r3 := a1*x3 + b1*y3 + c1 85 | r4 := a1*x4 + b1*y4 + c1 86 | 87 | if (r3 != 0) && (r4 != 0) && samesign(r3, r4) { 88 | return V2(0, 0), false 89 | } 90 | 91 | a2 := y4 - y3 92 | b2 := x3 - x4 93 | c2 := x4*y3 - x3*y4 94 | 95 | r1 := a2*x1 + b2*y1 + c2 96 | r2 := a2*x2 + b2*y2 + c2 97 | 98 | if (r1 != 0) && (r2 != 0) && samesign(r1, r2) { 99 | return V2(0, 0), false 100 | } 101 | 102 | denom := a1*b2 - a2*b1 103 | if denom == 0 { 104 | return V2(0, 0), false 105 | } 106 | 107 | offset := 0.0 - denom/2.0 108 | if denom < 0 { 109 | offset = denom / 2.0 110 | } 111 | 112 | num := b1*c2 - b2*c1 113 | xi := 0.0 114 | if num < 0 { 115 | xi = num - offset 116 | } else { 117 | xi = num + offset 118 | } 119 | xi /= denom 120 | 121 | num = a2*c1 - a1*c2 122 | yi := 0.0 123 | if num < 0 { 124 | yi = num - offset 125 | } else { 126 | yi = num + offset 127 | } 128 | yi /= denom 129 | 130 | return V2(xi, yi), true 131 | 132 | } 133 | -------------------------------------------------------------------------------- /widget/diagramwidget/geometry/r2/vec2.go: -------------------------------------------------------------------------------- 1 | // Package r2 implements operations relating to objects in R2. 2 | package r2 3 | 4 | import ( 5 | "math" 6 | ) 7 | 8 | // Vec2 implements a vector in R2 9 | type Vec2 struct { 10 | // X magnitude of the vector 11 | X float64 12 | 13 | // Y magnitude of the vector 14 | Y float64 15 | } 16 | 17 | // MakeVec2 creates a new vector inline 18 | func MakeVec2(x, y float64) Vec2 { 19 | return Vec2{X: x, Y: y} 20 | } 21 | 22 | // V2 is a shortcut for MakeVec2 23 | func V2(x, y float64) Vec2 { 24 | return MakeVec2(x, y) 25 | } 26 | 27 | // Length return the vector length 28 | func (v Vec2) Length() float64 { 29 | return math.Sqrt(math.Pow(v.X, 2) + math.Pow(v.Y, 2)) 30 | } 31 | 32 | // Dot returns the dot product of vector v and u 33 | func (v Vec2) Dot(u Vec2) float64 { 34 | return v.X*u.X + v.Y*u.Y 35 | } 36 | 37 | // Add returns the sum of vector v and u 38 | func (v Vec2) Add(u Vec2) Vec2 { 39 | return Vec2{X: v.X + u.X, Y: v.Y + u.Y} 40 | } 41 | 42 | // AddAngles adds two angles in radians. The inputs are assumed to be in the 43 | // range of +Pi to -Pi radians. The range of the result is +Pi to -Pi radians 44 | func AddAngles(a1 float64, a2 float64) float64 { 45 | angleSum := a1 + a2 46 | if math.Abs(angleSum) > math.Pi { 47 | if angleSum > 0 { 48 | angleSum = angleSum - 2*math.Pi 49 | } else { 50 | angleSum = angleSum + 2*math.Pi 51 | } 52 | } 53 | return angleSum 54 | } 55 | 56 | // Scale returns the vector v scaled by the scalar s 57 | func (v Vec2) Scale(s float64) Vec2 { 58 | return Vec2{X: v.X * s, Y: v.Y * s} 59 | } 60 | 61 | // Project returns the vector projection of v onto u 62 | func (v Vec2) Project(u Vec2) Vec2 { 63 | return u.Scale(u.Dot(v) / math.Pow(u.Length(), 2)) 64 | } 65 | 66 | // Unit returns the vector scaled to length 1 67 | func (v Vec2) Unit() Vec2 { 68 | return V2(v.X/v.Length(), v.Y/v.Length()) 69 | } 70 | 71 | // ScaleToLength keeps the vector direction, but updates the length 72 | func (v Vec2) ScaleToLength(l float64) Vec2 { 73 | return v.Unit().Scale(l) 74 | } 75 | 76 | // Angle computes the angle of the vector respect to the origin. The result is in radians. 77 | func (v Vec2) Angle() float64 { 78 | length := v.Length() 79 | yLength := v.Y 80 | baseAngle := math.Asin(yLength / length) 81 | // The base angle has range pi/2 to -pi/2. We must adjust if S.X is negative 82 | if v.X < 0 { 83 | if v.Y > 0 { 84 | baseAngle = math.Pi - baseAngle 85 | } else { 86 | baseAngle = -math.Pi - baseAngle 87 | } 88 | } 89 | return baseAngle 90 | } 91 | -------------------------------------------------------------------------------- /widget/diagramwidget/geometry/r2/vec2_test.go: -------------------------------------------------------------------------------- 1 | package r2 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | ) 7 | 8 | func TestVec2(t *testing.T) { 9 | // Test the Angle() function 10 | v1 := V2(1, 0) 11 | if v1.Angle() != 0 { 12 | t.Errorf("Angle of {1,0} failed. Expected 0, got %f", v1.Angle()) 13 | } 14 | v1 = V2(1, 0.5) 15 | tolerance := 0.000001 16 | if math.Abs(v1.Angle()-0.463647) > tolerance { 17 | t.Errorf("Angle of {1,0.5} failed. Expected 0.463647, got %f", v1.Angle()) 18 | } 19 | v1 = V2(-1, 0.5) 20 | if math.Abs(v1.Angle()-2.677945) > tolerance { 21 | t.Errorf("Angle of {-1,0.5} failed. Expected 2.677945, got %f", v1.Angle()) 22 | } 23 | v1 = V2(-1, -0.5) 24 | if math.Abs(v1.Angle()+2.677945) > tolerance { 25 | t.Errorf("Angle of {1,0.5} failed. Expected -2.677945, got %f", v1.Angle()) 26 | } 27 | v1 = V2(1, -0.5) 28 | if math.Abs(v1.Angle()+0.463647) > tolerance { 29 | t.Errorf("Angle of {1,0.5} failed. Expected -0.463647, got %f", v1.Angle()) 30 | } 31 | } 32 | 33 | func TestAngleSum(t *testing.T) { 34 | tolerance := 0.000001 35 | if math.Abs(AddAngles(0.0, 0.0)) > tolerance { 36 | t.Error("AngleSum 0 failed") 37 | } 38 | if math.Abs(AddAngles(0.0, math.Pi)-math.Pi) > tolerance { 39 | t.Error("AngleSum 0, Pi failed") 40 | } 41 | if math.Abs(AddAngles(math.Pi, math.Pi)) > tolerance { 42 | t.Error("AngleSum Pi, Pi failed") 43 | } 44 | if math.Abs(AddAngles(-math.Pi, -math.Pi)) > tolerance { 45 | t.Error("AngleSum -Pi, -Pi failed") 46 | } 47 | if math.Abs(AddAngles(0.0, math.Pi/3)-math.Pi/3) > tolerance { 48 | t.Error("AngleSum 0, Pi/3 failed") 49 | } 50 | if math.Abs(AddAngles(math.Pi/3, math.Pi/3)-2*math.Pi/3) > tolerance { 51 | t.Error("AngleSum Pi/3, Pi/3 failed") 52 | } 53 | // The following sum could return either +Pi or -Pi 54 | if math.Abs(AddAngles(math.Pi/3, 2*math.Pi/3))-math.Pi > tolerance { 55 | t.Error("AngleSum Pi/3, 2*Pi/3 failed") 56 | } 57 | if math.Abs(AddAngles(2*math.Pi/3, 2*math.Pi/3)+2*math.Pi/3) > tolerance { 58 | t.Error("AngleSum 2*Pi/3, 2*Pi/3 failed") 59 | } 60 | if math.Abs(AddAngles(-2*math.Pi/3, -2*math.Pi/3)-2*math.Pi/3) > tolerance { 61 | t.Error("AngleSum -2*Pi/3, -2*Pi/3 failed") 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /widget/diagramwidget/handle.go: -------------------------------------------------------------------------------- 1 | package diagramwidget 2 | 3 | import ( 4 | "image/color" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/canvas" 8 | "fyne.io/fyne/v2/widget" 9 | ) 10 | 11 | // Validate implementation of Draggable 12 | var _ fyne.Draggable = (*Handle)(nil) 13 | 14 | var defaultHandleSize float32 = 10.0 15 | 16 | // Handle is a widget used to manipulate the size or shape of its owning DiagramElement 17 | type Handle struct { 18 | widget.BaseWidget 19 | handleSize float32 20 | de DiagramElement 21 | } 22 | 23 | // NewHandle creates a handle for the specified DiagramElement 24 | func NewHandle(diagramElement DiagramElement) *Handle { 25 | handle := &Handle{ 26 | de: diagramElement, 27 | handleSize: defaultHandleSize, 28 | } 29 | handle.BaseWidget.ExtendBaseWidget(handle) 30 | return handle 31 | } 32 | 33 | // CreateRenderer is the required method for the Handle widget 34 | func (h *Handle) CreateRenderer() fyne.WidgetRenderer { 35 | hr := &handleRenderer{ 36 | handle: h, 37 | rect: canvas.NewRectangle(h.getStrokeColor()), 38 | } 39 | hr.rect.FillColor = color.Transparent 40 | hr.Refresh() 41 | return hr 42 | } 43 | 44 | // Dragged respondss to drag events, passing them on to the owning DiagramElement. It is the 45 | // DiagramElement that determines what to do as a result of the drag. 46 | func (h *Handle) Dragged(event *fyne.DragEvent) { 47 | h.de.handleDragged(h, event) 48 | } 49 | 50 | // DragEnd passes the event on to the owning DiagramElement 51 | func (h *Handle) DragEnd() { 52 | h.de.handleDragEnd(h) 53 | } 54 | 55 | func (h *Handle) getStrokeColor() color.Color { 56 | return h.de.GetDiagram().GetForegroundColor() 57 | } 58 | 59 | func (h *Handle) getStrokeWidth() float32 { 60 | return 1.0 61 | } 62 | 63 | // Move changes the position of the handle 64 | func (h *Handle) Move(position fyne.Position) { 65 | delta := fyne.Position{X: -h.handleSize / 2, Y: -h.handleSize / 2} 66 | h.BaseWidget.Move(position.Add(delta)) 67 | } 68 | 69 | // handleRenderer 70 | type handleRenderer struct { 71 | handle *Handle 72 | rect *canvas.Rectangle 73 | } 74 | 75 | func (hr *handleRenderer) Destroy() { 76 | 77 | } 78 | 79 | // Layout sets both the handle and its rectangle to the minimum size 80 | func (hr *handleRenderer) Layout(size fyne.Size) { 81 | hr.rect.Resize(hr.MinSize()) 82 | hr.handle.Resize(hr.MinSize()) 83 | } 84 | 85 | // MinSize returns the minimum size of the Handle widget 86 | func (hr *handleRenderer) MinSize() fyne.Size { 87 | return fyne.Size{Height: hr.handle.handleSize, Width: hr.handle.handleSize} 88 | } 89 | 90 | // Objects returns the objects that comprise the Handel 91 | func (hr *handleRenderer) Objects() []fyne.CanvasObject { 92 | obj := []fyne.CanvasObject{ 93 | hr.rect, 94 | } 95 | return obj 96 | } 97 | 98 | // Refresh re-renders the Handle after rendering properties have been changed 99 | func (hr *handleRenderer) Refresh() { 100 | hr.rect.StrokeColor = hr.handle.getStrokeColor() 101 | hr.rect.FillColor = color.Transparent 102 | hr.rect.StrokeWidth = hr.handle.getStrokeWidth() 103 | hr.rect.Refresh() 104 | } 105 | -------------------------------------------------------------------------------- /widget/diagramwidget/linkpoint.go: -------------------------------------------------------------------------------- 1 | package diagramwidget 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/widget" 6 | ) 7 | 8 | // LinkPoint identifies the point at which a link end is connected to another diagram element's connection pad 9 | type LinkPoint struct { 10 | widget.BaseWidget 11 | link DiagramLink 12 | } 13 | 14 | // NewLinkPoint creates an instance of a LinkPoint for a specific DiagramLink 15 | func NewLinkPoint(link DiagramLink) *LinkPoint { 16 | lp := &LinkPoint{} 17 | lp.BaseWidget.ExtendBaseWidget(lp) 18 | lp.link = link 19 | return lp 20 | } 21 | 22 | // CreateRenderer creates the renderere for a LinkPoint 23 | func (lp *LinkPoint) CreateRenderer() fyne.WidgetRenderer { 24 | lpr := &linkPointRenderer{} 25 | return lpr 26 | } 27 | 28 | // GetLink returns the Link to which the LinkPoint belongs 29 | func (lp *LinkPoint) GetLink() DiagramLink { 30 | return lp.link 31 | } 32 | 33 | // IsConnectionAllowed returns true if a connection is permitted with the indicated pad. The 34 | // question is passed to the owning link 35 | func (lp *LinkPoint) IsConnectionAllowed(connectionPad ConnectionPad) bool { 36 | return lp.link.isConnectionAllowed(lp, connectionPad) 37 | } 38 | 39 | // linkPointRenderer 40 | type linkPointRenderer struct { 41 | } 42 | 43 | func (lpr *linkPointRenderer) Destroy() { 44 | 45 | } 46 | 47 | func (lpr *linkPointRenderer) Layout(size fyne.Size) { 48 | 49 | } 50 | 51 | func (lpr *linkPointRenderer) MinSize() fyne.Size { 52 | return fyne.NewSize(1, 1) 53 | } 54 | 55 | func (lpr *linkPointRenderer) Objects() []fyne.CanvasObject { 56 | obj := []fyne.CanvasObject{} 57 | return obj 58 | } 59 | 60 | func (lpr *linkPointRenderer) Refresh() { 61 | 62 | } 63 | -------------------------------------------------------------------------------- /widget/diagramwidget/linksegment.go: -------------------------------------------------------------------------------- 1 | package diagramwidget 2 | 3 | import ( 4 | "math" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/canvas" 8 | "fyne.io/fyne/v2/driver/desktop" 9 | "fyne.io/fyne/v2/widget" 10 | "github.com/twpayne/go-geom" 11 | "github.com/twpayne/go-geom/xy" 12 | ) 13 | 14 | // LinkSegment is a widget representing a single line segment belonging to a link 15 | type LinkSegment struct { 16 | widget.BaseWidget 17 | link *BaseDiagramLink 18 | // p1 and p2 are coordinates in the link's coordinate space 19 | p1 fyne.Position 20 | p2 fyne.Position 21 | mouseDownPosition fyne.Position 22 | } 23 | 24 | // NewLinkSegment returns a LinkSegment belonging to the indicated Link 25 | func NewLinkSegment(link *BaseDiagramLink, p1 fyne.Position, p2 fyne.Position) *LinkSegment { 26 | ls := &LinkSegment{ 27 | link: link, 28 | p1: p1, 29 | p2: p2, 30 | } 31 | ls.BaseWidget.ExtendBaseWidget(ls) 32 | ls.Resize(ls.MinSize()) 33 | return ls 34 | } 35 | 36 | // CreateRenderer creates the renderer for the LinkSegment 37 | func (ls *LinkSegment) CreateRenderer() fyne.WidgetRenderer { 38 | lsr := &linkSegmentRenderer{ 39 | ls: ls, 40 | line: canvas.NewLine(ls.link.GetForegroundColor()), 41 | } 42 | return lsr 43 | } 44 | 45 | // MouseDown behavior depends upon the mouse event. If it is the primary button, it records the locateion of the 46 | // MouseDown in preparation for a MouseUp at the same location, which will trigger Tapped() behavior. Otherwise, if 47 | // it is the seconday button and a callback is present, it will invoke the callback 48 | func (ls *LinkSegment) MouseDown(event *desktop.MouseEvent) { 49 | if event.Button == desktop.MouseButtonPrimary { 50 | ls.mouseDownPosition = event.Position 51 | } else if event.Button == desktop.MouseButtonSecondary && ls.link.diagram.LinkSegmentMouseDownSecondaryCallback != nil { 52 | ls.link.diagram.LinkSegmentMouseDownSecondaryCallback(ls.link.typedLink, event) 53 | } 54 | } 55 | 56 | // MouseUp behavior depends on the mouse event. If it is the primary button and it is at the same location as the MouseDown, 57 | // the Tapped() behavior is invoked. Otherwise, if there is a callback present, the callback is invoked. 58 | func (ls *LinkSegment) MouseUp(event *desktop.MouseEvent) { 59 | if event.Button == desktop.MouseButtonPrimary && ls.mouseDownPosition == event.Position { 60 | clickPoint := geom.Coord{float64(event.Position.X), float64(event.Position.Y)} 61 | p1 := geom.Coord{float64(ls.p1.X), float64(ls.p1.Y)} 62 | p2 := geom.Coord{float64(ls.p2.X), float64(ls.p2.Y)} 63 | if xy.DistanceFromPointToLine(clickPoint, p1, p2) <= float64(ls.link.properties.StrokeWidth/2)+3 { 64 | ls.link.diagram.DiagramElementTapped(ls.link) 65 | } 66 | } else if ls.link.diagram.LinkSegmentMouseUpCallback != nil { 67 | ls.link.diagram.LinkSegmentMouseUpCallback(ls.link.typedLink, event) 68 | } 69 | } 70 | 71 | // SetPoints sets the endpoints of the LinkSegment 72 | func (ls *LinkSegment) SetPoints(p1 fyne.Position, p2 fyne.Position) { 73 | ls.p1 = p1 74 | ls.p2 = p2 75 | ls.Refresh() 76 | } 77 | 78 | // linkSegmentRenderer 79 | type linkSegmentRenderer struct { 80 | ls *LinkSegment 81 | line *canvas.Line 82 | } 83 | 84 | func (lsr *linkSegmentRenderer) Destroy() { 85 | 86 | } 87 | 88 | func (lsr *linkSegmentRenderer) Layout(size fyne.Size) { 89 | } 90 | 91 | func (lsr *linkSegmentRenderer) MinSize() fyne.Size { 92 | return fyne.NewSize(float32(math.Abs(float64(lsr.ls.p1.X-lsr.ls.p2.X))), float32(math.Abs(float64(lsr.ls.p1.Y-lsr.ls.p2.Y)))) 93 | } 94 | 95 | func (lsr *linkSegmentRenderer) Objects() []fyne.CanvasObject { 96 | obj := []fyne.CanvasObject{ 97 | lsr.line, 98 | } 99 | return obj 100 | } 101 | 102 | func (lsr *linkSegmentRenderer) Refresh() { 103 | minX := math.Min(float64(lsr.ls.p1.X), float64(lsr.ls.p2.X)) 104 | minY := math.Min(float64(lsr.ls.p1.Y), float64(lsr.ls.p2.Y)) 105 | widgetPosition := fyne.NewPos(float32(minX), float32(minY)) 106 | lsr.ls.Move(widgetPosition) 107 | lsr.ls.Resize(lsr.MinSize()) 108 | lsr.line.Position1 = lsr.ls.p1.AddXY(-widgetPosition.X, -widgetPosition.Y) 109 | lsr.line.Position2 = lsr.ls.p2.AddXY(-widgetPosition.X, -widgetPosition.Y) 110 | lsr.line.StrokeColor = lsr.ls.link.properties.ForegroundColor 111 | lsr.line.StrokeWidth = lsr.ls.link.properties.StrokeWidth 112 | lsr.line.Refresh() 113 | } 114 | -------------------------------------------------------------------------------- /widget/diagramwidget/springforcelayout.go: -------------------------------------------------------------------------------- 1 | package diagramwidget 2 | 3 | import ( 4 | "math" 5 | 6 | "fyne.io/fyne/v2" 7 | 8 | "fyne.io/x/fyne/widget/diagramwidget/geometry/r2" 9 | ) 10 | 11 | // adjacent returns true if there is at least one edge between n1 and n2 12 | func adjacent(dw *DiagramWidget, n1, n2 DiagramNode) bool { 13 | // TODO: expensive, may be worth caching? 14 | for _, e := range dw.GetDiagramLinks() { 15 | if ((e.GetSourcePad().GetPadOwner() == n1) && (e.GetTargetPad().GetPadOwner() == n2)) || ((e.GetSourcePad().GetPadOwner() == n2) && (e.GetTargetPad().GetPadOwner() == n1)) { 16 | return true 17 | } 18 | } 19 | 20 | return false 21 | } 22 | 23 | func calculateDistance(n1, n2 DiagramNode) float64 { 24 | return r2.MakeLineFromEndpoints(n1.R2Center(), n2.R2Center()).Length() 25 | } 26 | 27 | // calculateForce calculates the force between the given pair of nodes. 28 | // 29 | // The force is calculated at n1. 30 | func calculateForce(dw *DiagramWidget, n1, n2 DiagramNode, targetLength float64) r2.Vec2 { 31 | // spring constant for linear spring 32 | k := float64(0.01) 33 | d := calculateDistance(n1, n2) 34 | 35 | v := n2.R2Center().Add(n1.R2Center().Scale(-1)).Unit().Scale(-1) 36 | 37 | if adjacent(dw, n1, n2) { 38 | // adjacent nodes act like springs, and want to be close to the given 39 | // length. 40 | 41 | // avoid bouncing 42 | delta := math.Abs(d - targetLength) 43 | if delta < 0.05*targetLength { 44 | return r2.V2(0, 0) 45 | } 46 | 47 | if d < targetLength { 48 | return v.Scale(1*d*k + k*math.Pow(d, 1/(d+1))) 49 | } 50 | return v.Scale(-1*d*k - 0.01*k*math.Pow(d, 2)) 51 | } 52 | if d > 1.2*targetLength { 53 | return r2.V2(0, 0) 54 | } 55 | // non-adjacent nodes repel, at a rate falling of with distance. 56 | return v.Scale(50 * math.Sqrt(1/(d+0.1))) 57 | // return r2.V2(0, 0*math.Sqrt(1)) 58 | } 59 | 60 | // StepForceLayout calculates one step of force directed graph layout, with 61 | // the target distance between adjacent nodes being targetLength. 62 | func StepForceLayout(dw *DiagramWidget, targetLength float64) { 63 | deltas := make(map[int]r2.Vec2) 64 | 65 | // calculate all the deltas from the current state 66 | for k, nk := range dw.GetDiagramNodes() { 67 | deltas[k] = r2.V2(0, 0) 68 | 69 | for j, nj := range dw.GetDiagramNodes() { 70 | if j == k { 71 | continue 72 | } 73 | deltas[k] = deltas[k].Add(calculateForce(dw, nk, nj, targetLength)) 74 | } 75 | } 76 | 77 | // flip into current state 78 | for k, nk := range dw.GetDiagramNodes() { 79 | dw.DisplaceNode(nk, fyne.Position{X: float32(deltas[k].X), Y: float32(deltas[k].Y)}) 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /widget/filetree.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "sort" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/container" 8 | "fyne.io/fyne/v2/storage" 9 | "fyne.io/fyne/v2/theme" 10 | "fyne.io/fyne/v2/widget" 11 | ) 12 | 13 | // FileTree extends widget.Tree to display a file system hierarchy. 14 | type FileTree struct { 15 | widget.Tree 16 | Filter storage.FileFilter 17 | ShowRootPath bool 18 | Sorter func(fyne.URI, fyne.URI) bool 19 | 20 | listCache map[widget.TreeNodeID][]widget.TreeNodeID 21 | listableCache map[widget.TreeNodeID]fyne.ListableURI 22 | uriCache map[widget.TreeNodeID]fyne.URI 23 | } 24 | 25 | // NewFileTree creates a new FileTree from the given root URI. 26 | func NewFileTree(root fyne.URI) *FileTree { 27 | tree := &FileTree{ 28 | Tree: widget.Tree{ 29 | Root: root.String(), 30 | CreateNode: func(branch bool) fyne.CanvasObject { 31 | var icon fyne.CanvasObject 32 | if branch { 33 | icon = widget.NewIcon(nil) 34 | } else { 35 | icon = widget.NewFileIcon(nil) 36 | } 37 | return container.NewBorder(nil, nil, icon, nil, widget.NewLabel("Template Object")) 38 | }, 39 | }, 40 | listCache: make(map[widget.TreeNodeID][]widget.TreeNodeID), 41 | listableCache: make(map[widget.TreeNodeID]fyne.ListableURI), 42 | uriCache: make(map[widget.TreeNodeID]fyne.URI), 43 | } 44 | tree.IsBranch = func(id widget.TreeNodeID) bool { 45 | _, err := tree.toListable(id) 46 | return err == nil 47 | } 48 | tree.ChildUIDs = func(id widget.TreeNodeID) (c []string) { 49 | listable, err := tree.toListable(id) 50 | if err != nil { 51 | fyne.LogError("Unable to get lister for "+id, err) 52 | return 53 | } 54 | 55 | ids, ok := tree.listCache[id] 56 | if ok { 57 | return ids 58 | } 59 | 60 | uris, err := listable.List() 61 | if err != nil { 62 | fyne.LogError("Unable to list "+listable.String(), err) 63 | return 64 | } 65 | 66 | for _, u := range tree.sort(tree.filter(uris)) { 67 | // Convert to String 68 | c = append(c, u.String()) 69 | } 70 | 71 | tree.listCache[id] = c 72 | return 73 | } 74 | tree.UpdateNode = func(id widget.TreeNodeID, branch bool, node fyne.CanvasObject) { 75 | uri, err := tree.toURI(id) 76 | if err != nil { 77 | fyne.LogError("Unable to parse URI", err) 78 | return 79 | } 80 | 81 | c := node.(*fyne.Container) 82 | if branch { 83 | var r fyne.Resource 84 | if tree.IsBranchOpen(id) { 85 | // Set open folder icon 86 | r = theme.FolderOpenIcon() 87 | } else { 88 | // Set folder icon 89 | r = theme.FolderIcon() 90 | } 91 | c.Objects[1].(*widget.Icon).SetResource(r) 92 | } else { 93 | // Set file uri to update icon 94 | c.Objects[1].(*widget.FileIcon).SetURI(uri) 95 | } 96 | 97 | var l string 98 | if tree.Root == id && tree.ShowRootPath { 99 | l = id 100 | } else { 101 | l = uri.Name() 102 | } 103 | c.Objects[0].(*widget.Label).SetText(l) 104 | } 105 | 106 | // reset sorted child ID cache if the branch is closed - in the future we do FS watch 107 | tree.OnBranchClosed = func(id widget.TreeNodeID) { 108 | delete(tree.listCache, id) 109 | } 110 | 111 | tree.ExtendBaseWidget(tree) 112 | return tree 113 | } 114 | 115 | // MapURI allows an app to return a specific URI for the given uid. 116 | // This can be helpful to make more custom trees based on file structure/ 117 | func (t *FileTree) MapURI(uid string, target fyne.URI) { 118 | t.uriCache[uid] = target 119 | t.Refresh() 120 | } 121 | 122 | func (t *FileTree) filter(uris []fyne.URI) []fyne.URI { 123 | filter := t.Filter 124 | if filter == nil { 125 | return uris 126 | } 127 | var filtered []fyne.URI 128 | for _, u := range uris { 129 | if filter.Matches(u) { 130 | filtered = append(filtered, u) 131 | } 132 | } 133 | return filtered 134 | } 135 | 136 | func (t *FileTree) sort(uris []fyne.URI) []fyne.URI { 137 | if sorter := t.Sorter; sorter != nil { 138 | sort.Slice(uris, func(i, j int) bool { 139 | return sorter(uris[i], uris[j]) 140 | }) 141 | } 142 | return uris 143 | } 144 | 145 | func (t *FileTree) toListable(id widget.TreeNodeID) (fyne.ListableURI, error) { 146 | listable, ok := t.listableCache[id] 147 | if ok { 148 | return listable, nil 149 | } 150 | uri, err := t.toURI(id) 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | listable, err = storage.ListerForURI(uri) 156 | if err != nil { 157 | return nil, err 158 | } 159 | t.listableCache[id] = listable 160 | return listable, nil 161 | } 162 | 163 | func (t *FileTree) toURI(id widget.TreeNodeID) (fyne.URI, error) { 164 | uri, ok := t.uriCache[id] 165 | if ok { 166 | return uri, nil 167 | } 168 | 169 | uri, err := storage.ParseURI(id) 170 | if err != nil { 171 | return nil, err 172 | } 173 | t.uriCache[id] = uri 174 | return uri, nil 175 | } 176 | -------------------------------------------------------------------------------- /widget/filetree_test.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "path/filepath" 7 | "testing" 8 | 9 | "fyne.io/fyne/v2" 10 | "fyne.io/fyne/v2/container" 11 | "fyne.io/fyne/v2/storage" 12 | "fyne.io/fyne/v2/test" 13 | "fyne.io/fyne/v2/widget" 14 | 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | func TestFileTree(t *testing.T) { 19 | tree := &FileTree{} 20 | tree.Refresh() // Should not crash 21 | } 22 | 23 | func TestFileTree_Layout(t *testing.T) { 24 | test.NewApp() 25 | 26 | tempDir := createTempDir(t) 27 | defer os.RemoveAll(tempDir) 28 | 29 | root, err := storage.ParseURI("file://" + tempDir) 30 | assert.NoError(t, err) 31 | tree := NewFileTree(root) 32 | tree.OpenAllBranches() 33 | 34 | window := test.NewWindow(tree) 35 | defer window.Close() 36 | window.Resize(fyne.NewSize(300, 100)) 37 | 38 | branch, err := storage.Child(root, "B") 39 | assert.NoError(t, err) 40 | leaf, err := storage.Child(branch, "C.txt") 41 | assert.NoError(t, err) 42 | tree.Select(leaf.String()) 43 | 44 | test.AssertImageMatches(t, "filetree/selected.png", window.Canvas().Capture()) 45 | } 46 | 47 | func TestFileTree_filter(t *testing.T) { 48 | tempDir := createTempDir(t) 49 | defer os.RemoveAll(tempDir) 50 | 51 | root, err := storage.ParseURI("file://" + tempDir) 52 | assert.NoError(t, err) 53 | tree := NewFileTree(root) 54 | tree.Filter = storage.NewExtensionFileFilter([]string{".txt"}) 55 | 56 | branch1, err := storage.Child(root, "A") 57 | assert.NoError(t, err) 58 | branch2, err := storage.Child(root, "B") 59 | assert.NoError(t, err) 60 | leaf1, err := storage.Child(branch2, "C.txt") 61 | assert.NoError(t, err) 62 | leaf2, err := storage.Child(branch2, "D.txt") 63 | assert.NoError(t, err) 64 | 65 | given := []fyne.URI{ 66 | branch1, 67 | branch2, 68 | leaf1, 69 | leaf2, 70 | } 71 | 72 | expected := []fyne.URI{ 73 | leaf1, 74 | leaf2, 75 | } 76 | 77 | assert.Equal(t, expected, tree.filter(given)) 78 | } 79 | 80 | func TestFileTree_ShowRootPath(t *testing.T) { 81 | testPath, _ := filepath.Abs("./testdata") 82 | root, err := storage.ParseURI("file://" + testPath) 83 | assert.NoError(t, err) 84 | 85 | tree := NewFileTree(root) 86 | firstNodeContent := func() *widget.Label { 87 | renderer := tree.CreateRenderer() 88 | assert.Equal(t, 1, len(renderer.Objects())) 89 | content := renderer.Objects()[0].(*container.Scroll).Content.(fyne.Widget).CreateRenderer() 90 | content.Layout(fyne.NewSize(100, 100)) 91 | 92 | node := content.Objects()[0].(fyne.Widget).CreateRenderer() 93 | return node.Objects()[1].(*fyne.Container).Objects[0].(*widget.Label) 94 | } 95 | 96 | assert.Equal(t, "testdata", firstNodeContent().Text) 97 | 98 | tree.ShowRootPath = true 99 | tree.Refresh() 100 | assert.Equal(t, "file://", firstNodeContent().Text[:7]) 101 | } 102 | 103 | func TestFileTree_sort(t *testing.T) { 104 | tempDir := createTempDir(t) 105 | defer os.RemoveAll(tempDir) 106 | 107 | root, err := storage.ParseURI("file://" + tempDir) 108 | assert.NoError(t, err) 109 | tree := NewFileTree(root) 110 | tree.Sorter = func(u1, u2 fyne.URI) bool { 111 | return u2.String() < u1.String() // Reverse alphabetical 112 | } 113 | 114 | branch1, err := storage.Child(root, "A") 115 | assert.NoError(t, err) 116 | branch2, err := storage.Child(root, "B") 117 | assert.NoError(t, err) 118 | leaf1, err := storage.Child(branch2, "C.txt") 119 | assert.NoError(t, err) 120 | leaf2, err := storage.Child(branch2, "D.txt") 121 | assert.NoError(t, err) 122 | 123 | given := []fyne.URI{ 124 | branch1, 125 | branch2, 126 | leaf1, 127 | leaf2, 128 | } 129 | 130 | expected := []fyne.URI{ 131 | leaf2, 132 | leaf1, 133 | branch2, 134 | branch1, 135 | } 136 | 137 | assert.Equal(t, expected, tree.sort(given)) 138 | } 139 | 140 | func Test_NewFileTree(t *testing.T) { 141 | test.NewApp() 142 | 143 | tempDir := createTempDir(t) 144 | defer os.RemoveAll(tempDir) 145 | 146 | root, err := storage.ParseURI("file://" + tempDir) 147 | assert.NoError(t, err) 148 | tree := NewFileTree(root) 149 | tree.OpenAllBranches() 150 | 151 | assert.True(t, tree.IsBranchOpen(root.String())) 152 | branch1, err := storage.Child(root, "A") 153 | assert.NoError(t, err) 154 | assert.True(t, tree.IsBranchOpen(branch1.String())) 155 | branch2, err := storage.Child(root, "B") 156 | assert.NoError(t, err) 157 | assert.True(t, tree.IsBranchOpen(branch2.String())) 158 | leaf, err := storage.Child(branch2, "C.txt") 159 | assert.NoError(t, err) 160 | assert.False(t, tree.IsBranchOpen(leaf.String())) 161 | } 162 | 163 | func createTempDir(t *testing.T) string { 164 | t.Helper() 165 | tempDir, err := os.MkdirTemp("", "test") 166 | assert.NoError(t, err) 167 | err = os.MkdirAll(path.Join(tempDir, "A"), os.ModePerm) 168 | assert.NoError(t, err) 169 | err = os.MkdirAll(path.Join(tempDir, "B"), os.ModePerm) 170 | assert.NoError(t, err) 171 | err = os.WriteFile(path.Join(tempDir, "B", "C.txt"), []byte("c"), os.ModePerm) 172 | assert.NoError(t, err) 173 | err = os.WriteFile(path.Join(tempDir, "B", "D.txt"), []byte("d"), os.ModePerm) 174 | assert.NoError(t, err) 175 | return tempDir 176 | } 177 | -------------------------------------------------------------------------------- /widget/gif.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "bytes" 5 | "image" 6 | "image/draw" 7 | "image/gif" 8 | "io" 9 | "sync" 10 | "time" 11 | 12 | "fyne.io/fyne/v2" 13 | "fyne.io/fyne/v2/canvas" 14 | "fyne.io/fyne/v2/storage" 15 | "fyne.io/fyne/v2/widget" 16 | ) 17 | 18 | // AnimatedGif widget shows a Gif image with many frames. 19 | type AnimatedGif struct { 20 | widget.BaseWidget 21 | min fyne.Size 22 | 23 | src *gif.GIF 24 | dst *canvas.Image 25 | noDisposeIndex int 26 | remaining int 27 | stopping, running bool 28 | runLock sync.RWMutex 29 | } 30 | 31 | // NewAnimatedGif creates a new widget loaded to show the specified image. 32 | // If there is an error loading the image it will be returned in the error value. 33 | func NewAnimatedGif(u fyne.URI) (*AnimatedGif, error) { 34 | ret := newGif() 35 | 36 | return ret, ret.Load(u) 37 | } 38 | 39 | // NewAnimatedGifFromResource creates a new widget loaded to show the specified image resource. 40 | // If there is an error loading the image it will be returned in the error value. 41 | func NewAnimatedGifFromResource(r fyne.Resource) (*AnimatedGif, error) { 42 | ret := newGif() 43 | 44 | return ret, ret.LoadResource(r) 45 | } 46 | 47 | // CreateRenderer loads the widget renderer for this widget. This is an internal requirement for Fyne. 48 | func (g *AnimatedGif) CreateRenderer() fyne.WidgetRenderer { 49 | return &gifRenderer{gif: g} 50 | } 51 | 52 | // Load is used to change the gif file shown. 53 | // It will change the loaded content and prepare the new frames for animation. 54 | func (g *AnimatedGif) Load(u fyne.URI) error { 55 | g.dst.Image = nil 56 | g.dst.Refresh() 57 | 58 | if u == nil { 59 | return nil 60 | } 61 | 62 | read, err := storage.Reader(u) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | return g.load(read) 68 | } 69 | 70 | // LoadResource is used to change the gif resource shown. 71 | // It will change the loaded content and prepare the new frames for animation. 72 | func (g *AnimatedGif) LoadResource(r fyne.Resource) error { 73 | g.dst.Image = nil 74 | g.dst.Refresh() 75 | 76 | if r == nil || len(r.Content()) == 0 { 77 | return nil 78 | } 79 | return g.load(bytes.NewReader(r.Content())) 80 | } 81 | 82 | func (g *AnimatedGif) load(read io.Reader) error { 83 | pix, err := gif.DecodeAll(read) 84 | if err != nil { 85 | return err 86 | } 87 | g.src = pix 88 | g.dst.Image = pix.Image[0] 89 | g.dst.Refresh() 90 | 91 | return nil 92 | } 93 | 94 | // MinSize returns the minimum size that this GIF can occupy. 95 | // Because gif images are measured in pixels we cannot use the dimensions, so this defaults to 0x0. 96 | // You can set a minimum size if required using SetMinSize. 97 | func (g *AnimatedGif) MinSize() fyne.Size { 98 | return g.min 99 | } 100 | 101 | // SetMinSize sets the smallest possible size that this AnimatedGif should be drawn at. 102 | // Be careful not to set this based on pixel sizes as that will vary based on output device. 103 | func (g *AnimatedGif) SetMinSize(min fyne.Size) { 104 | g.min = min 105 | } 106 | 107 | func (g *AnimatedGif) draw(dst draw.Image, frame image.Image, dispose byte, index int) { 108 | bounds := dst.Bounds() 109 | switch dispose { 110 | case gif.DisposalNone: 111 | // Do not dispose old frame, draw new frame over old 112 | draw.Draw(dst, bounds, frame, image.Point{}, draw.Over) 113 | // will be used in case of disposalPrevious 114 | g.noDisposeIndex = index - 1 115 | case gif.DisposalBackground: 116 | // clear with background then render new frame Over it 117 | // replacing entirely with new frame should achieve this? 118 | draw.Draw(dst, bounds, frame, image.Point{}, draw.Src) 119 | case gif.DisposalPrevious: 120 | // restore frame with previous image then render new over it 121 | if g.noDisposeIndex >= 0 { 122 | draw.Draw(dst, bounds, g.src.Image[g.noDisposeIndex], image.Point{}, draw.Src) 123 | draw.Draw(dst, bounds, frame, image.Point{}, draw.Over) 124 | } else { 125 | // there was no previous graphic, render background instead? 126 | draw.Draw(dst, bounds, frame, image.Point{}, draw.Src) 127 | } 128 | default: 129 | // Disposal = Unspecified/Reserved, simply draw new frame over previous 130 | draw.Draw(dst, bounds, frame, image.Point{}, draw.Over) 131 | } 132 | 133 | g.dst.Refresh() 134 | } 135 | 136 | // Start begins the animation. The speed of the transition is controlled by the loaded gif file. 137 | func (g *AnimatedGif) Start() { 138 | if g.isRunning() { 139 | return 140 | } 141 | g.runLock.Lock() 142 | g.running = true 143 | g.runLock.Unlock() 144 | 145 | buffer := image.NewNRGBA(g.dst.Image.Bounds()) 146 | g.dst.Image = buffer 147 | g.noDisposeIndex = -1 148 | g.draw(buffer, g.src.Image[0], gif.DisposalNone, 0) 149 | 150 | go func() { 151 | switch g.src.LoopCount { 152 | case -1: // don't loop 153 | g.remaining = 1 154 | case 0: // loop forever 155 | g.remaining = -1 156 | default: 157 | g.remaining = g.src.LoopCount + 1 158 | } 159 | loop: 160 | for g.remaining != 0 { 161 | frames := g.src.Image 162 | for c, frame := range frames { 163 | dispose := byte(gif.DisposalNone) 164 | if c > 0 { 165 | dispose = g.src.Disposal[c-1] 166 | } 167 | if g.isStopping() { 168 | break loop 169 | } 170 | fyne.Do(func() { 171 | g.draw(buffer, frame, dispose, c) 172 | }) 173 | 174 | time.Sleep(time.Millisecond * time.Duration(g.src.Delay[c]) * 10) 175 | } 176 | if g.remaining > -1 { // don't underflow int 177 | g.remaining-- 178 | } 179 | } 180 | g.runLock.Lock() 181 | g.running = false 182 | g.stopping = false 183 | g.runLock.Unlock() 184 | }() 185 | } 186 | 187 | // Stop will request that the animation stops running, the last frame will remain visible 188 | func (g *AnimatedGif) Stop() { 189 | if !g.isRunning() { 190 | return 191 | } 192 | g.runLock.Lock() 193 | g.stopping = true 194 | g.runLock.Unlock() 195 | } 196 | 197 | func (g *AnimatedGif) isStopping() bool { 198 | g.runLock.RLock() 199 | defer g.runLock.RUnlock() 200 | return g.stopping 201 | } 202 | 203 | func (g *AnimatedGif) isRunning() bool { 204 | g.runLock.RLock() 205 | defer g.runLock.RUnlock() 206 | return g.running 207 | } 208 | 209 | func newGif() *AnimatedGif { 210 | ret := &AnimatedGif{} 211 | ret.ExtendBaseWidget(ret) 212 | ret.dst = &canvas.Image{} 213 | ret.dst.FillMode = canvas.ImageFillContain 214 | return ret 215 | } 216 | 217 | type gifRenderer struct { 218 | gif *AnimatedGif 219 | } 220 | 221 | func (g *gifRenderer) Destroy() { 222 | g.gif.Stop() 223 | } 224 | 225 | func (g *gifRenderer) Layout(size fyne.Size) { 226 | g.gif.dst.Resize(size) 227 | } 228 | 229 | func (g *gifRenderer) MinSize() fyne.Size { 230 | return g.gif.MinSize() 231 | } 232 | 233 | func (g *gifRenderer) Objects() []fyne.CanvasObject { 234 | return []fyne.CanvasObject{g.gif.dst} 235 | } 236 | 237 | func (g *gifRenderer) Refresh() { 238 | g.gif.dst.Refresh() 239 | } 240 | -------------------------------------------------------------------------------- /widget/gif_slow_test.go: -------------------------------------------------------------------------------- 1 | //go:build slowtests 2 | // +build slowtests 3 | 4 | package widget 5 | 6 | import ( 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "fyne.io/fyne/v2/storage" 13 | ) 14 | 15 | func TestNewAnimatedGif_Once(t *testing.T) { 16 | gif, err := NewAnimatedGif(storage.NewFileURI("./testdata/gif/earth-once.gif")) 17 | assert.Nil(t, err) 18 | 19 | gif.Start() 20 | time.Sleep(time.Millisecond * 10) 21 | assert.Equal(t, 1, gif.remaining) 22 | time.Sleep(time.Second * 5) 23 | assert.Equal(t, 0, gif.remaining) 24 | } 25 | 26 | func TestNewAnimatedGif_RunTwice(t *testing.T) { 27 | gif, err := NewAnimatedGif(storage.NewFileURI("./testdata/gif/earth-once.gif")) 28 | assert.Nil(t, err) 29 | 30 | gif.Start() 31 | time.Sleep(time.Millisecond * 10) 32 | assert.True(t, gif.running) 33 | time.Sleep(time.Second * 5) 34 | assert.False(t, gif.running) 35 | 36 | gif.Start() 37 | gif.Start() 38 | time.Sleep(time.Millisecond * 10) 39 | assert.True(t, gif.running) 40 | time.Sleep(time.Second * 5) 41 | assert.False(t, gif.running) 42 | } 43 | -------------------------------------------------------------------------------- /widget/gif_test.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | 11 | "fyne.io/fyne/v2" 12 | "fyne.io/fyne/v2/storage" 13 | "fyne.io/fyne/v2/test" 14 | ) 15 | 16 | func TestNewAnimatedGif(t *testing.T) { 17 | gif, err := NewAnimatedGif(storage.NewFileURI("./testdata/gif/earth.gif")) 18 | assert.Nil(t, err) 19 | 20 | w := test.NewWindow(gif) 21 | defer w.Close() 22 | w.SetPadded(false) 23 | w.Resize(fyne.NewSize(128, 128)) 24 | 25 | test.AssertImageMatches(t, "gif/initial.png", w.Canvas().Capture()) 26 | 27 | gif.Start() 28 | time.Sleep(time.Millisecond * 10) 29 | assert.Equal(t, -1, gif.remaining) 30 | } 31 | 32 | func TestAnimatedGif_MinSize(t *testing.T) { 33 | f, err := os.Open("./testdata/gif/earth.gif") 34 | assert.Nil(t, err) 35 | 36 | r, err := io.ReadAll(f) 37 | assert.Nil(t, err) 38 | 39 | res := fyne.NewStaticResource("earth.gif", r) 40 | gif, _ := NewAnimatedGifFromResource(res) 41 | assert.True(t, gif.min.IsZero()) 42 | 43 | gif.SetMinSize(fyne.NewSize(10.0, 10.0)) 44 | assert.Equal(t, float32(10), gif.MinSize().Width) 45 | assert.Equal(t, float32(10), gif.MinSize().Height) 46 | } 47 | -------------------------------------------------------------------------------- /widget/gridwrap_test.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "testing" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/theme" 8 | "fyne.io/fyne/v2/widget" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestGridWrap_New(t *testing.T) { 13 | g := createGridWrap(1000) 14 | template := widget.NewIcon(theme.AccountIcon()) 15 | 16 | assert.Equal(t, 1000, g.Length()) 17 | assert.GreaterOrEqual(t, g.MinSize().Width, template.MinSize().Width) 18 | assert.Equal(t, float32(0), g.offsetY) 19 | } 20 | 21 | func TestGridWrap_OffsetChange(t *testing.T) { 22 | g := createGridWrap(1000) 23 | 24 | assert.Equal(t, float32(0), g.offsetY) 25 | 26 | g.scroller.Scrolled(&fyne.ScrollEvent{Scrolled: fyne.NewDelta(0, -280)}) 27 | 28 | assert.NotEqual(t, 0, g.offsetY) 29 | } 30 | 31 | func TestGridWrap_ScrollTo(t *testing.T) { 32 | g := createGridWrap(1000) 33 | 34 | // override update item to keep track of greatest item rendered 35 | oldUpdateFunc := g.UpdateItem 36 | var greatest GridWrapItemID = -1 37 | g.UpdateItem = func(id GridWrapItemID, item fyne.CanvasObject) { 38 | if id > greatest { 39 | greatest = id 40 | } 41 | oldUpdateFunc(id, item) 42 | } 43 | 44 | g.ScrollTo(650) 45 | assert.GreaterOrEqual(t, greatest, 650) 46 | 47 | g.ScrollTo(800) 48 | assert.GreaterOrEqual(t, greatest, 800) 49 | 50 | g.ScrollToBottom() 51 | assert.Equal(t, greatest, GridWrapItemID(999)) 52 | } 53 | 54 | func TestGridWrap_ScrollToTop(t *testing.T) { 55 | g := createGridWrap(1000) 56 | g.ScrollTo(750) 57 | assert.NotEqual(t, g.offsetY, float32(0)) 58 | g.ScrollToTop() 59 | assert.Equal(t, g.offsetY, float32(0)) 60 | } 61 | 62 | func createGridWrap(items int) *GridWrap { 63 | data := make([]fyne.Resource, items) 64 | for i := 0; i < items; i++ { 65 | switch i % 10 { 66 | case 0: 67 | data[i] = theme.AccountIcon() 68 | case 1: 69 | data[i] = theme.CancelIcon() 70 | case 2: 71 | data[i] = theme.CheckButtonIcon() 72 | case 3: 73 | data[i] = theme.FileApplicationIcon() 74 | case 4: 75 | data[i] = theme.FileVideoIcon() 76 | case 5: 77 | data[i] = theme.DocumentIcon() 78 | case 6: 79 | data[i] = theme.MediaPlayIcon() 80 | case 7: 81 | data[i] = theme.MediaRecordIcon() 82 | case 8: 83 | data[i] = theme.FolderIcon() 84 | case 9: 85 | data[i] = theme.FolderOpenIcon() 86 | } 87 | } 88 | 89 | list := NewGridWrap( 90 | func() int { 91 | return len(data) 92 | }, 93 | func() fyne.CanvasObject { 94 | icon := widget.NewIcon(theme.DocumentIcon()) 95 | return icon 96 | }, 97 | func(id GridWrapItemID, item fyne.CanvasObject) { 98 | item.(*widget.Icon).SetResource(data[id]) 99 | }, 100 | ) 101 | list.Resize(fyne.NewSize(200, 400)) 102 | return list 103 | } 104 | 105 | func TestGridWrap_IndexIsInt(t *testing.T) { 106 | gw := &GridWrap{} 107 | 108 | // Both of these should be allowed to match widget.List behaviour. 109 | // It allows the same update item function to be shared between both widgets if necessary. 110 | gw.UpdateItem = func(id GridWrapItemID, item fyne.CanvasObject) {} 111 | gw.UpdateItem = func(id int, item fyne.CanvasObject) {} 112 | } 113 | -------------------------------------------------------------------------------- /widget/hexwidget.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "image/color" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/canvas" 8 | "fyne.io/fyne/v2/widget" 9 | ) 10 | 11 | // segmentLookupTable is used by h.Set() - the i-th index into this table 12 | // represents the raw value that should be sent to UpdateSegments to show 13 | // the value i. 14 | var segmentLookupTable []uint8 = []uint8{ 15 | 1 << 6, 16 | (1<<0 | (1 << 1) | (1 << 2) | (1 << 3) | (1 << 6) | (1 << 7)), 17 | (1<<2 | (1 << 5)), 18 | (1<<4 | (1 << 5)), 19 | (1<<0 | (1 << 3) | (1 << 4)), 20 | (1<<1 | (1 << 4)), 21 | (1 << 1), 22 | (1<<3 | (1 << 4) | (1 << 5) | (1 << 6)), 23 | 0, 24 | (1<<3 | (1 << 4)), 25 | (1 << 3), 26 | (1<<0 | (1 << 1)), 27 | (1<<0 | (1 << 1) | (1 << 2) | (1 << 5)), 28 | (1<<0 | (1 << 5)), 29 | (1<<1 | (1 << 2)), 30 | (1<<1 | (1 << 2) | (1 << 3)), 31 | } 32 | 33 | // size of the hex widget 34 | const defaultHexHeight float32 = 58.0 35 | const defaultHexWidth float32 = defaultHexHeight * (8 / 14.0) 36 | 37 | // slant angle 38 | const defaultHexOffset float32 = 0.1 * defaultHexWidth 39 | 40 | var defaultHexOnColor color.Color = color.RGBA{200, 25, 25, 255} 41 | var defaultHexOffColor color.Color = color.RGBA{25, 15, 15, 64} 42 | 43 | type hexRenderer struct { 44 | hex *HexWidget 45 | segmentObjects []fyne.CanvasObject 46 | } 47 | 48 | func (h *hexRenderer) MinSize() fyne.Size { 49 | return fyne.NewSize( 50 | h.hex.size.Width+h.hex.hexOffset, 51 | h.hex.size.Height, 52 | ) 53 | } 54 | 55 | func (h *hexRenderer) Layout(_ fyne.Size) { 56 | hexSegmentWidth := 0.2 * h.hex.size.Width 57 | 58 | hexSegmentVLength := (h.hex.size.Height - hexSegmentWidth) / 2 59 | hexSegmentHLength := h.hex.size.Width - hexSegmentWidth 60 | pos := fyne.NewPos(h.hex.hexOffset, hexSegmentWidth/2) 61 | 62 | pt0Center := fyne.NewPos(pos.X+h.hex.size.Width/2.0+h.hex.hexOffset, pos.Y) 63 | pt05 := fyne.NewPos(float32(pt0Center.X)-(hexSegmentHLength/2), pt0Center.Y) 64 | pt01 := fyne.NewPos(float32(pt0Center.X)+(hexSegmentHLength/2), pt0Center.Y) 65 | 66 | pt6Center := fyne.NewPos(pos.X+h.hex.size.Width/2.0, float32(pt0Center.Y)+hexSegmentVLength) 67 | pt65 := fyne.NewPos(float32(pt6Center.X)-(hexSegmentHLength/2), pt6Center.Y) 68 | pt61 := fyne.NewPos(float32(pt6Center.X)+(hexSegmentHLength/2), pt6Center.Y) 69 | 70 | pt3Center := fyne.NewPos(pos.X+h.hex.size.Width/2.0-h.hex.hexOffset, float32(pt0Center.Y)+2*hexSegmentVLength) 71 | pt34 := fyne.NewPos(float32(pt3Center.X)-(hexSegmentHLength/2), pt3Center.Y) 72 | pt32 := fyne.NewPos(float32(pt3Center.X)+(hexSegmentHLength/2), pt3Center.Y) 73 | 74 | setLineEndpoints(h.segmentObjects[0].(*canvas.Line), pt05, pt01) 75 | setLineEndpoints(h.segmentObjects[1].(*canvas.Line), pt01, pt61) 76 | setLineEndpoints(h.segmentObjects[2].(*canvas.Line), pt61, pt32) 77 | setLineEndpoints(h.segmentObjects[3].(*canvas.Line), pt32, pt34) 78 | setLineEndpoints(h.segmentObjects[4].(*canvas.Line), pt34, pt65) 79 | setLineEndpoints(h.segmentObjects[5].(*canvas.Line), pt65, pt05) 80 | setLineEndpoints(h.segmentObjects[6].(*canvas.Line), pt65, pt61) 81 | } 82 | 83 | func (h *hexRenderer) Refresh() { 84 | hexSegmentWidth := 0.2 * h.hex.size.Width 85 | 86 | h.segmentObjects[0].(*canvas.Line).StrokeWidth = float32(hexSegmentWidth / 2) 87 | h.segmentObjects[1].(*canvas.Line).StrokeWidth = float32(hexSegmentWidth / 2) 88 | h.segmentObjects[2].(*canvas.Line).StrokeWidth = float32(hexSegmentWidth / 2) 89 | h.segmentObjects[3].(*canvas.Line).StrokeWidth = float32(hexSegmentWidth / 2) 90 | h.segmentObjects[4].(*canvas.Line).StrokeWidth = float32(hexSegmentWidth / 2) 91 | h.segmentObjects[5].(*canvas.Line).StrokeWidth = float32(hexSegmentWidth / 2) 92 | h.segmentObjects[6].(*canvas.Line).StrokeWidth = float32(hexSegmentWidth / 2) 93 | 94 | for i, v := range h.segmentObjects { 95 | v.(*canvas.Line).StrokeColor = h.hex.getSegmentColor(i) 96 | canvas.Refresh(v) 97 | } 98 | } 99 | 100 | func (h *hexRenderer) Destroy() { 101 | } 102 | 103 | func (h *hexRenderer) Objects() []fyne.CanvasObject { 104 | return h.segmentObjects 105 | } 106 | 107 | // HexWidget represents a 7-segment hexadecimal display. The segments 108 | // of the display mapped active-low onto 7 state bits, with segment 0 in 109 | // the least significant bit. 110 | // 111 | // 0 112 | // ----- 113 | // | | 114 | // 5 | | 1 115 | // | 6 | 116 | // ----- 117 | // | | 118 | // 4 | | 2 119 | // | 3 | 120 | // ----- 121 | type HexWidget struct { 122 | widget.BaseWidget 123 | segments uint8 124 | 125 | // size of the hex widget 126 | size fyne.Size 127 | 128 | // slant angle 129 | hexOffset float32 130 | 131 | // color when the hex is on 132 | hexOnColor color.Color 133 | 134 | // color when the hex is off 135 | hexOffColor color.Color 136 | } 137 | 138 | // SetOnColor changes the color that segments are shown as when they are 139 | // active/on. 140 | func (h *HexWidget) SetOnColor(c color.Color) { 141 | h.hexOnColor = c 142 | h.Refresh() 143 | } 144 | 145 | // SetOffColor changes the color that segments are shown as when they are 146 | // inactive/off. 147 | func (h *HexWidget) SetOffColor(c color.Color) { 148 | h.hexOffColor = c 149 | h.Refresh() 150 | } 151 | 152 | // SetSize changes the size of the hex widget. 153 | func (h *HexWidget) SetSize(s fyne.Size) { 154 | h.size = s 155 | h.Refresh() 156 | } 157 | 158 | // SetSlant changes the amount of "slant" in the hex widgets. The topmost 159 | // segment is offset by slant many units to the right. A value of 0 means no 160 | // slant at all. For example, setting the slant equal to the height should 161 | // result in a 45 degree angle. 162 | func (h *HexWidget) SetSlant(s float32) { 163 | h.hexOffset = s 164 | h.Refresh() 165 | } 166 | 167 | func (h *HexWidget) getSegmentColor(segno int) color.Color { 168 | if (h.segments & (1 << uint(segno))) == 0 { 169 | return h.hexOnColor 170 | } 171 | 172 | return h.hexOffColor 173 | } 174 | 175 | // CreateRenderer implements fyne.Widget 176 | func (h *HexWidget) CreateRenderer() fyne.WidgetRenderer { 177 | 178 | seg0 := canvas.NewLine(h.hexOffColor) 179 | seg1 := canvas.NewLine(h.hexOffColor) 180 | seg2 := canvas.NewLine(h.hexOffColor) 181 | seg3 := canvas.NewLine(h.hexOffColor) 182 | seg4 := canvas.NewLine(h.hexOffColor) 183 | seg5 := canvas.NewLine(h.hexOffColor) 184 | seg6 := canvas.NewLine(h.hexOffColor) 185 | 186 | r := &hexRenderer{ 187 | hex: h, 188 | segmentObjects: []fyne.CanvasObject{seg0, seg1, seg2, seg3, seg4, seg5, seg6}, 189 | } 190 | 191 | r.Refresh() 192 | 193 | return r 194 | } 195 | 196 | // NewHexWidget instantiates a new widget instance, with all of the segments 197 | // disabled. 198 | func NewHexWidget() *HexWidget { 199 | h := &HexWidget{ 200 | segments: 0xff, 201 | size: fyne.NewSize(defaultHexWidth, defaultHexHeight), 202 | hexOffset: defaultHexOffset, 203 | hexOnColor: defaultHexOnColor, 204 | hexOffColor: defaultHexOffColor, 205 | } 206 | 207 | h.ExtendBaseWidget(h) 208 | return h 209 | } 210 | 211 | // UpdateSegments changes the state of the segments and causes the widget to 212 | // refresh so the changes are visible to the user. Segments values are packed 213 | // into the 8-bit segments integer, see the documentation for HexWidget for 214 | // more information on the appropriate packing. 215 | func (h *HexWidget) UpdateSegments(segments uint8) { 216 | h.segments = segments 217 | h.Refresh() 218 | } 219 | 220 | // Set updates the hex widget to show a specific number between 0 and 15, which 221 | // will be rendered in hexadecimal in 0...f. If the number is greater than 15, 222 | // it will be modulo-ed by 16. 223 | func (h *HexWidget) Set(val uint) { 224 | val = val % 16 225 | h.UpdateSegments(segmentLookupTable[val]) 226 | } 227 | 228 | func setLineEndpoints(l *canvas.Line, pt1, pt2 fyne.Position) { 229 | l.Position1 = pt1 230 | l.Position2 = pt2 231 | } 232 | -------------------------------------------------------------------------------- /widget/map.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "image" 5 | "math" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/nfnt/resize" 10 | 11 | "fyne.io/fyne/v2" 12 | "fyne.io/fyne/v2/canvas" 13 | "fyne.io/fyne/v2/container" 14 | "fyne.io/fyne/v2/layout" 15 | "fyne.io/fyne/v2/theme" 16 | "fyne.io/fyne/v2/widget" 17 | 18 | "golang.org/x/image/draw" 19 | ) 20 | 21 | const tileSize = 256 22 | 23 | // Map widget renders an interactive map using OpenStreetMap tile data. 24 | type Map struct { 25 | widget.BaseWidget 26 | 27 | pixels *image.NRGBA 28 | w, h int 29 | zoom, x, y int 30 | 31 | cl *http.Client 32 | 33 | tileSource string // url to download xyz tiles (example: "https://tile.openstreetmap.org/%d/%d/%d.png") 34 | hideAttribution bool // enable copyright attribution 35 | attributionLabel string // label for attribution (example: "OpenStreetMap") 36 | attributionURL string // url for attribution (example: "https://openstreetmap.org") 37 | hideZoomButtons bool // enable zoom buttons 38 | hideMoveButtons bool // enable move map buttons 39 | } 40 | 41 | // MapOption configures the provided map with different features. 42 | type MapOption func(*Map) 43 | 44 | // WithOsmTiles configures the map to use osm tile source. 45 | func WithOsmTiles() MapOption { 46 | return func(m *Map) { 47 | m.tileSource = "https://tile.openstreetmap.org/%d/%d/%d.png" 48 | m.attributionLabel = "OpenStreetMap" 49 | m.attributionURL = "https://openstreetmap.org" 50 | m.hideAttribution = false 51 | } 52 | } 53 | 54 | // WithTileSource configures the map to use a custom tile source. 55 | func WithTileSource(tileSource string) MapOption { 56 | return func(m *Map) { 57 | m.tileSource = tileSource 58 | } 59 | } 60 | 61 | // WithAttribution configures the map widget to display an attribution. 62 | func WithAttribution(enable bool, label, url string) MapOption { 63 | return func(m *Map) { 64 | m.hideAttribution = !enable 65 | m.attributionLabel = label 66 | m.attributionURL = url 67 | } 68 | } 69 | 70 | // WithZoomButtons enables or disables zoom controls. 71 | func WithZoomButtons(enable bool) MapOption { 72 | return func(m *Map) { 73 | m.hideZoomButtons = !enable 74 | } 75 | } 76 | 77 | // WithScrollButtons enables or disables map scroll controls. 78 | func WithScrollButtons(enable bool) MapOption { 79 | return func(m *Map) { 80 | m.hideMoveButtons = !enable 81 | } 82 | } 83 | 84 | // WithHTTPClient configures the map to use a custom http client. 85 | func WithHTTPClient(client *http.Client) MapOption { 86 | return func(m *Map) { 87 | m.cl = client 88 | } 89 | } 90 | 91 | // NewMap creates a new instance of the map widget. 92 | func NewMap() *Map { 93 | m := &Map{cl: &http.Client{}} 94 | WithOsmTiles()(m) 95 | m.ExtendBaseWidget(m) 96 | return m 97 | } 98 | 99 | // NewMapWithOptions creates a new instance of the map widget with provided map options. 100 | func NewMapWithOptions(opts ...MapOption) *Map { 101 | m := NewMap() 102 | for _, opt := range opts { 103 | opt(m) 104 | } 105 | return m 106 | } 107 | 108 | // MinSize returns the smallest possible size for a widget. 109 | // For our map this is a constant size representing a single tile on a device with 110 | // the highest known DPI (4x). 111 | func (m *Map) MinSize() fyne.Size { 112 | return fyne.NewSize(64, 64) 113 | } 114 | 115 | // PanEast will move the map to the East by 1 tile. 116 | func (m *Map) PanEast() { 117 | m.x++ 118 | m.Refresh() 119 | } 120 | 121 | // PanNorth will move the map to the North by 1 tile. 122 | func (m *Map) PanNorth() { 123 | m.y-- 124 | m.Refresh() 125 | } 126 | 127 | // PanSouth will move the map to the South by 1 tile. 128 | func (m *Map) PanSouth() { 129 | m.y++ 130 | m.Refresh() 131 | } 132 | 133 | // PanWest will move the map to the west by 1 tile. 134 | func (m *Map) PanWest() { 135 | m.x-- 136 | m.Refresh() 137 | } 138 | 139 | // Zoom sets the zoom level to a specific value, between 0 and 19. 140 | func (m *Map) Zoom(zoom int) { 141 | if zoom < 0 || zoom > 19 { 142 | return 143 | } 144 | delta := zoom - m.zoom 145 | if delta > 0 { 146 | for i := 0; i < delta; i++ { 147 | m.zoomInStep() 148 | } 149 | } else if delta < 0 { 150 | for i := 0; i > delta; i-- { 151 | m.zoomOutStep() 152 | } 153 | } 154 | m.Refresh() 155 | } 156 | 157 | // ZoomIn steps the scale of this map to be one step zoomed in. 158 | func (m *Map) ZoomIn() { 159 | if m.zoom >= 19 { 160 | return 161 | } 162 | m.zoomInStep() 163 | m.Refresh() 164 | } 165 | 166 | // ZoomOut steps the scale of this map to be one step zoomed out. 167 | func (m *Map) ZoomOut() { 168 | if m.zoom <= 0 { 169 | return 170 | } 171 | m.zoomOutStep() 172 | m.Refresh() 173 | } 174 | 175 | // CreateRenderer returns the renderer for this widget. 176 | // A map renderer is simply the map Raster with user interface elements overlaid. 177 | func (m *Map) CreateRenderer() fyne.WidgetRenderer { 178 | var zoom fyne.CanvasObject 179 | if !m.hideZoomButtons { 180 | zoom = container.NewVBox( 181 | newMapButton(theme.ZoomInIcon(), m.ZoomIn), 182 | newMapButton(theme.ZoomOutIcon(), m.ZoomOut)) 183 | } 184 | 185 | var move fyne.CanvasObject 186 | if !m.hideMoveButtons { 187 | buttonLayout := container.NewGridWithColumns(3, layout.NewSpacer(), 188 | newMapButton(theme.MoveUpIcon(), m.PanNorth), layout.NewSpacer(), 189 | newMapButton(theme.NavigateBackIcon(), m.PanWest), layout.NewSpacer(), 190 | newMapButton(theme.NavigateNextIcon(), m.PanEast), layout.NewSpacer(), 191 | newMapButton(theme.MoveDownIcon(), m.PanSouth), layout.NewSpacer()) 192 | move = container.NewVBox(buttonLayout) 193 | } 194 | 195 | var copyright fyne.CanvasObject 196 | if !m.hideAttribution { 197 | license, _ := url.Parse(m.attributionURL) 198 | link := widget.NewHyperlink(m.attributionLabel, license) 199 | copyright = container.NewHBox(layout.NewSpacer(), link) 200 | } 201 | 202 | overlay := container.NewBorder(nil, copyright, move, zoom) 203 | 204 | c := container.NewStack(canvas.NewRaster(m.draw), container.NewPadded(overlay)) 205 | return widget.NewSimpleRenderer(c) 206 | } 207 | 208 | func (m *Map) draw(w, h int) image.Image { 209 | scale := 1 210 | tileSize := tileSize 211 | // TODO use retina tiles once OSM supports it in their server (text scaling issues)... 212 | if c := fyne.CurrentApp().Driver().CanvasForObject(m); c != nil { 213 | scale = int(c.Scale()) 214 | if scale < 1 { 215 | scale = 1 216 | } 217 | tileSize = tileSize * scale 218 | } 219 | 220 | if m.w != w || m.h != h { 221 | m.pixels = image.NewNRGBA(image.Rect(0, 0, w, h)) 222 | } 223 | 224 | midTileX := (w - tileSize*2) / 2 225 | midTileY := (h - tileSize*2) / 2 226 | if m.zoom == 0 { 227 | midTileX += tileSize / 2 228 | midTileY += tileSize / 2 229 | } 230 | 231 | count := 1 << m.zoom 232 | mx := m.x + int(float32(count)/2-0.5) 233 | my := m.y + int(float32(count)/2-0.5) 234 | firstTileX := mx - int(math.Ceil(float64(midTileX)/float64(tileSize))) 235 | firstTileY := my - int(math.Ceil(float64(midTileY)/float64(tileSize))) 236 | 237 | for x := firstTileX; (x-firstTileX)*tileSize <= w+tileSize; x++ { 238 | for y := firstTileY; (y-firstTileY)*tileSize <= h+tileSize; y++ { 239 | if x < 0 || y < 0 || x >= int(count) || y >= int(count) { 240 | continue 241 | } 242 | 243 | src, err := getTile(m.tileSource, x, y, m.zoom, m.cl) 244 | if err != nil { 245 | fyne.LogError("tile fetch error", err) 246 | continue 247 | } 248 | 249 | pos := image.Pt(midTileX+(x-mx)*tileSize, 250 | midTileY+(y-my)*tileSize) 251 | scaled := src 252 | if scale > 1 { 253 | scaled = resize.Resize(uint(tileSize), uint(tileSize), src, resize.Lanczos2) 254 | } 255 | draw.Copy(m.pixels, pos, scaled, image.Rect(0, 0, tileSize, tileSize), draw.Over, nil) 256 | } 257 | } 258 | 259 | return m.pixels 260 | } 261 | 262 | func (m *Map) zoomInStep() { 263 | m.zoom++ 264 | m.x *= 2 265 | m.y *= 2 266 | } 267 | 268 | func (m *Map) zoomOutStep() { 269 | m.zoom-- 270 | m.x /= 2 271 | m.y /= 2 272 | } 273 | -------------------------------------------------------------------------------- /widget/map_test.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "testing" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/test" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestMap_Pan(t *testing.T) { 13 | m := NewMap() 14 | m.Resize(fyne.NewSize(200, 200)) 15 | m.Zoom(3) 16 | assert.Equal(t, 0, m.x) 17 | assert.Equal(t, 0, m.y) 18 | 19 | m.PanSouth() 20 | m.PanEast() 21 | assert.Equal(t, 1, m.x) 22 | assert.Equal(t, 1, m.y) 23 | 24 | m.PanNorth() 25 | m.PanWest() 26 | assert.Equal(t, 0, m.x) 27 | assert.Equal(t, 0, m.y) 28 | } 29 | 30 | func TestMap_Zoom(t *testing.T) { 31 | m := NewMap() 32 | m.Resize(fyne.NewSize(200, 200)) 33 | assert.Equal(t, 0, m.zoom) 34 | m.ZoomIn() 35 | assert.Equal(t, 1, m.zoom) 36 | m.ZoomOut() 37 | assert.Equal(t, 0, m.zoom) 38 | 39 | m.Zoom(5) 40 | assert.Equal(t, 5, m.zoom) 41 | m.Zoom(55) // invalid 42 | assert.Equal(t, 5, m.zoom) 43 | } 44 | 45 | func TestNewMap_WithDefaults(t *testing.T) { 46 | // arrange 47 | w := test.NewApp().NewWindow("TestMap") 48 | m := NewMap() 49 | // action 50 | w.SetContent(m) 51 | // verify 52 | assert.Equal(t, "https://tile.openstreetmap.org/%d/%d/%d.png", m.tileSource) 53 | assert.Equal(t, "OpenStreetMap", m.attributionLabel) 54 | assert.Equal(t, "https://openstreetmap.org", m.attributionURL) 55 | assert.False(t, m.hideAttribution) 56 | assert.False(t, m.hideMoveButtons) 57 | assert.False(t, m.hideZoomButtons) 58 | } 59 | 60 | func TestNewMap_WithOptions(t *testing.T) { 61 | // arrange 62 | w := test.NewApp().NewWindow("TestMap") 63 | m := NewMapWithOptions( 64 | WithScrollButtons(false), 65 | WithZoomButtons(false), 66 | WithAttribution(true, "test", "http://test.org"), 67 | ) 68 | // action 69 | w.SetContent(m) 70 | // verify 71 | assert.Equal(t, "test", m.attributionLabel) 72 | assert.Equal(t, "http://test.org", m.attributionURL) 73 | assert.False(t, m.hideAttribution) 74 | assert.True(t, m.hideMoveButtons) 75 | assert.True(t, m.hideZoomButtons) 76 | } 77 | -------------------------------------------------------------------------------- /widget/mapbutton.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/canvas" 6 | "fyne.io/fyne/v2/theme" 7 | "fyne.io/fyne/v2/widget" 8 | ) 9 | 10 | type mapButton struct { 11 | widget.Button 12 | } 13 | 14 | func newMapButton(icon fyne.Resource, f func()) *mapButton { 15 | b := &mapButton{} 16 | b.ExtendBaseWidget(b) 17 | 18 | b.Icon = icon 19 | b.OnTapped = f 20 | return b 21 | } 22 | 23 | func (b *mapButton) CreateRenderer() fyne.WidgetRenderer { 24 | return &mapButtonRenderer{WidgetRenderer: b.Button.CreateRenderer(), 25 | bg: canvas.NewRectangle(theme.Color(theme.ColorNameShadow))} 26 | } 27 | 28 | type mapButtonRenderer struct { 29 | fyne.WidgetRenderer 30 | 31 | bg *canvas.Rectangle 32 | } 33 | 34 | func (r *mapButtonRenderer) Layout(s fyne.Size) { 35 | halfPad := theme.Padding() / 2 36 | r.bg.Move(fyne.NewPos(halfPad, halfPad)) 37 | r.bg.Resize(s.Subtract(fyne.NewSize(theme.Padding(), theme.Padding()))) 38 | 39 | r.WidgetRenderer.Layout(s) 40 | } 41 | 42 | func (r *mapButtonRenderer) Objects() []fyne.CanvasObject { 43 | return append([]fyne.CanvasObject{r.bg}, r.WidgetRenderer.Objects()...) 44 | } 45 | 46 | func (r *mapButtonRenderer) Refresh() { 47 | r.bg.FillColor = theme.Color(theme.ColorNameShadow) 48 | r.WidgetRenderer.Refresh() 49 | } 50 | -------------------------------------------------------------------------------- /widget/mapcache.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "image" 7 | "image/png" 8 | "net/http" 9 | ) 10 | 11 | var tileMap = make(map[string]image.Image) 12 | 13 | func getTile(tileSource string, x, y, zoom int, cl *http.Client) (image.Image, error) { 14 | if tileSource == "" { 15 | return nil, errors.New("no tileSource provided") 16 | } 17 | 18 | u := fmt.Sprintf(tileSource, zoom, x, y) 19 | if tile, ok := tileMap[u]; ok { 20 | return tile, nil 21 | } 22 | 23 | req, err := http.NewRequest("GET", u, nil) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | req.Header.Set("User-Agent", "Fyne-X Map Widget/0.1") 29 | res, err := cl.Do(req) 30 | if err != nil { 31 | return nil, err 32 | } 33 | defer res.Body.Close() 34 | 35 | img, err := png.Decode(res.Body) 36 | if err == nil { 37 | tileMap[u] = img 38 | } 39 | return img, err 40 | } 41 | -------------------------------------------------------------------------------- /widget/numerical_entry.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "strconv" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/driver/mobile" 8 | "fyne.io/fyne/v2/widget" 9 | ) 10 | 11 | // NumericalEntry is an extended entry that only allows numerical input. 12 | // Only integers are allowed by default. Support for floats can be enabled by setting AllowFloat. 13 | type NumericalEntry struct { 14 | widget.Entry 15 | AllowFloat bool 16 | // AllowNegative determines if negative numbers can be entered. 17 | AllowNegative bool 18 | } 19 | 20 | // NewNumericalEntry returns an extended entry that only allows numerical input. 21 | func NewNumericalEntry() *NumericalEntry { 22 | entry := &NumericalEntry{} 23 | entry.ExtendBaseWidget(entry) 24 | return entry 25 | } 26 | 27 | // TypedRune is called when this item receives a char event. 28 | // 29 | // Implements: fyne.Focusable 30 | func (e *NumericalEntry) TypedRune(r rune) { 31 | if e.Entry.CursorColumn == 0 && e.Entry.CursorRow == 0 { 32 | if e.AllowNegative { 33 | if len(e.Text) > 0 && e.Text[0] == '-' { 34 | return 35 | } else if r == '-' { 36 | e.Entry.TypedRune(r) 37 | return 38 | } 39 | } 40 | } 41 | 42 | if r >= '0' && r <= '9' { 43 | e.Entry.TypedRune(r) 44 | return 45 | } 46 | 47 | if e.AllowFloat && (r == '.' || r == ',') { 48 | e.Entry.TypedRune(r) 49 | return 50 | } 51 | } 52 | 53 | // TypedShortcut handles the registered shortcuts. 54 | // 55 | // Implements: fyne.Shortcutable 56 | func (e *NumericalEntry) TypedShortcut(shortcut fyne.Shortcut) { 57 | paste, ok := shortcut.(*fyne.ShortcutPaste) 58 | if !ok { 59 | e.Entry.TypedShortcut(shortcut) 60 | return 61 | } 62 | 63 | if e.isNumber(paste.Clipboard.Content()) { 64 | e.Entry.TypedShortcut(shortcut) 65 | } 66 | } 67 | 68 | // Keyboard sets up the right keyboard to use on mobile. 69 | // 70 | // Implements: mobile.Keyboardable 71 | func (e *NumericalEntry) Keyboard() mobile.KeyboardType { 72 | return mobile.NumberKeyboard 73 | } 74 | 75 | func (e *NumericalEntry) isNumber(content string) bool { 76 | if e.AllowFloat { 77 | _, err := strconv.ParseFloat(content, 64) 78 | return err == nil 79 | } 80 | 81 | _, err := strconv.Atoi(content) 82 | return err == nil 83 | } 84 | -------------------------------------------------------------------------------- /widget/numerical_entry_test.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "testing" 5 | 6 | "fyne.io/fyne/v2/test" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNumericalnEntry_Int(t *testing.T) { 11 | entry := NewNumericalEntry() 12 | 13 | test.Type(entry, "Not a number") 14 | assert.Empty(t, entry.Text) 15 | 16 | number := "123456789" 17 | test.Type(entry, number) 18 | assert.Equal(t, number, entry.Text) 19 | 20 | entry.CursorColumn = 0 21 | test.Type(entry, "3") 22 | assert.Equal(t, "3123456789", entry.Text) 23 | 24 | entry.CursorColumn = 0 25 | test.Type(entry, "-4") 26 | assert.Equal(t, "43123456789", entry.Text) 27 | } 28 | 29 | func TestNumericalnEntry_Float(t *testing.T) { 30 | entry := NewNumericalEntry() 31 | entry.AllowFloat = true 32 | 33 | test.Type(entry, "Not a number") 34 | assert.Empty(t, entry.Text) 35 | 36 | number := "123.456789" 37 | test.Type(entry, number) 38 | assert.Equal(t, number, entry.Text) 39 | 40 | entry.CursorColumn = 0 41 | test.Type(entry, "3") 42 | assert.Equal(t, "3123.456789", entry.Text) 43 | 44 | entry.CursorColumn = 0 45 | test.Type(entry, "-4") 46 | assert.Equal(t, "43123.456789", entry.Text) 47 | } 48 | 49 | func TestNumericalEntry_NegInt(t *testing.T) { 50 | entry := NewNumericalEntry() 51 | entry.AllowNegative = true 52 | 53 | test.Type(entry, "-2") 54 | assert.Equal(t, "-2", entry.Text) 55 | 56 | entry.Text = "" 57 | test.Type(entry, "24-") 58 | assert.Equal(t, "24", entry.Text) 59 | entry.CursorColumn = 0 60 | test.Type(entry, "-") 61 | assert.Equal(t, "-24", entry.Text) 62 | 63 | entry.CursorColumn = 0 64 | test.Type(entry, "4") 65 | assert.Equal(t, "-24", entry.Text) 66 | } 67 | 68 | func TestNumericalEntry_NegFloat(t *testing.T) { 69 | entry := NewNumericalEntry() 70 | entry.AllowNegative = true 71 | entry.AllowFloat = true 72 | 73 | test.Type(entry, "-2.4") 74 | assert.Equal(t, "-2.4", entry.Text) 75 | 76 | entry.Text = "" 77 | test.Type(entry, "-24.-5") 78 | assert.Equal(t, "-24.5", entry.Text) 79 | 80 | entry.CursorColumn = 0 81 | test.Type(entry, "-") 82 | assert.Equal(t, "-24.5", entry.Text) 83 | 84 | entry.CursorColumn = 0 85 | test.Type(entry, "4") 86 | assert.Equal(t, "-24.5", entry.Text) 87 | 88 | } 89 | -------------------------------------------------------------------------------- /widget/testdata/filetree/selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/widget/testdata/filetree/selected.png -------------------------------------------------------------------------------- /widget/testdata/gif/README.md: -------------------------------------------------------------------------------- 1 | # Test images 2 | 3 | earth.gif downloaded under license CC BY-SA 3.0 from Wikipedia user Marvel. 4 | Originally derived from NASA imagery, more information at https://commons.wikimedia.org/wiki/File:Rotating_earth_(large).gif. 5 | -------------------------------------------------------------------------------- /widget/testdata/gif/earth-once.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/widget/testdata/gif/earth-once.gif -------------------------------------------------------------------------------- /widget/testdata/gif/earth.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/widget/testdata/gif/earth.gif -------------------------------------------------------------------------------- /widget/testdata/gif/initial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/widget/testdata/gif/initial.png -------------------------------------------------------------------------------- /widget/testdata/twostatetoolbaraction/offstate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/widget/testdata/twostatetoolbaraction/offstate.png -------------------------------------------------------------------------------- /widget/testdata/twostatetoolbaraction/onstate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyne-io/fyne-x/58a230ad1acbf18ad25b28f748a74d898c915581/widget/testdata/twostatetoolbaraction/onstate.png -------------------------------------------------------------------------------- /widget/twostatetoolbaraction.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/widget" 6 | ) 7 | 8 | // TwoStateToolbarAction is a push button style of ToolbarItem that displays a different 9 | // icon depending on its state. 10 | // 11 | // state is a boolean indicating off and on. The actual meaning of the boolean depends on how it is used. For 12 | // example, in a media play app, false might indicate that the medium file is not being played, and true might 13 | // indicate that the file is being played. 14 | // Similarly, the two states could be used to indicate that a panel is being hidden or shown. 15 | type TwoStateToolbarAction struct { 16 | on bool 17 | offIcon fyne.Resource 18 | onIcon fyne.Resource 19 | OnActivated func(bool) `json:"-"` 20 | 21 | button widget.Button 22 | } 23 | 24 | // NewTwoStateToolbarAction returns a new push button style of Toolbar item that displays 25 | // a different icon for each of its two states 26 | func NewTwoStateToolbarAction(offStateIcon fyne.Resource, 27 | onStateIcon fyne.Resource, 28 | onTapped func(bool)) *TwoStateToolbarAction { 29 | t := &TwoStateToolbarAction{offIcon: offStateIcon, onIcon: onStateIcon, OnActivated: onTapped} 30 | t.button.SetIcon(t.offIcon) 31 | t.button.OnTapped = t.activated 32 | return t 33 | } 34 | 35 | // GetOn returns the current state of the toolbaraction 36 | func (t *TwoStateToolbarAction) GetOn() bool { 37 | return t.on 38 | } 39 | 40 | // SetOn sets the state of the toolbaraction 41 | func (t *TwoStateToolbarAction) SetOn(on bool) { 42 | t.on = on 43 | if t.OnActivated != nil { 44 | t.OnActivated(t.on) 45 | } 46 | t.setButtonIcon() 47 | t.button.Refresh() 48 | } 49 | 50 | // SetOffIcon sets the icon that is displayed when the state is false 51 | func (t *TwoStateToolbarAction) SetOffIcon(icon fyne.Resource) { 52 | t.offIcon = icon 53 | t.setButtonIcon() 54 | t.button.Refresh() 55 | } 56 | 57 | // SetOnIcon sets the icon that is displayed when the state is true 58 | func (t *TwoStateToolbarAction) SetOnIcon(icon fyne.Resource) { 59 | t.onIcon = icon 60 | t.setButtonIcon() 61 | t.button.Refresh() 62 | } 63 | 64 | // ToolbarObject gets a button to render this ToolbarAction 65 | func (t *TwoStateToolbarAction) ToolbarObject() fyne.CanvasObject { 66 | t.button.Importance = widget.LowImportance 67 | 68 | // synchronize properties 69 | t.setButtonIcon() 70 | t.button.OnTapped = t.activated 71 | return &t.button 72 | } 73 | 74 | func (t *TwoStateToolbarAction) activated() { 75 | if !t.on { 76 | t.on = true 77 | } else { 78 | t.on = false 79 | } 80 | t.setButtonIcon() 81 | if t.OnActivated != nil { 82 | t.OnActivated(t.on) 83 | } 84 | t.button.Refresh() 85 | } 86 | 87 | func (t *TwoStateToolbarAction) setButtonIcon() { 88 | if !t.on { 89 | t.button.Icon = t.offIcon 90 | } else { 91 | t.button.Icon = t.onIcon 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /widget/twostatetoolbaraction_test.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "testing" 5 | 6 | "fyne.io/fyne/v2/test" 7 | "fyne.io/fyne/v2/theme" 8 | "fyne.io/fyne/v2/widget" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestNewTwoStateToolbarAction(t *testing.T) { 14 | action := NewTwoStateToolbarAction(theme.MediaPlayIcon(), 15 | theme.MediaPauseIcon(), 16 | func(_ bool) {}) 17 | assert.Equal(t, theme.MediaPlayIcon().Name(), action.offIcon.Name()) 18 | assert.Equal(t, theme.MediaPauseIcon().Name(), action.onIcon.Name()) 19 | assert.Equal(t, action.offIcon.Name(), action.button.Icon.Name()) 20 | } 21 | 22 | func TestTwoStateToolbarAction_Activated(t *testing.T) { 23 | action := NewTwoStateToolbarAction(theme.MediaPlayIcon(), 24 | theme.MediaPauseIcon(), 25 | func(_ bool) {}) 26 | require.Equal(t, action.offIcon.Name(), action.button.Icon.Name()) 27 | action.button.Tapped(nil) 28 | assert.Equal(t, action.onIcon.Name(), action.button.Icon.Name()) 29 | } 30 | 31 | func TestTwoStateToolbarAction_Tapped(t *testing.T) { 32 | test.NewApp() 33 | action := NewTwoStateToolbarAction(theme.MediaPlayIcon(), 34 | theme.MediaPauseIcon(), 35 | func(_ bool) {}) 36 | tb := widget.NewToolbar(action) 37 | w := test.NewWindow(tb) 38 | defer w.Close() 39 | test.AssertRendersToImage(t, "twostatetoolbaraction/offstate.png", w.Canvas()) 40 | action.button.Tapped(nil) 41 | test.AssertRendersToImage(t, "twostatetoolbaraction/onstate.png", w.Canvas()) 42 | } 43 | 44 | func TestTwoStateToolbarAction_GetSetState(t *testing.T) { 45 | var ts bool 46 | playState := false 47 | test.NewApp() 48 | action := NewTwoStateToolbarAction(theme.MediaPlayIcon(), 49 | theme.MediaPauseIcon(), 50 | func(on bool) { 51 | ts = on 52 | }) 53 | tb := widget.NewToolbar(action) 54 | w := test.NewWindow(tb) 55 | defer w.Close() 56 | assert.Equal(t, playState, action.GetOn()) 57 | action.SetOn(true) 58 | assert.Equal(t, true, action.GetOn()) 59 | assert.Equal(t, true, ts) 60 | test.AssertRendersToImage(t, "twostatetoolbaraction/onstate.png", w.Canvas()) 61 | } 62 | 63 | func TestTwoStateToolbarAction_SetOffStateIcon(t *testing.T) { 64 | test.NewApp() 65 | action := NewTwoStateToolbarAction(nil, 66 | theme.MediaPauseIcon(), 67 | func(staone bool) {}) 68 | tb := widget.NewToolbar(action) 69 | w := test.NewWindow(tb) 70 | defer w.Close() 71 | 72 | action.SetOffIcon(theme.MediaPlayIcon()) 73 | assert.Equal(t, theme.MediaPlayIcon().Name(), action.offIcon.Name()) 74 | } 75 | 76 | func TestTwoStateToolbarAction_SetOnStateIcon(t *testing.T) { 77 | test.NewApp() 78 | action := NewTwoStateToolbarAction(theme.MediaPlayIcon(), 79 | nil, 80 | func(on bool) {}) 81 | tb := widget.NewToolbar(action) 82 | w := test.NewWindow(tb) 83 | defer w.Close() 84 | 85 | action.SetOnIcon(theme.MediaPauseIcon()) 86 | assert.Equal(t, theme.MediaPauseIcon().Name(), action.onIcon.Name()) 87 | } 88 | -------------------------------------------------------------------------------- /widget/widget.go: -------------------------------------------------------------------------------- 1 | // Package widget contains community extensions for Fyne widgets 2 | package widget // import "fyne.io/x/fyne/widget" 3 | -------------------------------------------------------------------------------- /wrapper/mouseable.go: -------------------------------------------------------------------------------- 1 | package wrapper 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/driver/desktop" 6 | "fyne.io/fyne/v2/widget" 7 | ) 8 | 9 | var _ fyne.Widget = (*mouseableObject)(nil) 10 | var _ desktop.Hoverable = (*mouseableObject)(nil) 11 | 12 | // handles the canvas object and mouse events 13 | type mouseableObject struct { 14 | widget.BaseWidget 15 | object fyne.CanvasObject 16 | mouseIn func(*desktop.MouseEvent) 17 | mouseMoved func(*desktop.MouseEvent) 18 | mouseOut func() 19 | } 20 | 21 | // Content returns the originl object that was set to be hoverable. 22 | func (m *mouseableObject) Content() fyne.CanvasObject { 23 | return m.object 24 | } 25 | 26 | // CreateRenderer is a private method to Fyne which links this widget to its renderer. 27 | // 28 | // Implements: fyne.Widget 29 | func (m *mouseableObject) CreateRenderer() fyne.WidgetRenderer { 30 | if m.object == nil { 31 | return nil 32 | } 33 | if o, ok := m.object.(fyne.Widget); ok { 34 | return o.CreateRenderer() 35 | } 36 | return widget.NewSimpleRenderer(m.object) 37 | } 38 | 39 | // MouseIn is called when the mouse enters the widget. 40 | // 41 | // Implements: desktop.Hoverable 42 | func (m *mouseableObject) MouseIn(e *desktop.MouseEvent) { 43 | if m.mouseIn == nil { 44 | return 45 | } 46 | 47 | if o, ok := m.object.(desktop.Hoverable); ok { 48 | o.MouseIn(e) 49 | } 50 | 51 | m.mouseIn(e) 52 | } 53 | 54 | // MouseMoved is called when the mouse moves over the widget. 55 | // 56 | // Implements: desktop.Hoverable 57 | func (m *mouseableObject) MouseMoved(e *desktop.MouseEvent) { 58 | if m.mouseMoved == nil { 59 | return 60 | } 61 | 62 | if o, ok := m.object.(desktop.Hoverable); ok { 63 | o.MouseMoved(e) 64 | } 65 | 66 | m.mouseMoved(e) 67 | } 68 | 69 | // MouseOut is called when the mouse exits the widget. 70 | // 71 | // Implements: desktop.Hoverable 72 | func (m *mouseableObject) MouseOut() { 73 | if m.mouseOut == nil { 74 | return 75 | } 76 | 77 | if o, ok := m.object.(desktop.Hoverable); ok { 78 | o.MouseOut() 79 | } 80 | 81 | m.mouseOut() 82 | } 83 | 84 | // MakeHoverable sets the object to be hoverable. 85 | func MakeHoverable(object fyne.CanvasObject, mouseIn, mouseMoved func(*desktop.MouseEvent), mouseout func()) fyne.CanvasObject { 86 | m := &mouseableObject{ 87 | object: object, 88 | mouseIn: mouseIn, 89 | mouseMoved: mouseMoved, 90 | mouseOut: mouseout, 91 | } 92 | m.ExtendBaseWidget(m) 93 | return m 94 | } 95 | -------------------------------------------------------------------------------- /wrapper/mouseable_test.go: -------------------------------------------------------------------------------- 1 | package wrapper 2 | 3 | import ( 4 | "testing" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/container" 8 | "fyne.io/fyne/v2/driver/desktop" 9 | "fyne.io/fyne/v2/test" 10 | "fyne.io/fyne/v2/theme" 11 | "fyne.io/fyne/v2/widget" 12 | ) 13 | 14 | func TestMouseable(t *testing.T) { 15 | app := test.NewApp() 16 | win := app.NewWindow("Test") 17 | 18 | label := widget.NewLabel("Label 1, not wrapped") 19 | label2 := widget.NewLabel("Label, mouseable") 20 | 21 | pos := fyne.NewPos(0, 0) 22 | in := false 23 | out := false 24 | mouseable := MakeHoverable(label, func(e *desktop.MouseEvent) { 25 | in = true 26 | }, func(e *desktop.MouseEvent) { 27 | pos = e.Position 28 | }, func() { 29 | out = true 30 | }) 31 | 32 | win.SetContent(container.NewHBox(label2, mouseable)) 33 | 34 | moveTo := fyne.NewPos(15, 15) 35 | expectedPos := mouseable.Position().Add(moveTo) 36 | test.MoveMouse(win.Canvas(), mouseable.Position().Add(fyne.NewPos(5, 5))) // to place the mouse 37 | if !in { 38 | t.Error("MouseIn was not called") 39 | } 40 | test.MoveMouse(win.Canvas(), expectedPos) // to move the mouse 41 | if pos != moveTo.Subtract(fyne.NewPos(theme.Padding(), theme.Padding())) { 42 | t.Error("MouseMoved was not called", pos, moveTo) 43 | } 44 | if out { 45 | t.Error("MouseOut was called") 46 | } 47 | 48 | test.MoveMouse(win.Canvas(), fyne.NewPos(0, 0)) // go out now 49 | if !out { 50 | t.Error("MouseOut was not called") 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /wrapper/tappable.go: -------------------------------------------------------------------------------- 1 | package wrapper 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/widget" 6 | ) 7 | 8 | var _ fyne.Widget = (*tappableObject)(nil) 9 | var _ fyne.Tappable = (*tappableObject)(nil) 10 | 11 | // handles the tap event. 12 | type tappableObject struct { 13 | widget.BaseWidget 14 | object fyne.CanvasObject 15 | onTapped func(*fyne.PointEvent) 16 | } 17 | 18 | // Content returns the encapsulated widget. 19 | func (t *tappableObject) Content() fyne.CanvasObject { 20 | return t.object 21 | } 22 | 23 | // CreateRenderer is a private method to Fyne which links this widget to its renderer. 24 | // 25 | // Implements: fyne.Widget 26 | func (t *tappableObject) CreateRenderer() fyne.WidgetRenderer { 27 | if t.object == nil { 28 | return nil 29 | } 30 | if o, ok := t.object.(fyne.Widget); ok { 31 | return o.CreateRenderer() 32 | } 33 | return widget.NewSimpleRenderer(t.object) 34 | } 35 | 36 | // Tapped reacts on tap (or click) events. 37 | // 38 | // Implements: fyne.Tappable 39 | func (t *tappableObject) Tapped(e *fyne.PointEvent) { 40 | if t.object == nil { 41 | return 42 | } 43 | 44 | if o, ok := t.object.(fyne.Tappable); ok { 45 | o.Tapped(e) 46 | } 47 | t.onTapped(e) 48 | } 49 | 50 | // MakeTappable set the object to be tappable. 51 | func MakeTappable(object fyne.CanvasObject, ontapped func(*fyne.PointEvent)) fyne.CanvasObject { 52 | tappable := &tappableObject{object: object, onTapped: ontapped} 53 | tappable.ExtendBaseWidget(tappable) 54 | return tappable 55 | } 56 | -------------------------------------------------------------------------------- /wrapper/tappable_test.go: -------------------------------------------------------------------------------- 1 | package wrapper 2 | 3 | import ( 4 | "testing" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/container" 8 | "fyne.io/fyne/v2/test" 9 | "fyne.io/fyne/v2/widget" 10 | ) 11 | 12 | func TestTappable(t *testing.T) { 13 | app := test.NewApp() 14 | win := app.NewWindow("Test") 15 | win.Resize(fyne.NewSize(400, 400)) 16 | defer win.Close() 17 | 18 | label1 := widget.NewLabel("Label 1, not wrapped") 19 | label2 := widget.NewLabel("Label 2, click me") 20 | 21 | tapped := false 22 | wrapped := MakeTappable(label2, func(e *fyne.PointEvent) { 23 | tapped = true 24 | }) 25 | 26 | mainContainer := container.NewGridWithColumns(2, label1, wrapped) 27 | win.SetContent(mainContainer) 28 | 29 | // Click on wrapped label 30 | test.Tap(wrapped.(fyne.Tappable)) 31 | if !tapped { 32 | t.Error("Tapped event not fired") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /wrapper/wrapper.go: -------------------------------------------------------------------------------- 1 | // Package wrapper provides utility functions and wrappers. 2 | package wrapper // import fyne.io/x/fyne/wrapper 3 | --------------------------------------------------------------------------------