From e7218b1efd6aaa91d0b00e963850548a987514d0 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Fri, 21 Aug 2026 13:00:25 -0400 Subject: [PATCH] Complete the Gio v0.10.2 GUI migration (working on Android). - Event loop: call w.Event() and FrameEvent.Frame() on the same goroutine; no channel bridge (the bridge broke frame rendering). - clip: use clip.UniformRRect(...).Push(gtx.Ops) / Pop() for real clipping in layoutRRect (old .Add(ops) painted unclipped). - Window clear color is white in v0.10.2. - Fonts: always provide gofont; on Android use WithCollection(gofont.Collection()). - Button labels: explicitly set Color (zero-value NRGBA has A=0, i.e. transparent, so labels were invisible). - Filter: detect changes by comparing the editor text, since material.Editor.Layout consumes the editor's input events internally and a later FilterEd.Update(gtx) never sees them. - idPage: persist the manually entered ID with store.SetID (on Android nativeIdentities is unimplemented, so there are no id buttons to click). - Use U+2026 (gofont) instead of U+22EE (not in gofont) for the menu button. - Split getConfDir/noidLabelText out into impl_linux.go for the Linux build. --- .gitignore | 2 + cmd/passgo-gui/impl_android.go | 14 +- cmd/passgo-gui/impl_darwin.go | 24 ++-- cmd/passgo-gui/impl_linux.go | 29 ++++ cmd/passgo-gui/impl_others.go | 2 +- cmd/passgo-gui/main.go | 254 +++++++++++++++++---------------- cmd/passgo-gui/ui.go | 66 ++++----- 7 files changed, 216 insertions(+), 175 deletions(-) create mode 100644 cmd/passgo-gui/impl_linux.go diff --git a/.gitignore b/.gitignore index 2925ed9..b0fd805 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ cmd/passgo/passgo cmd/passgo-gui/passgo-gui nohup.out *.apk +*.idsig +/passgo-gui diff --git a/cmd/passgo-gui/impl_android.go b/cmd/passgo-gui/impl_android.go index 43f7ac3..e1b296a 100644 --- a/cmd/passgo-gui/impl_android.go +++ b/cmd/passgo-gui/impl_android.go @@ -1,13 +1,13 @@ -//+build android +//go:build android package main import ( "os" - "git.wow.st/gmp/passgo" "gioui.org/app" "gioui.org/io/event" + "git.wow.st/gmp/passgo" ) var ( @@ -22,8 +22,14 @@ func init() { func handleEvent(e event.Event) { switch e := e.(type) { - case app.ViewEvent: - initPgp(e.View) + case app.AndroidViewEvent: + // A zero View means the view is detached (e.g. the activity going + // away on a recents-wipe); there is nothing to register for a + // detached view. A re-attach arrives as a fresh event with a live + // view. + if e.View != 0 { + initPgp(e.View) + } } } diff --git a/cmd/passgo-gui/impl_darwin.go b/cmd/passgo-gui/impl_darwin.go index a3770b1..362f9e6 100644 --- a/cmd/passgo-gui/impl_darwin.go +++ b/cmd/passgo-gui/impl_darwin.go @@ -1,14 +1,14 @@ -//+build darwin +//go:build darwin package main import ( + "io" "os" "os/user" "path" "gioui.org/font/opentype" - "gioui.org/text" ) var ( @@ -22,22 +22,18 @@ func setFont() error { return err } - fnts, err := opentype.ParseCollectionReaderAt(f) + defer f.Close() + data, err := io.ReadAll(f) + if err != nil { + log(Info, "Cannot read system font collection") + return err + } + fnts, err := opentype.ParseCollection(data) if err != nil { log(Info, "Cannot parse font collection") return err } - fnt, err := fnts.Font(0) - if err != nil { - log(Info, "Cannot get font from collection") - return err - } - collection = append(collection, text.FontFace{Font: text.Font{}, Face: fnt}) - if err != nil { - log(Info, "Cannot access font from font collection") - return err - } - //font.Register(text.Font{}, face) + collection = append(collection, fnts...) return nil } diff --git a/cmd/passgo-gui/impl_linux.go b/cmd/passgo-gui/impl_linux.go new file mode 100644 index 0000000..72e1ea3 --- /dev/null +++ b/cmd/passgo-gui/impl_linux.go @@ -0,0 +1,29 @@ +//go:build linux && !android + +package main + +import ( + "os" + + "gioui.org/app" +) + +var ( + noidLabelText = "No GPG ids available" +) + +func getConfDir() (string, error) { + ret, err := app.DataDir() + if err != nil { + log(Error, "Cannot get data directory:", err) + return "", err + } + if _, err := os.Stat(ret); os.IsNotExist(err) { + err = os.MkdirAll(ret, 0700) + if err != nil { + log(Error, "Cannot create configuration directory ", ret) + return "", err + } + } + return ret, nil +} diff --git a/cmd/passgo-gui/impl_others.go b/cmd/passgo-gui/impl_others.go index 523768b..59481f4 100644 --- a/cmd/passgo-gui/impl_others.go +++ b/cmd/passgo-gui/impl_others.go @@ -1,4 +1,4 @@ -//+build !android +//go:build !android package main diff --git a/cmd/passgo-gui/main.go b/cmd/passgo-gui/main.go index 45af325..f083b29 100644 --- a/cmd/passgo-gui/main.go +++ b/cmd/passgo-gui/main.go @@ -1,5 +1,3 @@ -// +build darwin linux - package main import ( @@ -16,8 +14,6 @@ import ( "gioui.org/app" "gioui.org/font/gofont" - "gioui.org/io/key" - "gioui.org/io/system" "gioui.org/layout" "gioui.org/op" "gioui.org/text" @@ -43,24 +39,26 @@ type conf struct { } func main() { - if false { go func() { - f, err := os.Create("cpuprofile") - if err != nil { - fmt.Printf("Can't create CPU profile\n") - os.Exit(-1) - } - fmt.Printf("Starting CPU profile\n") - if err := pprof.StartCPUProfile(f); err != nil { - fmt.Printf("Can't start CPU profile\n") + if false { + go func() { + f, err := os.Create("cpuprofile") + if err != nil { + fmt.Printf("Can't create CPU profile\n") + os.Exit(-1) + } + fmt.Printf("Starting CPU profile\n") + if err := pprof.StartCPUProfile(f); err != nil { + fmt.Printf("Can't start CPU profile\n") + f.Close() + os.Exit(-1) + } + time.Sleep(time.Second * 10) + fmt.Printf("Stopping CPU profile\n") + pprof.StopCPUProfile() f.Close() - os.Exit(-1) - } - time.Sleep(time.Second * 10) - fmt.Printf("Stopping CPU profile\n") - pprof.StopCPUProfile() - f.Close() - fmt.Printf("CPU profile written\n") - }() } + fmt.Printf("CPU profile written\n") + }() + } var fd *os.File var err error confDir, err = getConfDir() @@ -106,7 +104,7 @@ func main() { passch = make(chan []byte) go func() { - log(Info,"passgo.Identities()") + log(Info, "passgo.Identities()") passgo.Identities() }() go Updater() @@ -117,18 +115,18 @@ func main() { } var ( - fontSize float32 + fontSize float32 collection []text.FontFace - confDir string - Config conf - l []passgo.Pass - mux sync.Mutex - store passgo.Store - reload chan struct{} - updated chan struct{} - chdir chan struct{} - passch chan []byte - th *material.Theme + confDir string + Config conf + l []passgo.Pass + mux sync.Mutex + store passgo.Store + reload chan struct{} + updated chan struct{} + chdir chan struct{} + passch chan []byte + th *material.Theme ) func Updater() { @@ -196,14 +194,22 @@ func saveConf(fds ...*os.File) { } func eventLoop() { - if collection == nil { - collection = gofont.Collection() - } - var ops op.Ops - th = material.NewTheme(collection) + th = material.NewTheme() th.TextSize = unit.Sp(fontSize) - w := app.NewWindow( + if len(collection) > 0 { + // Use the embedded Go font plus the platform fonts (e.g. the + // Unicode font installed on Darwin) instead of the system fonts. + th.Shaper = text.NewShaper(text.NoSystemFonts(), + text.WithCollection(append(gofont.Collection(), collection...))) + } else { + // Always provide the embedded Go font: the system font scan + // finds no fonts on Android, and a fontless shaper renders a + // blank screen. + th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection())) + } + w := new(app.Window) + w.Option( app.Size(unit.Dp(250), unit.Dp(500)), app.Title("passgo")) @@ -211,13 +217,12 @@ func eventLoop() { var c1 layout.FlexChild // flex child for title bar - sysinset := &layout.Inset{} margin := layout.UniformInset(unit.Dp(10)) title := material.Body1(th, "passgo") dotsBtn := &Button{ Size: unit.Sp(fontSize), - Label: "\xe2\x8b\xae", + Label: "\u2026", Alignment: text.Middle, Color: black, Background: gray, @@ -243,6 +248,7 @@ func eventLoop() { var overlayStart time.Time FilterEd := &widget.Editor{SingleLine: true} + var lastFilter string updateBtns := func() { passBtns = passBtns[:0] pathnames = pathnames[:0] @@ -256,10 +262,10 @@ func eventLoop() { d, n := path.Split(x.Pathname) x = passgo.Pass{ Pathname: d, - Level: x.Level - 1, - Dir: true, + Level: x.Level - 1, + Dir: true, } - fmt.Printf("addd(): d/n = '%s'/'%s'\n", d,n) + fmt.Printf("addd(): d/n = '%s'/'%s'\n", d, n) if dirs[d] != true { addd(x) } @@ -267,8 +273,9 @@ func eventLoop() { s := strings.Repeat(" /", x.Level) lbl := strings.Join([]string{s, d}, "") passBtns = append(passBtns, &Button{ - Size: unit.Sp(fontSize), - Label: lbl, + Size: unit.Sp(fontSize), + Label: lbl, + Color: black, Background: gray, }) pathnames = append(pathnames, x.Pathname) @@ -288,6 +295,7 @@ func eventLoop() { passBtns = append(passBtns, &Button{ Size: unit.Sp(fontSize), Label: lbl, + Color: black, Background: gray, }) pathnames = append(pathnames, x.Pathname) @@ -304,7 +312,7 @@ func eventLoop() { idBtns := make([]*Button, 0) updateIdBtns := func() { - log(Info,"passgo.Identities()") + log(Info, "passgo.Identities()") ids, err := passgo.Identities() if err != nil { log(Info, err) @@ -335,7 +343,7 @@ func eventLoop() { } storeDirLabel := material.Label(th, unit.Sp(fontSize), "Store directory") - storeDirEd := &widget.Editor{ SingleLine: true} + storeDirEd := &widget.Editor{SingleLine: true} storeDirEd.SetText(store.Dir) saveBtn := &Button{ Size: unit.Sp(fontSize), @@ -361,7 +369,7 @@ func eventLoop() { } promptLabel := material.Label(th, unit.Sp(fontSize), "passphrase") - promptEd := &widget.Editor{ SingleLine: true, Submit: true } + promptEd := &widget.Editor{SingleLine: true, Submit: true} okBtn := &Button{ Size: unit.Sp(fontSize), Label: "ok", @@ -386,9 +394,9 @@ func eventLoop() { insertLabel := material.Label(th, unit.Sp(fontSize), "Insert") passnameLabel := material.Label(th, unit.Sp(fontSize), "password name:") - passnameEd := &widget.Editor{ SingleLine: true } + passnameEd := &widget.Editor{SingleLine: true} passvalLabel := material.Label(th, unit.Sp(fontSize), "password value:") - passvalEd := &widget.Editor{ SingleLine: true, Submit: true } + passvalEd := &widget.Editor{SingleLine: true, Submit: true} noidLabel := material.Label(th, unit.Sp(fontSize), noidLabelText) idLabel := material.Label(th, unit.Sp(fontSize), "Select ID") @@ -433,6 +441,7 @@ func eventLoop() { }) if xBtn.Clicked() { FilterEd.SetText("") + lastFilter = "" updateBtns() } return lpf1.Layout(gtx, c21, c22) @@ -440,9 +449,6 @@ func eventLoop() { c3 := layout.Flexed(1.0, func(gtx C) D { var ret D mux.Lock() - if lst.Dragging() { - key.HideInputOp{}.Add(gtx.Ops) - } switch { case store.Empty: confBtn.Layout(gtx) @@ -543,7 +549,11 @@ func eventLoop() { if animating && x > end { animOff() } - if len(FilterEd.Events()) > 0 { + // material.Editor.Layout (above) already consumes the editor's + // input events, so we detect a filter change by comparing the + // current text against the previous frame's. + if t := FilterEd.Text(); t != lastFilter { + lastFilter = t updateBtns() } return ret @@ -588,19 +598,14 @@ func eventLoop() { c4 = layout.Rigid(func(gtx C) D { return material.Editor(th, idEd, "id").Layout(gtx) }) - for _, e := range idEd.Events() { - switch e.(type) { - case widget.SubmitEvent: - log(Info, "Submit") - store.Id = idEd.Text() - page = listPage - } - } c5 = layout.Rigid(func(gtx C) D { return idSubmitBtn.Layout(gtx) }) if idSubmitBtn.Clicked() { store.Id = idEd.Text() + // Persist the ID so it survives a restart (the manual entry + // path has no keyring to re-derive it from). + store.SetID(store.Id) page = listPage } c6 = layout.Rigid(func(gtx C) D { @@ -632,6 +637,15 @@ func eventLoop() { } return ret }) + if ev, ok := idEd.Update(gtx); ok { + if _, isSubmit := ev.(widget.SubmitEvent); isSubmit { + log(Info, "Submit") + store.Id = idEd.Text() + store.SetID(store.Id) + w.Invalidate() + page = listPage + } + } return flex.Layout(gtx, c1, c2, c3, c4, c5, c6, c7) } @@ -644,7 +658,7 @@ func eventLoop() { numBtn.Button = Button{Size: unit.Sp(fontSize), Label: "#"} symBtn.Select() numBtn.Select() - lenEd := &widget.Editor{ SingleLine: true, Alignment: text.End } + lenEd := &widget.Editor{SingleLine: true, Alignment: text.End} lenEd.SetText("15") lBtn := &Button{Size: unit.Sp(fontSize), Label: "<", Background: gray} rBtn := &Button{Size: unit.Sp(fontSize), Label: ">", Background: gray} @@ -835,9 +849,8 @@ func eventLoop() { promptPage = func(gtx C) D { submit := false - for _, e := range promptEd.Events() { - switch e.(type) { - case widget.SubmitEvent: + if ev, ok := promptEd.Update(gtx); ok { + if _, isSubmit := ev.(widget.SubmitEvent); isSubmit { log(Info, "Submit") submit = true } @@ -882,78 +895,73 @@ func eventLoop() { ms := &runtime.MemStats{} x := 0 var mallocs uint64 + var idsInit bool for { + // Window.Event and FrameEvent.Frame must be called from the same + // goroutine; drain the auxiliary channels non-blocking instead of + // bridging the window events through a channel. select { case <-updated: log(Info, "UPDATE") updateBtns() w.Invalidate() + default: + } + select { case <-anim.C: w.Invalidate() - case e := <-w.Events(): + default: + } + switch e := w.Event().(type) { + case app.DestroyEvent: + os.Exit(0) + case app.FrameEvent: x++ - if x == 100 { - runtime.ReadMemStats(ms) - mallocs = ms.Mallocs + if !idsInit { // the app is running; refresh the ID list + idsInit = true + go updateIdBtns() } - switch e := e.(type) { - case system.DestroyEvent: - os.Exit(0) - case system.StageEvent: - if e.Stage == system.StageRunning { - go func() { - updateIdBtns() - }() - } - case system.FrameEvent: - gtx := layout.NewContext(&ops, e) + // NewContext resets ops and adjusts for e.Insets. + gtx := app.NewContext(&ops, e) - sysinset.Top = e.Insets.Top - sysinset.Bottom = e.Insets.Bottom - sysinset.Left = e.Insets.Left - sysinset.Right = e.Insets.Right - - sysinset.Layout(gtx, func(gtx C) D { - return margin.Layout(gtx, func(gtx C) D { - margincs = gtx.Constraints - c1 = layout.Rigid(func(gtx C) D { - ct2 := layout.Rigid(func(gtx C) D { - return plusBtn.Layout(gtx) - }) - ct3 := layout.Rigid(func(gtx C) D { - return dotsBtn.Layout(gtx) - }) - ct1 := layout.Flexed(1.0, func(gtx C) D { - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return title.Layout(gtx) - }) - return titleflex.Layout(gtx, ct1, ct2, ct3) - }) - - return page(gtx) + margin.Layout(gtx, func(gtx C) D { + margincs = gtx.Constraints + c1 = layout.Rigid(func(gtx C) D { + ct2 := layout.Rigid(func(gtx C) D { + return plusBtn.Layout(gtx) }) + ct3 := layout.Rigid(func(gtx C) D { + return dotsBtn.Layout(gtx) + }) + ct1 := layout.Flexed(1.0, func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return title.Layout(gtx) + }) + return titleflex.Layout(gtx, ct1, ct2, ct3) }) - if dotsBtn.Clicked() { - log(Info, "Configure") - w.Invalidate() - page = confPage - } - if plusBtn.Clicked() { - log(Info, "Plus") - w.Invalidate() - insName, insValue = "", "" - page = insertPage - } - e.Frame(gtx.Ops) - default: - handleEvent(e) + return page(gtx) + }) + + if dotsBtn.Clicked() { + log(Info, "Configure") + w.Invalidate() + page = confPage } - if x == 100 { - x = 0 - runtime.ReadMemStats(ms) - fmt.Printf("mallocs: %d\n", ms.Mallocs-mallocs) + if plusBtn.Clicked() { + log(Info, "Plus") + w.Invalidate() + insName, insValue = "", "" + page = insertPage } + e.Frame(&ops) + default: + handleEvent(e) + } + if x == 100 { + x = 0 + runtime.ReadMemStats(ms) + fmt.Printf("mallocs: %d\n", ms.Mallocs-mallocs) } } } diff --git a/cmd/passgo-gui/ui.go b/cmd/passgo-gui/ui.go index 2f0f60d..7812c4a 100644 --- a/cmd/passgo-gui/ui.go +++ b/cmd/passgo-gui/ui.go @@ -4,9 +4,8 @@ import ( "image" "image/color" - "gioui.org/f32" "gioui.org/gesture" - "gioui.org/io/pointer" + "gioui.org/io/event" "gioui.org/layout" "gioui.org/op/clip" "gioui.org/op/paint" @@ -16,18 +15,18 @@ import ( ) var ( - black = color.RGBA{A: 0xff, R: 0, G: 0, B: 0} - white = color.RGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff} - gray = color.RGBA{A: 0xff, R: 0xf0, G: 0xf0, B: 0xf0} - darkgray = color.RGBA{A: 0xff, R: 0xa0, G: 0xa0, B: 0xa0} + black = color.NRGBA{A: 0xff, R: 0, G: 0, B: 0} + white = color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff} + gray = color.NRGBA{A: 0xff, R: 0xf0, G: 0xf0, B: 0xf0} + darkgray = color.NRGBA{A: 0xff, R: 0xa0, G: 0xa0, B: 0xa0} ) type Overlay struct { - Size unit.Value + Size unit.Sp Text string Click gesture.Click - Color color.RGBA - Background color.RGBA + Color color.NRGBA + Background color.NRGBA Alignment text.Alignment } @@ -39,11 +38,9 @@ func (b *Overlay) Layout(gtx C) D { l := material.Label(th, b.Size, b.Text) ins := layout.UniformInset(unit.Dp(4)) l.Color = b.Color - ret := ins.Layout(gtx, func(gtx C) D { + return ins.Layout(gtx, func(gtx C) D { return l.Layout(gtx) }) - pointer.Rect(image.Rect(0, 0, ret.Size.X, ret.Size.Y)).Add(gtx.Ops) - return ret }) c1 := layout.Expanded(func(gtx C) D { return layoutRRect(b.Background, gtx) @@ -54,55 +51,58 @@ func (b *Overlay) Layout(gtx C) D { type SelButton struct { Button - SelColor color.RGBA + SelColor color.NRGBA Selected bool } type Button struct { - Size unit.Value + Size unit.Sp Label string Click gesture.Click - Color color.RGBA - Background color.RGBA + Color color.NRGBA + Background color.NRGBA Alignment text.Alignment clicked bool } -func layoutRRect(col color.RGBA, gtx C) D { - r := float32(gtx.Px(unit.Dp(4))) +func layoutRRect(col color.NRGBA, gtx C) D { + r := gtx.Dp(4) sz := image.Point{X: gtx.Constraints.Min.X, Y: gtx.Constraints.Min.Y} - w, h := float32(sz.X), float32(sz.Y) - rect := f32.Rectangle{ - f32.Point{0, 0}, - f32.Point{w, h}, - } - //clip.RoundRect(gtx.Ops, rect, r, r, r, r) - clip.RRect{Rect: rect, NE: r, NW: r, SE: r, SW: r}.Add(gtx.Ops) + // Push the rounded-rect clip, paint, then restore the previous clip. + stk := clip.UniformRRect(image.Rectangle{Max: sz}, r).Push(gtx.Ops) paint.ColorOp{Color: col}.Add(gtx.Ops) - paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{X: w, Y: h}}}.Add(gtx.Ops) + paint.PaintOp{}.Add(gtx.Ops) + stk.Pop() return layout.Dimensions{Size: sz} } func (b *Button) Layout(gtx C) D { mwidth := gtx.Constraints.Min.X b.clicked = false - for _, ev := range b.Click.Events(gtx) { - if ev.Type == gesture.TypeClick { - b.clicked = true - } - } ins := layout.UniformInset(unit.Dp(1)) return ins.Layout(gtx, func(gtx C) D { st := layout.Stack{} c2 := layout.Stacked(func(gtx C) D { l := material.Label(th, b.Size, b.Label) + l.Color = b.Color ins := layout.UniformInset(unit.Dp(4)) - //paint.ColorOp{Color: b.Color}.Add(ops) ret := ins.Layout(gtx, func(gtx C) D { return l.Layout(gtx) }) - pointer.Rect(image.Rect(0, 0, ret.Size.X, ret.Size.Y)).Add(gtx.Ops) + // Register the click handler for the label area. + stk := clip.Rect(image.Rectangle{Max: ret.Size}).Push(gtx.Ops) + event.Op(gtx.Ops, b) b.Click.Add(gtx.Ops) + stk.Pop() + for { + ev, ok := b.Click.Update(gtx.Source) + if !ok { + break + } + if ev.Kind == gesture.KindClick { + b.clicked = true + } + } return ret }) c1 := layout.Expanded(func(gtx C) D {