diff --git a/cmd/simple_ui/main.go b/cmd/simple_ui/main.go new file mode 100644 index 0000000..7423213 --- /dev/null +++ b/cmd/simple_ui/main.go @@ -0,0 +1,7 @@ +package main + +import "simple_ui/internal/app" + +func main() { + app.Run() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0a6c530 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module simple_ui + +go 1.24.5 + +require gioui.org v0.8.0 + +require ( + gioui.org/shader v1.0.8 // indirect + github.com/go-text/typesetting v0.2.1 // indirect + golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect + golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect + golang.org/x/image v0.18.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..32c2072 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg= +gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc= +gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= +gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= +gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= +github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= +github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= +golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= +golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU= +golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..92a75e2 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,101 @@ +package app + +import ( + "log" + "os" + "simple_ui/internal/components" + "simple_ui/internal/ui" + + "gioui.org/app" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "golang.org/x/exp/shiny/materialdesign/icons" +) + +func Run() { + go runApp() + app.Main() +} + +func runApp() { + w := &app.Window{} + + w.Option( + app.Title("Simple UI"), + app.Size(800, 600), + app.MinSize(400, 300), + ) + + err := runWindowEvents(w) + if err != nil { + log.Fatal(err) + } + os.Exit(0) +} + +func runWindowEvents(window *app.Window) error { + theme := material.NewTheme() + var ops op.Ops + currentPage := HomePage + + navMenu := components.NewNavigationMenu(theme) + + homeIcon, err := widget.NewIcon(icons.ActionHome) + if err != nil { + log.Fatalf("failed to create home icon: %v", err) + } + aboutIcon, err := widget.NewIcon(icons.ActionInfo) + if err != nil { + log.Fatalf("failed to create about icon: %v", err) + } + + navMenu.AddItem("Home", homeIcon, func() { currentPage = HomePage }) + navMenu.AddItem("About", aboutIcon, func() { currentPage = AboutPage }) + + navMenu.SetSelected(int(currentPage)) + + pages := []ui.Page{ + HomePage: ui.NewHomePage(theme), + AboutPage: ui.NewAboutPage(theme), + } + + for { + switch e := window.Event().(type) { + case app.DestroyEvent: + return e.Err + case app.FrameEvent: + gtx := app.NewContext(&ops, e) + + navMenu.HandleClicks(gtx) + navMenu.SetSelected(int(currentPage)) + + layout.Flex{ + Axis: layout.Horizontal, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return navMenu.Layout(gtx) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: unit.Dp(16), + Bottom: unit.Dp(16), + Left: unit.Dp(16), + Right: unit.Dp(16), + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return pages[currentPage].Layout(gtx) + }) + }), + ) + + e.Frame(gtx.Ops) + } + } +} + +const ( + HomePage = iota + AboutPage +) diff --git a/internal/components/dropdown.go b/internal/components/dropdown.go new file mode 100644 index 0000000..9a120c7 --- /dev/null +++ b/internal/components/dropdown.go @@ -0,0 +1,25 @@ +package components + +import ( + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" +) + +type Dropdown struct { + Editor widget.Editor + Clicks []*widget.Clickable + Options []string + Filtered []string + Show bool +} + +func NewDropdown(options []string) *Dropdown { + return &Dropdown{ + Options: options, + } +} + +func (d *Dropdown) Layout(gtx layout.Context, th *material.Theme, hint string) (layout.Dimensions, string) { + return layout.Dimensions{}, "" +} diff --git a/internal/components/icon_button.go b/internal/components/icon_button.go new file mode 100644 index 0000000..042e9a7 --- /dev/null +++ b/internal/components/icon_button.go @@ -0,0 +1,89 @@ +package components + +import ( + "image" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" +) + +type ButtonState int + +const ( + NormalButton ButtonState = iota + SelectedButton + UnselectedButton +) + +type iconAndTextButton struct { + theme *material.Theme + button *widget.Clickable + icon *widget.Icon + word string + axis layout.Axis + state ButtonState +} + +func (b iconAndTextButton) Layout(gtx layout.Context) layout.Dimensions { + btn := material.ButtonLayout(b.theme, b.button) + switch b.state { + case NormalButton: + btn.Background = b.theme.Palette.Bg + case SelectedButton: + btn.Background = b.theme.Palette.ContrastBg + case UnselectedButton: + btn.Background = b.theme.Palette.Bg + } + + return btn.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + iconAndLabel := layout.Flex{Axis: b.axis, Alignment: layout.Middle} + var bottomSpacer, textIconSpacer unit.Dp + + if b.axis == layout.Horizontal { + bottomSpacer = unit.Dp(0) + textIconSpacer = unit.Dp(2) + } else { + bottomSpacer = unit.Dp(2) + textIconSpacer = unit.Dp(0) + } + + layIcon := layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Bottom: bottomSpacer, Right: textIconSpacer}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + var d layout.Dimensions + if b.icon != nil { + size := gtx.Dp(unit.Dp(24)) + + if b.axis == layout.Horizontal { + size = gtx.Dp(unit.Dp(56)) - 2*gtx.Dp(unit.Dp(16)) + } + + gtx.Constraints = layout.Exact(image.Pt(size, size)) + if b.state == SelectedButton { + d = b.icon.Layout(gtx, b.theme.Palette.ContrastFg) + } else { + d = b.icon.Layout(gtx, b.theme.Palette.Fg) + } + } + return d + }) + }) + + layLabel := layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Top: bottomSpacer, Right: textIconSpacer}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + l := material.Body1(b.theme, b.word) + if b.state == SelectedButton { + l.Color = b.theme.Palette.ContrastFg + } else { + l.Color = b.theme.Palette.Fg + } + return l.Layout(gtx) + }) + }) + + return iconAndLabel.Layout(gtx, layIcon, layLabel) + }) + }) +} diff --git a/internal/components/navigation_menu.go b/internal/components/navigation_menu.go new file mode 100644 index 0000000..af9f94c --- /dev/null +++ b/internal/components/navigation_menu.go @@ -0,0 +1,194 @@ +package components + +import ( + "fmt" + "image/color" + "simple_ui/internal/utils" + "time" + + "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "golang.org/x/exp/shiny/materialdesign/icons" +) + +type NavigationMenu struct { + theme *material.Theme + contractButton *widget.Clickable + items []MenuItem + contracted bool + selected int + anim utils.Animation +} + +type MenuItem struct { + Label string + Button *widget.Clickable + Icon *widget.Icon + OnClick func() +} + +func NewNavigationMenu(theme *material.Theme) *NavigationMenu { + nm := &NavigationMenu{ + theme: theme, + contractButton: &widget.Clickable{}, + items: make([]MenuItem, 0), + } + nm.anim.Start(0, 0, 0, utils.EasingInOutSine) + return nm +} + +func (nm *NavigationMenu) ToggleContracted() { + nm.contracted = !nm.contracted + var end float32 + if nm.contracted { + end = 1 + } else { + end = 0 + } + nm.anim.Start(nm.anim.Current(), end, 250*time.Millisecond, utils.EasingInOutSine) + fmt.Printf("Navigation menu toggled: %v\n", nm.contracted) +} + +func (nm *NavigationMenu) IsContracted() bool { + return nm.contracted +} + +func (nm *NavigationMenu) AddItem(label string, icon *widget.Icon, onClick func()) *widget.Clickable { + btn := &widget.Clickable{} + nm.items = append(nm.items, MenuItem{ + Label: label, + Button: btn, + Icon: icon, + OnClick: onClick, + }) + return btn +} + +func (nm *NavigationMenu) SetSelected(index int) { + if index >= 0 && index < len(nm.items) { + nm.selected = index + } +} + +func (nm *NavigationMenu) HandleClicks(gtx layout.Context) { + for i, item := range nm.items { + if item.Button.Clicked(gtx) { + nm.selected = i + if item.OnClick != nil { + item.OnClick() + } + } + } +} + +func (nm *NavigationMenu) Layout(gtx layout.Context) layout.Dimensions { + // Animate contraction/expansion + value := nm.anim.Update(gtx) + contractedWidth := float32(gtx.Dp(unit.Dp(100))) + expandedWidth := float32(gtx.Dp(unit.Dp(240))) + width := int(expandedWidth + (contractedWidth-expandedWidth)*value) + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + + // Add background color + backgroundColor := color.NRGBA{R: 205, G: 205, B: 205, A: 255} // Blue + return layout.Background{}.Layout(gtx, + func(gtx layout.Context) layout.Dimensions { + paint.FillShape(gtx.Ops, backgroundColor, clip.Rect{Max: gtx.Constraints.Min}.Op()) + return layout.Dimensions{Size: gtx.Constraints.Min} + }, + func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + + return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Axis: layout.Horizontal, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if nm.contracted { + return layout.Dimensions{} + } + return material.H6(nm.theme, "Simple Weather").Layout(gtx) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Spacer{Width: unit.Dp(16)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + contractedIcon, err := widget.NewIcon(icons.NavigationChevronLeft) + if err != nil { + return layout.Dimensions{} + } + if nm.IsContracted() { + contractedIcon, err = widget.NewIcon(icons.NavigationChevronRight) + if err != nil { + return layout.Dimensions{} + } + } + + if nm.contractButton.Clicked(gtx) { + nm.ToggleContracted() + } + contractButton := material.IconButton(nm.theme, nm.contractButton, contractedIcon, "Toggle menu") + contractButton.Background = nm.theme.Palette.Bg + contractButton.Color = nm.theme.Palette.Fg + + return contractButton.Layout(gtx) + }), + ) + }) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, nm.menuItems()...) + }), + ) + }, + ) +} + +func (nm *NavigationMenu) menuItems() []layout.FlexChild { + children := make([]layout.FlexChild, len(nm.items)) + + for i, item := range nm.items { + i, item := i, item + children[i] = layout.Rigid(func(gtx layout.Context) layout.Dimensions { + + var state ButtonState + + if i == nm.selected { + state = SelectedButton + } else { + state = UnselectedButton + } + axis := layout.Horizontal + + if nm.IsContracted() { + axis = layout.Vertical + } + + btn := &iconAndTextButton{ + theme: nm.theme, + button: item.Button, + icon: item.Icon, + word: item.Label, + state: state, + axis: axis, + } + + return layout.Inset{Top: unit.Dp(2), Bottom: unit.Dp(2), Left: unit.Dp(4), Right: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return btn.Layout(gtx) + }) + }) + } + + return children +} diff --git a/internal/ui/about_page.go b/internal/ui/about_page.go new file mode 100644 index 0000000..283308e --- /dev/null +++ b/internal/ui/about_page.go @@ -0,0 +1,29 @@ +package ui + +import ( + "gioui.org/layout" + "gioui.org/widget/material" +) + +type AboutPage struct { + theme *material.Theme +} + +func NewAboutPage(theme *material.Theme) *AboutPage { + return &AboutPage{ + theme: theme, + } +} + +func (p *AboutPage) Layout(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(16).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + body := material.Body1(p.theme, "This is a simple UI application built with Gio.") + return body.Layout(gtx) + }), + ) + }) +} diff --git a/internal/ui/home.go b/internal/ui/home.go new file mode 100644 index 0000000..e1565a2 --- /dev/null +++ b/internal/ui/home.go @@ -0,0 +1,63 @@ +package ui + +import ( + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" +) + +type HomePage struct { + searchButton *widget.Clickable + searchField *widget.Editor + theme *material.Theme +} + +func NewHomePage(theme *material.Theme) *HomePage { + return &HomePage{ + searchButton: &widget.Clickable{}, + theme: theme, + searchField: &widget.Editor{SingleLine: true}, + } +} + +func searchClicked(searchField *widget.Editor) { + query := searchField.Text() + searchField.SetText("") + println("Search query:", query) +} + +func (h *HomePage) Layout(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(16).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Axis: layout.Horizontal, + }.Layout(gtx, + layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions { + return material.Editor(h.theme, h.searchField, "Search...").Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if h.searchButton.Clicked(gtx) { + searchClicked(h.searchField) + } + return material.Button(h.theme, h.searchButton, "Search").Layout(gtx) + }), + ) + }), + ) + }) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + + return layout.Dimensions{} + }), + ) + }) +} diff --git a/internal/ui/page.go b/internal/ui/page.go new file mode 100644 index 0000000..26924e3 --- /dev/null +++ b/internal/ui/page.go @@ -0,0 +1,9 @@ +package ui + +import ( + "gioui.org/layout" +) + +type Page interface { + Layout(gtx layout.Context) layout.Dimensions +} diff --git a/internal/utils/animation.go b/internal/utils/animation.go new file mode 100644 index 0000000..32b6e9a --- /dev/null +++ b/internal/utils/animation.go @@ -0,0 +1,79 @@ +package utils + +import ( + "math" + "time" + + "gioui.org/layout" + "gioui.org/op" +) + +type Animation struct { + start float32 + end float32 + current float32 + startTime time.Time + duration time.Duration + active bool + Easing Easing +} + +type Easing int + +const ( + None Easing = iota + EasingIn + EasingOut + EasingOutSine + EasingInOutSine +) + +func (a *Animation) Current() float32 { + return a.current +} + +func applyEasing(easing Easing, t float32) float32 { + switch easing { + case EasingIn: + return t * t + case EasingOut: + return t * (2 - t) + case EasingOutSine: + return float32(math.Sin(float64(t) * math.Pi / 2)) + case EasingInOutSine: + return float32(-0.5 * (math.Cos(math.Pi*float64(t)) - 1)) + default: + return t + } +} + +func (a *Animation) Start(start, end float32, duration time.Duration, easing Easing) { + a.start = start + a.end = end + a.current = start + a.startTime = time.Now() + a.duration = duration + a.Easing = easing + a.active = true +} + +func (a *Animation) Update(gtx layout.Context) float32 { + if !a.active { + return a.current + } + + elapsed := time.Since(a.startTime) + + if elapsed >= a.duration { + a.current = a.end + a.active = false + } else { + progress := float32(elapsed) / float32(a.duration) + eased := applyEasing(a.Easing, progress) + a.current = a.start + (a.end-a.start)*eased + } + + gtx.Execute(&op.InvalidateCmd{}) + + return a.current +}