summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCalvin Morrison <calvin@pobox.com>2026-08-22 13:16:35 -0400
committerCalvin Morrison <calvin@pobox.com>2026-08-22 13:16:35 -0400
commit9aacce8b3b54060d0037eca897856d7273e6e5e8 (patch)
treea98ea0cb151a7ded56fe681d6782986b2807dfcf
parent5ef4699d05bc919255449a9af780f216a0589a72 (diff)
gui: a /dev/draw layer in go, with no cgo and no devdraw
9fans.net/go/draw builds for plan9 but shells out to plan9port's devdraw, which 9front does not have. This talks to /dev/draw itself: the protocol is file i/o, so a pure-go client is a few hundred lines under an already-complete idea. draw/keyboard.go opens /dev/cons and turns the console raw, as libdraw's initkeyboard does. Without it rio keeps its line editor on the window and paints what you type over the drawing. draw/snarf.go writes /dev/snarf, which is what every other program on the system means by copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--gui/.gitignore3
-rw-r--r--gui/cmd/hello/main.go5
-rw-r--r--gui/cmd/spike/main.go106
-rw-r--r--gui/cmd/text/main.go66
-rw-r--r--gui/draw/draw.go250
-rw-r--r--gui/draw/font.go336
-rw-r--r--gui/draw/keyboard.go65
-rw-r--r--gui/draw/mouse.go74
-rw-r--r--gui/draw/snarf.go15
-rw-r--r--gui/draw/window.go86
-rw-r--r--gui/go.mod3
-rw-r--r--gui/probe/go.mod3
-rw-r--r--gui/probe/main.go5
13 files changed, 1017 insertions, 0 deletions
diff --git a/gui/.gitignore b/gui/.gitignore
new file mode 100644
index 0000000..b10d965
--- /dev/null
+++ b/gui/.gitignore
@@ -0,0 +1,3 @@
+# cross-compiled guest binaries
+*.9
+probe/hello-go1.*
diff --git a/gui/cmd/hello/main.go b/gui/cmd/hello/main.go
new file mode 100644
index 0000000..26b925b
--- /dev/null
+++ b/gui/cmd/hello/main.go
@@ -0,0 +1,5 @@
+package main
+
+import "fmt"
+
+func main() { fmt.Println("hello from go on plan9") }
diff --git a/gui/cmd/spike/main.go b/gui/cmd/spike/main.go
new file mode 100644
index 0000000..442df52
--- /dev/null
+++ b/gui/cmd/spike/main.go
@@ -0,0 +1,106 @@
+// Spike: prove that a Plan 9 GUI can be written in Go with nothing but the
+// standard library -- no C, no cgo, no libdraw, no window system.
+//
+// Draws a panel layout and a cursor block that tracks the mouse.
+// Button 3 quits; so does the deadline, so a forgotten instance on a VM
+// does not sit on /dev/draw forever.
+package main
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "9front/gui/draw"
+)
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintf(os.Stderr, "spike: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ d, err := draw.Init("/dev")
+ if err != nil {
+ return err
+ }
+ defer d.Close()
+ if d.Screen == nil {
+ return fmt.Errorf("draw device gave no screen image")
+ }
+ scr := d.Screen
+ r := scr.Rect()
+ fmt.Fprintf(os.Stderr, "spike: screen %dx%d\n", r.Dx(), r.Dy())
+
+ col := map[string]uint32{}
+ img := map[string]*draw.Image{}
+ for name, v := range map[string]uint32{
+ "bg": 0x2B3A42FF,
+ "panel": 0x3F5765FF,
+ "accent": 0xBDD4DEFF,
+ "hot": 0xEFA00BFF,
+ } {
+ col[name] = v
+ if img[name], err = d.Color(v); err != nil {
+ return err
+ }
+ }
+
+ // Static layout: a header bar and three panels, laid out by arithmetic
+ // rather than by hand-placed constants. This is the seam where a real
+ // layout pass would go.
+ repaint := func() {
+ draw.Draw(scr, r, img["bg"], nil, draw.ZP)
+ hdr := draw.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+40)
+ draw.Draw(scr, hdr, img["accent"], nil, draw.ZP)
+
+ const gap = 12
+ top := r.Min.Y + 40 + gap
+ w := (r.Dx() - 4*gap) / 3
+ for i := int32(0); i < 3; i++ {
+ x := r.Min.X + gap + i*(w+gap)
+ p := draw.Rect(x, top, x+w, r.Max.Y-gap)
+ draw.Draw(scr, p, img["panel"], nil, draw.ZP)
+ }
+ }
+ repaint()
+ d.Flush()
+
+ mc, err := draw.OpenMouse("/dev")
+ if err != nil {
+ return err
+ }
+ defer mc.Close()
+
+ deadline := time.After(10 * time.Minute)
+ cursor := draw.Rect(0, 0, 28, 28)
+ var last draw.Rectangle
+ for {
+ select {
+ case m, ok := <-mc.C:
+ if !ok {
+ return nil
+ }
+ if m.Buttons&4 != 0 {
+ return nil
+ }
+ // Damage repair without a full repaint: erase where the
+ // block was, then draw it where the mouse is now.
+ if last != (draw.Rectangle{}) {
+ repaint()
+ }
+ now := cursor.Add(draw.Point{X: m.X - 14, Y: m.Y - 14})
+ draw.Draw(scr, now, img["hot"], nil, draw.ZP)
+ d.Flush()
+ last = now
+ case <-mc.Resize:
+ r = scr.Rect()
+ repaint()
+ d.Flush()
+ case <-deadline:
+ return nil
+ }
+ }
+}
diff --git a/gui/cmd/text/main.go b/gui/cmd/text/main.go
new file mode 100644
index 0000000..f1e2a9f
--- /dev/null
+++ b/gui/cmd/text/main.go
@@ -0,0 +1,66 @@
+// text: prove the font layer draws glyphs on the raw screen.
+package main
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "9front/gui/draw"
+)
+
+const fontpath = "/lib/font/bit/lucidasans/unicode.8.font"
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintf(os.Stderr, "text: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ d, err := draw.Init("/dev")
+ if err != nil {
+ return err
+ }
+ defer d.Close()
+ scr := d.Screen
+ r := scr.Rect()
+
+ f, err := d.OpenFont(fontpath)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(os.Stderr, "text: font h=%d ascent=%d\n", f.Height, f.Ascent)
+
+ // acme's palette: a 25% mix over white, per allocimagemix.
+ bg, err := d.Color(0xFFFFEAFF)
+ if err != nil {
+ return err
+ }
+ ink, err := d.Color(0x000000FF)
+ if err != nil {
+ return err
+ }
+ rule, err := d.Color(0x99994CFF)
+ if err != nil {
+ return err
+ }
+
+ draw.Draw(scr, r, bg, nil, draw.ZP)
+ y := r.Min.Y + 20
+ for _, s := range []string{
+ "Go on Plan 9: glyphs from /lib/font/bit, no libdraw.",
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz",
+ "0123456789 !@#$%^&*()-=[]{};'\\:\"|,./<>?",
+ "width(\"hello\") = " + fmt.Sprint(f.Width("hello")) + " px",
+ time.Now().Format("Mon 2 Jan 2006 15:04:05"),
+ } {
+ f.String(scr, draw.Point{X: r.Min.X + 20, Y: y}, ink, s)
+ y += int32(f.Height) + 6
+ }
+ draw.Draw(scr, draw.Rect(r.Min.X+20, y+8, r.Min.X+520, y+9), rule, nil, draw.ZP)
+ d.Flush()
+ time.Sleep(90 * time.Second)
+ return nil
+}
diff --git a/gui/draw/draw.go b/gui/draw/draw.go
new file mode 100644
index 0000000..0aa7911
--- /dev/null
+++ b/gui/draw/draw.go
@@ -0,0 +1,250 @@
+// Package draw speaks the Plan 9 /dev/draw protocol directly.
+//
+// There is no C, no cgo and no libdraw here: /dev/draw is a file, its
+// protocol is a handful of little-endian messages, and this file is the
+// whole of what libdraw's init.c, alloc.c and draw.c actually do.
+// See draw(3) and /sys/src/libdraw for the authority.
+package draw
+
+import (
+ "encoding/binary"
+ "fmt"
+ "os"
+ "strconv"
+ "sync"
+)
+
+// Channel descriptors, from /sys/include/draw.h.
+const (
+ chGrey1 = 0x31
+ chRGB24 = 0x081828
+ chRGBA32 = 0x08182848
+)
+
+type Point struct{ X, Y int32 }
+
+type Rectangle struct{ Min, Max Point }
+
+func Rect(x0, y0, x1, y1 int32) Rectangle {
+ return Rectangle{Point{x0, y0}, Point{x1, y1}}
+}
+
+func (r Rectangle) Dx() int32 { return r.Max.X - r.Min.X }
+func (r Rectangle) Dy() int32 { return r.Max.Y - r.Min.Y }
+
+// Add offsets a rectangle by p.
+func (r Rectangle) Add(p Point) Rectangle {
+ return Rectangle{
+ Point{r.Min.X + p.X, r.Min.Y + p.Y},
+ Point{r.Max.X + p.X, r.Max.Y + p.Y},
+ }
+}
+
+var ZP Point
+
+// Colors are RGBA, as draw(2) writes them.
+const (
+ White = 0xFFFFFFFF
+ Black = 0x000000FF
+ Red = 0xFF0000FF
+ Green = 0x00FF00FF
+ Blue = 0x0000FFFF
+)
+
+type Image struct {
+ d *Display
+ id uint32
+ R Rectangle
+ Clipr Rectangle
+ chn uint32
+ repl bool
+}
+
+func (i *Image) Rect() Rectangle { return i.R }
+
+type Display struct {
+ ctl, data *os.File
+ dirno int
+
+ // Screen is the display's own image, id 0. Without a window system
+ // that is the physical screen; under rio it is the whole screen and
+ // the window comes from /dev/winname instead.
+ Screen *Image
+
+ // opaque is libdraw's display->opaque: a replicated all-ones GREY1
+ // pixel. Draw with a nil mask means "mask with this", not "mask with
+ // image 0" -- image 0 is the screen.
+ opaque *Image
+
+ mu sync.Mutex
+ nextid uint32
+}
+
+// Init attaches to the draw device under dev (normally "/dev").
+func Init(dev string) (*Display, error) {
+ if dev == "" {
+ dev = "/dev"
+ }
+ ctl, err := os.OpenFile(dev+"/draw/new", os.O_RDWR, 0)
+ if err != nil {
+ return nil, fmt.Errorf("open draw/new: %w", err)
+ }
+ // 12 fields of 12 bytes: id, imageid, chan, repl, r[4], clipr[4].
+ var info [12 * 12]byte
+ n, err := ctl.Read(info[:])
+ if err != nil || n < 12 {
+ ctl.Close()
+ return nil, fmt.Errorf("read draw/new: short read %d: %w", n, err)
+ }
+ fld := func(i int) int32 {
+ s := string(info[i*12 : i*12+12])
+ v, _ := strconv.Atoi(trim(s))
+ return int32(v)
+ }
+ d := &Display{ctl: ctl, dirno: int(fld(0))}
+ data, err := os.OpenFile(fmt.Sprintf("%s/draw/%d/data", dev, d.dirno), os.O_RDWR, 0)
+ if err != nil {
+ ctl.Close()
+ return nil, fmt.Errorf("open draw/%d/data: %w", d.dirno, err)
+ }
+ d.data = data
+ if n >= len(info) {
+ d.Screen = &Image{
+ d: d,
+ id: 0,
+ chn: strToChan(trim(string(info[2*12 : 3*12]))),
+ repl: fld(3) != 0,
+ R: Rect(fld(4), fld(5), fld(6), fld(7)),
+ Clipr: Rect(fld(8), fld(9), fld(10), fld(11)),
+ }
+ }
+ if d.opaque, err = d.Alloc(Rect(0, 0, 1, 1), chGrey1, true, White); err != nil {
+ d.Close()
+ return nil, err
+ }
+ return d, nil
+}
+
+func (d *Display) Close() error {
+ d.data.Close()
+ return d.ctl.Close()
+}
+
+func trim(s string) string {
+ i, j := 0, len(s)
+ for i < j && s[i] == ' ' {
+ i++
+ }
+ for j > i && (s[j-1] == ' ' || s[j-1] == '\n') {
+ j--
+ }
+ return s[i:j]
+}
+
+// strToChan parses "r8g8b8" and friends. Only what the screen actually
+// reports is needed, so unknown names fall back to RGB24.
+func strToChan(s string) uint32 {
+ typ := map[byte]uint32{'r': 0, 'g': 1, 'b': 2, 'k': 3, 'a': 4, 'm': 5, 'x': 6}
+ var c uint32
+ for i := 0; i+1 < len(s); i += 2 {
+ t, ok := typ[s[i]]
+ if !ok {
+ return chRGB24
+ }
+ nb := uint32(s[i+1] - '0')
+ c = c<<8 | (t&15)<<4 | nb&15
+ }
+ if c == 0 {
+ return chRGB24
+ }
+ return c
+}
+
+func put32(b []byte, off int, v uint32) {
+ binary.LittleEndian.PutUint32(b[off:], v)
+}
+
+func (d *Display) write(msg []byte) error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ _, err := d.data.Write(msg)
+ return err
+}
+
+// Alloc creates a new image on the server. A 1x1 replicated image is how
+// you make a solid colour: it tiles to fill whatever you draw it into.
+func (d *Display) Alloc(r Rectangle, chn uint32, repl bool, col uint32) (*Image, error) {
+ d.mu.Lock()
+ d.nextid++
+ id := d.nextid
+ d.mu.Unlock()
+
+ clipr := r
+ if repl {
+ // Huge but not infinite, so offsets stay huge instead of overflowing.
+ clipr = Rect(-0x3FFFFFFF, -0x3FFFFFFF, 0x3FFFFFFF, 0x3FFFFFFF)
+ }
+ b := make([]byte, 51)
+ b[0] = 'b'
+ put32(b, 1, id)
+ put32(b, 5, 0) // screenid: not a window
+ b[9] = 0 // refresh: Refbackup
+ put32(b, 10, chn)
+ if repl {
+ b[14] = 1
+ }
+ put32(b, 15, uint32(r.Min.X))
+ put32(b, 19, uint32(r.Min.Y))
+ put32(b, 23, uint32(r.Max.X))
+ put32(b, 27, uint32(r.Max.Y))
+ put32(b, 31, uint32(clipr.Min.X))
+ put32(b, 35, uint32(clipr.Min.Y))
+ put32(b, 39, uint32(clipr.Max.X))
+ put32(b, 43, uint32(clipr.Max.Y))
+ put32(b, 47, col)
+ if err := d.write(b); err != nil {
+ return nil, fmt.Errorf("alloc image: %w", err)
+ }
+ return &Image{d: d, id: id, R: r, Clipr: clipr, chn: chn, repl: repl}, nil
+}
+
+// Color is the common case of Alloc: one replicated pixel of a solid colour.
+func (d *Display) Color(col uint32) (*Image, error) {
+ return d.Alloc(Rect(0, 0, 1, 1), chRGBA32, true, col)
+}
+
+// Free releases the image. All of a client's images go away by themselves
+// when its data fd closes, so this is only for long-running programs.
+func (i *Image) Free() error {
+ b := make([]byte, 5)
+ b[0] = 'f'
+ put32(b, 1, i.id)
+ return i.d.write(b)
+}
+
+// Draw copies src (through mask, or opaquely if mask is nil) into r on dst.
+func Draw(dst *Image, r Rectangle, src *Image, mask *Image, p Point) error {
+ b := make([]byte, 45)
+ b[0] = 'd'
+ put32(b, 1, dst.id)
+ put32(b, 5, src.id)
+ if mask == nil {
+ mask = dst.d.opaque
+ }
+ put32(b, 9, mask.id)
+ put32(b, 13, uint32(r.Min.X))
+ put32(b, 17, uint32(r.Min.Y))
+ put32(b, 21, uint32(r.Max.X))
+ put32(b, 25, uint32(r.Max.Y))
+ put32(b, 29, uint32(p.X))
+ put32(b, 33, uint32(p.Y))
+ put32(b, 37, uint32(p.X))
+ put32(b, 41, uint32(p.Y))
+ return dst.d.write(b)
+}
+
+// Flush makes queued drawing visible. devdraw executes each write as it
+// arrives, so this only matters for the screen refresh.
+func (d *Display) Flush() error {
+ return d.write([]byte{'v'})
+}
diff --git a/gui/draw/font.go b/gui/draw/font.go
new file mode 100644
index 0000000..b94f2fd
--- /dev/null
+++ b/gui/draw/font.go
@@ -0,0 +1,336 @@
+package draw
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "os"
+ "path"
+ "strconv"
+ "strings"
+)
+
+// Fontchar is one glyph's metrics, unpacked from the 6-byte on-disk form.
+type Fontchar struct {
+ X int32 // left edge in the subfont image
+ Top, Bottom, Left uint8
+ Width uint8 // advance
+}
+
+// Subfont is one image of glyphs plus their metrics -- the unit a .font file
+// is assembled from.
+type Subfont struct {
+ Bits *Image
+ N int
+ Height, Ascent int
+ Info []Fontchar // N+1 entries; the last one bounds the last glyph
+}
+
+type frange struct {
+ min, max rune
+ offset int
+ file string
+ sub *Subfont // loaded on first use
+}
+
+// Font is a .font file: a height, an ascent, and a set of rune ranges each
+// served by a subfont.
+type Font struct {
+ d *Display
+ Name string
+ Height, Ascent int
+ ranges []frange
+}
+
+// OpenFont reads a .font file. Subfonts are loaded lazily, because a unicode
+// font names dozens of them and a calendar touches two.
+func (d *Display) OpenFont(name string) (*Font, error) {
+ f, err := os.Open(name)
+ if err != nil {
+ return nil, fmt.Errorf("open font: %w", err)
+ }
+ defer f.Close()
+
+ fnt := &Font{d: d, Name: name}
+ dir := path.Dir(name)
+ sc := bufio.NewScanner(f)
+ first := true
+ for sc.Scan() {
+ fld := strings.Fields(sc.Text())
+ if len(fld) == 0 {
+ continue
+ }
+ if first {
+ if len(fld) < 2 {
+ return nil, fmt.Errorf("font %s: bad first line", name)
+ }
+ h, err1 := strconv.ParseInt(fld[0], 0, 32)
+ a, err2 := strconv.ParseInt(fld[1], 0, 32)
+ if err1 != nil || err2 != nil || h <= 0 || a <= 0 {
+ return nil, fmt.Errorf("font %s: bad height/ascent", name)
+ }
+ fnt.Height, fnt.Ascent = int(h), int(a)
+ first = false
+ continue
+ }
+ // min max [offset] file
+ if len(fld) < 3 {
+ continue
+ }
+ min, err1 := strconv.ParseInt(fld[0], 0, 32)
+ max, err2 := strconv.ParseInt(fld[1], 0, 32)
+ if err1 != nil || err2 != nil || min > max {
+ return nil, fmt.Errorf("font %s: bad range %q", name, sc.Text())
+ }
+ r := frange{min: rune(min), max: rune(max)}
+ rest := fld[2:]
+ // The offset is optional and sits before the filename, so it is
+ // only an offset if it parses as a number and something follows.
+ if len(rest) >= 2 {
+ if off, err := strconv.ParseInt(rest[0], 0, 32); err == nil {
+ r.offset = int(off)
+ rest = rest[1:]
+ }
+ }
+ r.file = rest[0]
+ if !path.IsAbs(r.file) {
+ r.file = path.Join(dir, r.file)
+ }
+ fnt.ranges = append(fnt.ranges, r)
+ }
+ if err := sc.Err(); err != nil {
+ return nil, err
+ }
+ if first {
+ return nil, fmt.Errorf("font %s: empty", name)
+ }
+ return fnt, nil
+}
+
+// lookup finds the subfont holding r and r's index within it.
+func (f *Font) lookup(r rune) (*Subfont, int) {
+ for i := range f.ranges {
+ g := &f.ranges[i]
+ if r < g.min || r > g.max {
+ continue
+ }
+ if g.sub == nil {
+ s, err := f.d.readSubfont(g.file)
+ if err != nil {
+ return nil, 0
+ }
+ g.sub = s
+ }
+ n := int(r-g.min) + g.offset
+ if n < 0 || n >= g.sub.N {
+ return nil, 0
+ }
+ return g.sub, n
+ }
+ return nil, 0
+}
+
+// String draws s at p (p is the top-left, as in libdraw) in colour src, and
+// returns the point just past the last glyph.
+func (f *Font) String(dst *Image, p Point, src *Image, s string) Point {
+ for _, r := range s {
+ sub, n := f.lookup(r)
+ if sub == nil {
+ continue
+ }
+ i, i1 := sub.Info[n], sub.Info[n+1]
+ w := i1.X - i.X
+ if w > 0 {
+ Draw(dst,
+ Rect(p.X+int32(i.Left), p.Y+int32(i.Top),
+ p.X+int32(i.Left)+w, p.Y+int32(i.Bottom)),
+ src, sub.Bits, Point{i.X, int32(i.Top)})
+ }
+ p.X += int32(i.Width)
+ }
+ return p
+}
+
+// Width is the advance of s, without drawing it.
+func (f *Font) Width(s string) int32 {
+ var w int32
+ for _, r := range s {
+ sub, n := f.lookup(r)
+ if sub == nil {
+ continue
+ }
+ w += int32(sub.Info[n].Width)
+ }
+ return w
+}
+
+// readSubfont loads one subfont file: a Plan 9 image, then the glyph table.
+//
+// The image is never decompressed here. Plan 9's compressed image format is
+// exactly what the draw device's 'Y' message accepts, so the blocks go
+// straight to the kernel -- which is all libdraw's creadimage does too.
+func (d *Display) readSubfont(name string) (*Subfont, error) {
+ f, err := os.Open(name)
+ if err != nil {
+ return nil, fmt.Errorf("open subfont: %w", err)
+ }
+ defer f.Close()
+ r := bufio.NewReader(f)
+
+ var hdr [5 * 12]byte
+ if _, err := io.ReadFull(r, hdr[:11]); err != nil {
+ return nil, fmt.Errorf("%s: short header: %w", name, err)
+ }
+ compressed := string(hdr[:11]) == "compressed\n"
+ if compressed {
+ if _, err := io.ReadFull(r, hdr[:]); err != nil {
+ return nil, fmt.Errorf("%s: short header: %w", name, err)
+ }
+ } else if _, err := io.ReadFull(r, hdr[11:]); err != nil {
+ return nil, fmt.Errorf("%s: short header: %w", name, err)
+ }
+
+ fld := func(i int) int32 {
+ v, _ := strconv.Atoi(trim(string(hdr[i*12 : i*12+12])))
+ return int32(v)
+ }
+ chn := strToChan(trim(string(hdr[0:12])))
+ ir := Rect(fld(1), fld(2), fld(3), fld(4))
+ img, err := d.Alloc(ir, chn, false, 0)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", name, err)
+ }
+
+ if compressed {
+ err = img.loadCompressed(r, ir)
+ } else {
+ err = img.loadRaw(r, ir, chn)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", name, err)
+ }
+
+ // n, height, ascent, then 6 bytes per glyph for n+1 glyphs.
+ var sh [3 * 12]byte
+ if _, err := io.ReadFull(r, sh[:]); err != nil {
+ return nil, fmt.Errorf("%s: short subfont header: %w", name, err)
+ }
+ n, _ := strconv.Atoi(trim(string(sh[0:12])))
+ if n <= 0 || n > 0x7fff {
+ return nil, fmt.Errorf("%s: bad glyph count %d", name, n)
+ }
+ sf := &Subfont{
+ Bits: img,
+ N: n,
+ Height: mustAtoi(trim(string(sh[12:24]))),
+ Ascent: mustAtoi(trim(string(sh[24:36]))),
+ Info: make([]Fontchar, n+1),
+ }
+ pk := make([]byte, 6*(n+1))
+ if _, err := io.ReadFull(r, pk); err != nil {
+ return nil, fmt.Errorf("%s: short glyph table: %w", name, err)
+ }
+ for i := 0; i <= n; i++ {
+ b := pk[i*6:]
+ sf.Info[i] = Fontchar{
+ X: int32(b[0]) | int32(b[1])<<8,
+ Top: b[2],
+ Bottom: b[3],
+ Left: b[4],
+ Width: b[5],
+ }
+ }
+ return sf, nil
+}
+
+func mustAtoi(s string) int { v, _ := strconv.Atoi(s); return v }
+
+// loadCompressed forwards each compressed block to the draw device verbatim.
+func (i *Image) loadCompressed(r io.Reader, ir Rectangle) error {
+ var bh [2 * 12]byte
+ miny := ir.Min.Y
+ for miny != ir.Max.Y {
+ if _, err := io.ReadFull(r, bh[:]); err != nil {
+ return fmt.Errorf("short block header: %w", err)
+ }
+ maxy := int32(mustAtoi(trim(string(bh[0:12]))))
+ nb := mustAtoi(trim(string(bh[12:24])))
+ if maxy <= miny || maxy > ir.Max.Y || nb <= 0 {
+ return fmt.Errorf("bad block: maxy=%d nb=%d", maxy, nb)
+ }
+ buf := make([]byte, 21+nb)
+ buf[0] = 'Y'
+ put32(buf, 1, i.id)
+ put32(buf, 5, uint32(ir.Min.X))
+ put32(buf, 9, uint32(miny))
+ put32(buf, 13, uint32(ir.Max.X))
+ put32(buf, 17, uint32(maxy))
+ if _, err := io.ReadFull(r, buf[21:]); err != nil {
+ return fmt.Errorf("short block: %w", err)
+ }
+ if err := i.d.write(buf); err != nil {
+ return err
+ }
+ miny = maxy
+ }
+ return nil
+}
+
+// loadRaw uploads an uncompressed image a stripe at a time with 'y'.
+func (i *Image) loadRaw(r io.Reader, ir Rectangle, chn uint32) error {
+ depth := chanDepth(chn)
+ if depth == 0 {
+ return fmt.Errorf("bad channel %#x", chn)
+ }
+ bpl := bytesPerLine(ir, depth)
+ // Keep each write comfortably inside the device's iounit.
+ rows := 8000 / bpl
+ if rows < 1 {
+ rows = 1
+ }
+ for y := ir.Min.Y; y < ir.Max.Y; {
+ y1 := y + int32(rows)
+ if y1 > ir.Max.Y {
+ y1 = ir.Max.Y
+ }
+ n := bpl * int(y1-y)
+ buf := make([]byte, 21+n)
+ buf[0] = 'y'
+ put32(buf, 1, i.id)
+ put32(buf, 5, uint32(ir.Min.X))
+ put32(buf, 9, uint32(y))
+ put32(buf, 13, uint32(ir.Max.X))
+ put32(buf, 17, uint32(y1))
+ if _, err := io.ReadFull(r, buf[21:]); err != nil {
+ return fmt.Errorf("short pixel data: %w", err)
+ }
+ if err := i.d.write(buf); err != nil {
+ return err
+ }
+ y = y1
+ }
+ return nil
+}
+
+// chanDepth is bits per pixel for a channel descriptor.
+func chanDepth(c uint32) int {
+ d := 0
+ for ; c != 0; c >>= 8 {
+ d += int(c & 15)
+ }
+ return d
+}
+
+// bytesPerLine mirrors libdraw's unitsperline(r, d, 8), including the
+// negative-min.x case.
+func bytesPerLine(r Rectangle, d int) int {
+ if d <= 0 || d > 32 {
+ return 0
+ }
+ if r.Min.X >= 0 {
+ l := (int(r.Max.X)*d + 7) / 8
+ return l - (int(r.Min.X)*d)/8
+ }
+ l := (int(r.Max.X)*d + 7) / 8
+ return l + (int(-r.Min.X)*d+7)/8
+}
diff --git a/gui/draw/keyboard.go b/gui/draw/keyboard.go
new file mode 100644
index 0000000..3885364
--- /dev/null
+++ b/gui/draw/keyboard.go
@@ -0,0 +1,65 @@
+package draw
+
+import (
+ "bufio"
+ "os"
+ "unicode/utf8"
+)
+
+// Keyboardctl delivers runes typed at the window.
+//
+// Opening it also turns the console raw. Without that, rio keeps its own
+// line editor on the window and echoes what you type into the text layer,
+// painting over whatever the program has drawn. Holding consctl open is
+// what keeps raw mode; closing it puts the window back as it was.
+type Keyboardctl struct {
+ C <-chan rune
+ ctl *os.File
+ cons *os.File
+}
+
+func OpenKeyboard(dev string) (*Keyboardctl, error) {
+ ctl, err := os.OpenFile(dev+"/consctl", os.O_WRONLY, 0)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := ctl.WriteString("rawon"); err != nil {
+ ctl.Close()
+ return nil, err
+ }
+ cons, err := os.Open(dev + "/cons")
+ if err != nil {
+ ctl.Close()
+ return nil, err
+ }
+
+ ch := make(chan rune, 32)
+ k := &Keyboardctl{C: ch, ctl: ctl, cons: cons}
+ go func() {
+ defer close(ch)
+ br := bufio.NewReader(cons)
+ var buf []byte
+ b := make([]byte, 1)
+ for {
+ n, err := br.Read(b)
+ if n == 0 || err != nil {
+ return
+ }
+ buf = append(buf, b[0])
+ // a rune may arrive a byte at a time
+ if r, sz := utf8.DecodeRune(buf); r != utf8.RuneError || sz > 1 {
+ ch <- r
+ buf = buf[:0]
+ } else if len(buf) >= utf8.UTFMax {
+ buf = buf[:0]
+ }
+ }
+ }()
+ return k, nil
+}
+
+func (k *Keyboardctl) Close() error {
+ k.cons.Close()
+ // dropping consctl is what restores rio's own line editing
+ return k.ctl.Close()
+}
diff --git a/gui/draw/mouse.go b/gui/draw/mouse.go
new file mode 100644
index 0000000..a4ddf63
--- /dev/null
+++ b/gui/draw/mouse.go
@@ -0,0 +1,74 @@
+package draw
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+)
+
+// Mouse is one record from /dev/mouse.
+type Mouse struct {
+ Point
+ Buttons int
+ Msec uint32
+}
+
+// Mousectl delivers mouse and resize events on channels. This is the shape
+// libdraw's Mousectl already has -- it is hand-rolled CSP in C because there
+// were no goroutines. Here it is what the language does natively.
+type Mousectl struct {
+ C <-chan Mouse
+ Resize <-chan struct{}
+
+ f *os.File
+}
+
+func OpenMouse(dev string) (*Mousectl, error) {
+ if dev == "" {
+ dev = "/dev"
+ }
+ f, err := os.OpenFile(dev+"/mouse", os.O_RDWR, 0)
+ if err != nil {
+ return nil, fmt.Errorf("open mouse: %w", err)
+ }
+ c := make(chan Mouse)
+ rc := make(chan struct{}, 1)
+ mc := &Mousectl{C: c, Resize: rc, f: f}
+ // One goroutine parked in read(2). Plan 9 has no netpoll in the Go
+ // runtime, so this pins an OS thread for as long as it blocks -- fine
+ // for the two or three event sources a UI has, not for hundreds.
+ go func() {
+ defer close(c)
+ buf := make([]byte, 1+5*12)
+ for {
+ n, err := f.Read(buf)
+ if err != nil {
+ return
+ }
+ if n != 1+4*12 {
+ continue
+ }
+ fld := func(i int) int {
+ v, _ := strconv.Atoi(trim(string(buf[1+i*12 : 1+i*12+12])))
+ return v
+ }
+ switch buf[0] {
+ case 'r':
+ select {
+ case rc <- struct{}{}:
+ default:
+ }
+ fallthrough
+ case 'm':
+ c <- Mouse{
+ Point: Point{int32(fld(0)), int32(fld(1))},
+ Buttons: fld(2),
+ Msec: uint32(fld(3)),
+ }
+ }
+ }
+ }()
+ return mc, nil
+}
+
+func (mc *Mousectl) Close() error { return mc.f.Close() }
diff --git a/gui/draw/snarf.go b/gui/draw/snarf.go
new file mode 100644
index 0000000..ae26edd
--- /dev/null
+++ b/gui/draw/snarf.go
@@ -0,0 +1,15 @@
+package draw
+
+import "os"
+
+// Snarf puts text on the system snarf buffer, which is what every other
+// program on the system means by "copy".
+func Snarf(dev, text string) error {
+ f, err := os.OpenFile(dev+"/snarf", os.O_WRONLY|os.O_TRUNC, 0)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ _, err = f.WriteString(text)
+ return err
+}
diff --git a/gui/draw/window.go b/gui/draw/window.go
new file mode 100644
index 0000000..0de8070
--- /dev/null
+++ b/gui/draw/window.go
@@ -0,0 +1,86 @@
+package draw
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+)
+
+// NamedImage looks up an image the window system published by name and
+// installs it under a fresh id of ours. The 'n' message also sets the
+// client's infoid, so the image's geometry comes back from a plain read of
+// the ctl file -- the same 12-field block that draw/new returns.
+func (d *Display) NamedImage(name string) (*Image, error) {
+ if name == "" || len(name) >= 256 {
+ return nil, fmt.Errorf("namedimage: bad name %q", name)
+ }
+ d.mu.Lock()
+ d.nextid++
+ id := d.nextid
+ d.mu.Unlock()
+
+ b := make([]byte, 6+len(name))
+ b[0] = 'n'
+ put32(b, 1, id)
+ b[5] = byte(len(name))
+ copy(b[6:], name)
+ if err := d.write(b); err != nil {
+ return nil, fmt.Errorf("namedimage %q: %w", name, err)
+ }
+
+ var info [12 * 12]byte
+ n, err := d.ctl.ReadAt(info[:], 0)
+ if err != nil && n < len(info) {
+ return nil, fmt.Errorf("namedimage %q: ctl read: %w", name, err)
+ }
+ fld := func(i int) int32 {
+ v, _ := strconv.Atoi(trim(string(info[i*12 : i*12+12])))
+ return int32(v)
+ }
+ return &Image{
+ d: d,
+ id: id,
+ chn: strToChan(trim(string(info[2*12 : 3*12]))),
+ repl: fld(3) != 0,
+ R: Rect(fld(4), fld(5), fld(6), fld(7)),
+ Clipr: Rect(fld(8), fld(9), fld(10), fld(11)),
+ }, nil
+}
+
+// Window returns the image to draw into: the rio window named by
+// <dev>/winname if there is one, otherwise the raw screen.
+//
+// Without this a program draws to image 0 -- the screen itself -- straight
+// over the window system. Nothing stops it: /dev/draw is the kernel's
+// device, not rio's, and rio publishes the window by name rather than
+// proxying the device. Being a good citizen is opt-in, and this is the
+// opt-in.
+func (d *Display) Window(dev string) (*Image, error) {
+ if dev == "" {
+ dev = "/dev"
+ }
+ buf, err := os.ReadFile(dev + "/winname")
+ if err != nil {
+ return d.Screen, nil // not under a window system
+ }
+ name := strings.TrimRight(string(buf), "\x00\n ")
+ if name == "" {
+ return d.Screen, nil
+ }
+ win, err := d.NamedImage(name)
+ if err != nil {
+ return nil, err
+ }
+ return win, nil
+}
+
+// Reattach re-reads winname and looks the window up again. rio replaces the
+// window's image on resize rather than resizing it in place, so a resize
+// event means "throw the old one away and ask again".
+func (d *Display) Reattach(dev string, old *Image) (*Image, error) {
+ if old != nil && old.id != 0 {
+ old.Free() // ignore errors: the old image may already be gone
+ }
+ return d.Window(dev)
+}
diff --git a/gui/go.mod b/gui/go.mod
new file mode 100644
index 0000000..e5c497d
--- /dev/null
+++ b/gui/go.mod
@@ -0,0 +1,3 @@
+module 9front/gui
+
+go 1.23
diff --git a/gui/probe/go.mod b/gui/probe/go.mod
new file mode 100644
index 0000000..6edd0a9
--- /dev/null
+++ b/gui/probe/go.mod
@@ -0,0 +1,3 @@
+module probe
+
+go 1.21
diff --git a/gui/probe/main.go b/gui/probe/main.go
new file mode 100644
index 0000000..26b925b
--- /dev/null
+++ b/gui/probe/main.go
@@ -0,0 +1,5 @@
+package main
+
+import "fmt"
+
+func main() { fmt.Println("hello from go on plan9") }