From 77c7bc9b187450818ec068239a3ae836d01fdebc Mon Sep 17 00:00:00 2001 From: Calvin Morrison Date: Sat, 22 Aug 2026 19:12:01 -0400 Subject: gui: a widget layer, so the next program does not write one cal9 had grown its own: borders drawn four fills at a time in two places, a button-3 menu, hit regions, buttons, text wrapping. None of that is about calendars, and a second program would have written it again, differently. It lives in gui/ui now. It is a kit and not a framework. Every call draws a thing and, where it makes sense, records where it drew it; the program keeps its own event loop and its own idea of what to redraw. Menu takes a redraw function rather than calling back into anything. The moment the library owns the loop it stops being usable by the next program, which was the problem being fixed. Body is the fix for two bugs that turned out to be one. rio draws a window's border into the client's own image and puts corner cursors in it, taking those clicks before the client sees them: the border is rio's resize handle. Filling the whole rectangle erased it, so the border vanished and so did resizing. Drawing inside Body gives both back, and the drag-to-resize cal9 had grown -- which could only ever shrink, since a window stops getting mouse events once the pointer leaves it -- was deleted rather than fixed. Field is one line of editable text with a cursor, backspace, ^U and a paste from /dev/snarf. No selection, no mouse placement, no history: a form asking for a summary and a time needs none of them and each is somewhere to be subtly wrong. Co-Authored-By: Claude Opus 5 --- gui/draw/snarf.go | 10 +++ gui/ui/field.go | 88 +++++++++++++++++++++++ gui/ui/menu.go | 79 +++++++++++++++++++++ gui/ui/ui.go | 207 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 384 insertions(+) create mode 100644 gui/ui/field.go create mode 100644 gui/ui/menu.go create mode 100644 gui/ui/ui.go diff --git a/gui/draw/snarf.go b/gui/draw/snarf.go index ae26edd..3f0e35b 100644 --- a/gui/draw/snarf.go +++ b/gui/draw/snarf.go @@ -13,3 +13,13 @@ func Snarf(dev, text string) error { _, err = f.WriteString(text) return err } + +// Snarfed reads the system snarf buffer, which is what every other +// program on the system means by paste. +func Snarfed(dev string) (string, error) { + b, err := os.ReadFile(dev + "/snarf") + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/gui/ui/field.go b/gui/ui/field.go new file mode 100644 index 0000000..6872d32 --- /dev/null +++ b/gui/ui/field.go @@ -0,0 +1,88 @@ +package ui + +import "9front/gui/draw" + +// A Field is one line of editable text. +// +// Deliberately small: a cursor at the end, backspace, and ^U to clear. +// There is no selection, no mouse cursor placement and no history, +// because a form that asks for a summary and a time does not need them +// and every one of them is a place to get subtly wrong. A program that +// needs a real editor should hand the job to one. +type Field struct { + Label string + Value string + R draw.Rectangle // set by Draw, used for hit testing +} + +// Draw paints the field and records where it landed in f.R, so the +// caller can make it clickable. It does not register a hit region +// itself: the first matching region wins a click, and a do-nothing one +// here would silently swallow the caller's. +func (u *UI) Draw(f *Field, r draw.Rectangle, focused bool) { + f.R = r + lw := u.F.Width("attendees ") + 8 + u.Text(draw.Point{X: r.Min.X, Y: r.Min.Y + 2}, "ink", f.Label) + + box := draw.Rect(r.Min.X+lw, r.Min.Y, r.Max.X, r.Max.Y) + u.Fill(box, "bg") + c := "rule" + if focused { + c = "border" + } + u.Border(box, c) + + s := f.Value + if focused { + s += "|" + } + // keep the end of the line in view: that is where you are typing + for len(s) > 0 && u.F.Width(s) > box.Dx()-8 { + s = s[1:] + } + u.Text(draw.Point{X: box.Min.X + 4, Y: box.Min.Y + 2}, "ink", s) +} + +// oneLine keeps a paste to the first line: these are one-line fields, +// and a pasted paragraph would silently lose everything after the first +// newline anyway. +func oneLine(s string) string { + for i, r := range s { + if r == '\n' || r == '\r' { + return s[:i] + } + } + return s +} + +// Key applies one typed rune and says whether it changed anything. +func (f *Field) Key(r rune) bool { + switch r { + case '\b', 0x7F: // backspace, del + if f.Value == "" { + return false + } + v := []rune(f.Value) + f.Value = string(v[:len(v)-1]) + return true + case 0x15: // ^U + if f.Value == "" { + return false + } + f.Value = "" + return true + case 0x16, 0x19: // ^V, ^Y: paste + if v, err := draw.Snarfed("/dev"); err == nil { + f.Value += oneLine(v) + return true + } + return false + case '\n', '\r', '\t', 0x1B: + return false // the form deals with these + } + if r < ' ' { + return false + } + f.Value += string(r) + return true +} diff --git a/gui/ui/menu.go b/gui/ui/menu.go new file mode 100644 index 0000000..7fe7ff8 --- /dev/null +++ b/gui/ui/menu.go @@ -0,0 +1,79 @@ +package ui + +import "9front/gui/draw" + +// An Item is one line of a menu. +type Item struct { + Label string + Do func() +} + +// Menu pops up at p and tracks until button 3 comes back up, the way +// page(1) and vdir(1) do it: press 3, drag, release on an item. +// +// It keeps itself on the screen, which is the detail everyone forgets +// until the menu opens near the bottom edge and half of it is missing. +// redraw is called if the menu is dismissed without a choice. +func (u *UI) Menu(items []Item, at draw.Point, mc *draw.Mousectl, redraw func()) { + if len(items) == 0 { + return + } + + lh := int32(u.F.Height) + 4 + var w int32 + for _, it := range items { + if x := u.F.Width(it.Label) + 20; x > w { + w = x + } + } + h := lh*int32(len(items)) + 4 + win := u.Win.Rect() + r := draw.Rect(at.X, at.Y, at.X+w, at.Y+h) + if r.Max.X > win.Max.X { + r = r.Add(draw.Point{X: win.Max.X - r.Max.X}) + } + if r.Max.Y > win.Max.Y { + r = r.Add(draw.Point{Y: win.Max.Y - r.Max.Y}) + } + + sel := -1 + paint := func() { + u.Fill(r, "tag") + u.Border(r, "border") + for i, it := range items { + ir := draw.Rect(r.Min.X+1, r.Min.Y+2+int32(i)*lh, + r.Max.X-1, r.Min.Y+2+int32(i+1)*lh) + if i == sel { + u.Fill(ir, "today") + } + u.Text(draw.Point{X: ir.Min.X + 8, Y: ir.Min.Y + 2}, "ink", it.Label) + } + u.D.Flush() + } + itemAt := func(p draw.Point) int { + if p.X < r.Min.X || p.X >= r.Max.X { + return -1 + } + i := int((p.Y - r.Min.Y - 2) / lh) + if i < 0 || i >= len(items) || p.Y < r.Min.Y+2 { + return -1 + } + return i + } + paint() + + for m := range mc.C { + if n := itemAt(m.Point); n != sel { + sel = n + paint() + } + if m.Buttons&4 == 0 { + if sel >= 0 { + items[sel].Do() + } else { + redraw() + } + return + } + } +} diff --git a/gui/ui/ui.go b/gui/ui/ui.go new file mode 100644 index 0000000..5288828 --- /dev/null +++ b/gui/ui/ui.go @@ -0,0 +1,207 @@ +// Package ui is the bits every program drawing in a rio window needs +// and none of them should write twice: borders, buttons, a button-3 +// menu, hit regions, dragging the window about, selecting text. +// +// It is a kit, not a framework. Every function draws a thing and, where +// it makes sense, records where it was drawn so a click can find it. +// The program keeps its own event loop and its own idea of what to +// redraw; nothing here calls back into it except the handler you give. +package ui + +import ( + "os" + "fmt" + + "9front/gui/draw" +) + +// UI is what the drawing functions need: somewhere to draw, something +// to draw with, and a palette. A program embeds it. +type UI struct { + D *draw.Display + Win *draw.Image + F *draw.Font + Col map[string]*draw.Image + + hits []Hit +} + +// A Hit is a region that does something when clicked. +type Hit struct { + R draw.Rectangle + Do func() +} + +// Reset forgets every hit region. Call it at the top of a redraw, or +// clicks will find things that are no longer on the screen. +func (u *UI) Reset() { u.hits = u.hits[:0] } + +// On records a region. The first one that contains a point wins, so +// record the small things before the big ones that enclose them. +func (u *UI) On(r draw.Rectangle, do func()) { + u.hits = append(u.hits, Hit{r, do}) +} + +// Click runs the handler for the first region containing p, and says +// whether it found one. +func (u *UI) Click(p draw.Point) bool { + for _, h := range u.hits { + if In(h.R, p) { + h.Do() + return true + } + } + return false +} + +// In reports whether p is inside r. +func In(r draw.Rectangle, p draw.Point) bool { + return p.X >= r.Min.X && p.X < r.Max.X && p.Y >= r.Min.Y && p.Y < r.Max.Y +} + +// ---------------------------------------------------------------- paint + +// Border width rio leaves around a window. rio draws the border into +// the client's own image and colours it to show which window has the +// input, so a program that fills its whole rectangle erases it and the +// window loses its edge. Draw inside Body and those pixels stay rio's. +const Selborder = 4 + +// Body is the part of the window a program should draw in: everything +// but rio's border. +func (u *UI) Body() draw.Rectangle { + r := u.Win.Rect() + return draw.Rect(r.Min.X+Selborder, r.Min.Y+Selborder, + r.Max.X-Selborder, r.Max.Y-Selborder) +} + +// Fill paints a rectangle in a named colour. +func (u *UI) Fill(r draw.Rectangle, c string) { + if img, ok := u.Col[c]; ok { + draw.Draw(u.Win, r, img, nil, draw.ZP) + } +} + +// Text draws a string at p in a named colour. +func (u *UI) Text(p draw.Point, c string, s string) { + if img, ok := u.Col[c]; ok { + u.F.String(u.Win, p, img, s) + } +} + +// Border outlines a rectangle, one pixel wide. Everything that draws a +// box wants this, and everything used to write it out four times. +func (u *UI) Border(r draw.Rectangle, c string) { + u.Fill(draw.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+1), c) + u.Fill(draw.Rect(r.Min.X, r.Max.Y-1, r.Max.X, r.Max.Y), c) + u.Fill(draw.Rect(r.Min.X, r.Min.Y, r.Min.X+1, r.Max.Y), c) + u.Fill(draw.Rect(r.Max.X-1, r.Min.Y, r.Max.X, r.Max.Y), c) +} + +// Button draws a labelled box and records it. on picks the lit +// background, for a button that shows the current state. +func (u *UI) Button(r draw.Rectangle, label string, on bool, do func()) { + bg := "tag" + if on { + bg = "today" + } + u.Fill(r, bg) + u.Border(r, "border") + w := u.F.Width(label) + u.Text(draw.Point{X: r.Min.X + (r.Dx()-w)/2, Y: r.Min.Y + 3}, "ink", label) + u.On(r, do) +} + +// Fit truncates s to w pixels, so a long summary cannot run out of its +// box and over whatever is next to it. +func (u *UI) Fit(s string, w int32) string { + if u.F.Width(s) <= w { + return s + } + r := []rune(s) + for len(r) > 1 { + r = r[:len(r)-1] + if u.F.Width(string(r)+"...") <= w { + return string(r) + "..." + } + } + return "" +} + +// Wrap breaks text into lines no wider than w. +func (u *UI) Wrap(text string, w int32) []string { + var out []string + line := "" + for _, word := range fields(text) { + try := word + if line != "" { + try = line + " " + word + } + if u.F.Width(try) <= w || line == "" { + line = try + continue + } + out = append(out, line) + line = word + } + if line != "" { + out = append(out, line) + } + return out +} + +func fields(s string) []string { + var out []string + cur := "" + for _, r := range s { + if r == ' ' || r == '\t' || r == '\n' { + if cur != "" { + out = append(out, cur) + cur = "" + } + continue + } + cur += string(r) + } + if cur != "" { + out = append(out, cur) + } + return out +} + +// ---------------------------------------------------------------- rio + +// Wctl asks rio to do something to our window: resize, move, hide. +// Outside rio there is no /dev/wctl and this quietly does nothing, +// which is the right answer for a menu item that cannot apply. +func (u *UI) Wctl(cmd string) { + f, err := os.OpenFile("/dev/wctl", os.O_WRONLY, 0) + if err != nil { + return + } + defer f.Close() + fmt.Fprint(f, cmd) +} + +// Resize asks rio for a window of a given size, keeping the corner. +func (u *UI) Resize(dx, dy int32) { + r := u.Win.Rect() + u.Wctl(fmt.Sprintf("resize -r %d %d %d %d", + r.Min.X, r.Min.Y, r.Min.X+dx, r.Min.Y+dy)) +} + + +// Hide puts the window away. +func (u *UI) Hide() { u.Wctl("hide") } + +// New starts a command in a window of its own. +// +// rio splits this line on spaces and there is no quoting, so every +// argument has to be a single token. +func (u *UI) New(dx, dy int32, argv ...string) { + cmd := fmt.Sprintf("new -dx %d -dy %d", dx, dy) + for _, a := range argv { + cmd += " " + a + } + u.Wctl(cmd) +} -- cgit v1.2.3