diff --git a/.github/screens.png b/.github/screens.png new file mode 100644 index 0000000..72d545d Binary files /dev/null and b/.github/screens.png differ diff --git a/README.md b/README.md index 4ad4699..d0d6630 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ Selection: ppp - PPP dialers detected (AutoPPP and PAP), with a pppd plugin - The classic AOL client dialing in: AOLnet, SprintNet and Tymnet played, then the line to a BBS's AOL host - Menus with access by user and group, guests and self sign-up +- The menu full screen on ANSI and VT terminals (a 90s blue desk, the bar moved with the cursor keys; only what changed is sent, for slow lines), lines for the others +- `modemmenu -setup`: the configuration on full screens with pull-down menus and the sample's help, for the sysop at the console or calling in - Each item starts with the caller's credentials and the call's details: dialed number, caller ID, connect rate, time - Drop files for doors, last callers, and handing a caller to another menu - A ready-made FreePBX setup ([freepbx/](freepbx/README.md)) @@ -49,6 +51,7 @@ Go 1.22 or later: make # with PAM when libpam's headers are there make install # /usr/local/bin/modemmenu and the samples modemmenu -config /etc/asterisk/modemmenu.conf -check # check the configuration +modemmenu -config /etc/asterisk/modemmenu.conf -setup # or change it on a full screen ``` then point an `app_dmodem` profile at it ([docs/running.md](docs/running.md)). diff --git a/cmd/modemmenu/access_test.go b/cmd/modemmenu/access_test.go index 8506d71..3a4517c 100644 --- a/cmd/modemmenu/access_test.go +++ b/cmd/modemmenu/access_test.go @@ -203,7 +203,7 @@ func TestUsage(t *testing.T) { func callAs(t *testing.T, bin, conf string, env ...string) (*caller, *exec.Cmd) { m, s := testPty(t) t.Cleanup(func() { m.Close() }) - cmd := exec.Command(bin, "-config", conf) + cmd := exec.Command(bin, "-config", lineMenu(conf)) cmd.Stdin, cmd.Stdout, cmd.Stderr = s, s, s cmd.Env = append(os.Environ(), env...) cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true} diff --git a/cmd/modemmenu/dropfile_test.go b/cmd/modemmenu/dropfile_test.go index 5bc5bd1..f03e73a 100644 --- a/cmd/modemmenu/dropfile_test.go +++ b/cmd/modemmenu/dropfile_test.go @@ -222,7 +222,7 @@ prompt = "\r\nDown ({time_left}): " [item nap] command = echo "DOWN left=$MODEM_SECONDS_LEFT"; exec sleep 60 `, users, sock)), 0o600) - srv := exec.Command(bin, "-config", down, "-serve") + srv := exec.Command(bin, "-config", lineMenu(down), "-serve") if err := srv.Start(); err != nil { t.Fatal(err) } diff --git a/cmd/modemmenu/e2e_test.go b/cmd/modemmenu/e2e_test.go index 7816233..ae651e8 100644 --- a/cmd/modemmenu/e2e_test.go +++ b/cmd/modemmenu/e2e_test.go @@ -187,7 +187,7 @@ command = env > %s; cat "$MODEM_SESSION_FILE" >> %s; echo args: {user} {baud} {d m, s := testPty(t) defer m.Close() - cmd := exec.Command(bin, "-config", conf) + cmd := exec.Command(bin, "-config", lineMenu(conf)) cmd.Stdin, cmd.Stdout, cmd.Stderr = s, s, s cmd.Env = append(os.Environ(), "DMODEM_RATE=33600/31200", "DMODEM_CALLERID=5559999", "DMODEM_DIALED=5551000", "DMODEM_CHANNEL=Local/s@test", "TMPDIR="+dir) @@ -287,7 +287,7 @@ func TestHangup(t *testing.T) { os.WriteFile(conf, []byte("[general]\nlog = none\n[file]\npath = "+users+ "\n[item ppp]\ncommand = exec true\n"), 0o600) m, s := testPty(t) - cmd := exec.Command(bin, "-config", conf) + cmd := exec.Command(bin, "-config", lineMenu(conf)) cmd.Stdin, cmd.Stdout, cmd.Stderr = s, s, s cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true} if err := cmd.Start(); err != nil { @@ -307,3 +307,18 @@ func TestHangup(t *testing.T) { t.Fatal("modemmenu is still running after the hangup") } } + +// lineMenu: the configuration at path with the menu of lines (not full +// screen), for the tests that read it as lines; path +func lineMenu(path string) string { + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + return path + } + defer f.Close() + b, _ := os.ReadFile(path) + if !bytes.Contains(b, []byte("fullscreen =")) { + f.WriteString("\n[menu]\nfullscreen = no\n") + } + return path +} diff --git a/cmd/modemmenu/fullscreen.go b/cmd/modemmenu/fullscreen.go new file mode 100644 index 0000000..4b2a27b --- /dev/null +++ b/cmd/modemmenu/fullscreen.go @@ -0,0 +1,262 @@ +// Copyright (C) 2026 D-Modem Authors +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "errors" + "fmt" + "io" + "regexp" + "strconv" + "strings" + "time" + + "github.com/D-Modem/menu/internal/config" + "github.com/D-Modem/menu/internal/term" + "github.com/D-Modem/menu/internal/tui" +) + +// The menu full screen, on a terminal that can have it (ANSI, or a VT +// that said what it is, of 40x14 at least): the items in a window on the +// desk, a bar moved with the cursor keys, an item's key picking it at +// once. Others get the menu of lines. The login stays lines, for the +// dialers and AOL clients that read and answer it. + +// deskFor is the full screen for the caller's terminal, nil when it +// cannot have one (or [menu] fullscreen = no). +func deskFor(t *term.Term, s *settings, sess *session) *tui.Desk { + if !s.fullscreen || t.PETSCII || (sess.Terminal != "ansi" && sess.Terminal != "vt") { + return nil + } + rows, _ := strconv.Atoi(sess.Rows) + cols, _ := strconv.Atoi(sess.Cols) + if rows == 0 || cols == 0 { + rows, cols = 24, 80 + } + if !tui.OK(rows, cols) { + return nil + } + d := &tui.Desk{Line: t, Rows: rows, Cols: cols, Idle: s.idleTimeout} + // the shaded desk costs a byte or three a cell: not below 9600 bit/s + if rate, err := strconv.Atoi(sess.TxRate); err != nil || rate >= 9600 { + d.Shade = true + } + // a DEC terminal itself has no colors; the emulators do + d.Mono = sess.hardware + // the lines: UTF-8 where it showed é as one character; else a VT's + // own set for what said it is a VT or xterm, code page 437 for the + // ANSI terminals of BBSs (which ask nothing of them) and SyncTERM + cs, ok := tui.ParseCharset(s.terminal.charset) + switch { + case ok: + case sess.charset == "utf8", sess.charset == "" && sess.Term == "xterm": + cs = tui.UTF8 + case sess.charset != "cp437" && (strings.HasPrefix(sess.Term, "vt") || sess.Term == "xterm"): + cs = tui.DEC + default: + cs = tui.CP437 + } + d.Charset = cs + return d +} + +// escapes: what a template's text has that is not to be read +var escapes = regexp.MustCompile(`\x1b\[[0-9;?]*[@-~]|\x1b.|[\x00-\x08\x0b-\x1f\x7f]`) + +// plainLines: a template's text as lines to put in a window, without its +// escapes and empty lines +func plainLines(s string) []string { + s = escapes.ReplaceAllString(strings.ReplaceAll(s, "\r", ""), "") + var out []string + for _, l := range strings.Split(s, "\n") { + if l = strings.TrimSpace(l); l != "" { + out = append(out, l) + } + } + return out +} + +// fullMenu: the menu full screen until the caller hangs up, says goodbye, +// the time is up or a selection ends with after = hangup; first (if not +// nil) starts at once. The desk is down when it returns. +func fullMenu(t *term.Term, s *settings, sess *session, lim *limiter, first *menuItem, d *tui.Desk, motd string) error { + defer d.Close() + items := s.visible(sess) + title := "Main menu" + if l := plainLines(config.Expand(s.menuTitle, sess.vars(false), nil)); len(l) > 0 { + title = l[0] + } + w := &tui.List{Title: title, Center: true, MinW: 44} + if l := plainLines(config.Expand(s.welcome, sess.vars(false), nil)); len(l) > 0 { + w.Notes = l + } + for _, it := range items { + w.Items = append(w.Items, tui.Entry{ID: it.name, Key: it.key, Label: it.label, Help: it.help}) + } + w.Items = append(w.Items, tui.Entry{Key: s.hangup[0], Label: "Goodbye (hang up)", Help: "Hang up the line."}) + w.Fix() + d.Name = sess.User + if sp := sess.Speed(); sp != "" { + d.Name += " at " + sp + } + // a box to show on the desk: the motd first, then what launch said + // went wrong + notice, noticeTitle, noticeKind := "", "", tui.Info + if m := strings.TrimSpace(escapes.ReplaceAllString(strings.ReplaceAll(motd, "\r", ""), "")); m != "" { + notice, noticeTitle = m, "Message of the day" + } + for { + lim.mark(sess, time.Now()) + left, limited := lim.left(time.Now()) + if limited && left <= 0 { + return errTimeLimit + } + idle := time.Now().Add(s.idleTimeout) + if limited && lim.deadline.Before(idle) { + idle = lim.deadline + } + it := first + first = nil + for it == nil { + now := time.Now() + left, limited := lim.left(now) + frame := func() *tui.Screen { + scr := d.Blank(-1) + d.Status(scr, now.Format("15:04")) + w.Draw(d, scr, true) + if e := w.Selected(); e != nil { + help := e.Help + if help == "" { + help = "Enter starts it, or press its key: " + e.Key + "." + } + d.HelpBox(scr, e.Label, help, false) + } + status := "" + if limited { + status = fmt.Sprintf("%d min left", int((left+30*time.Second)/time.Minute)) + } + d.KeyBar(scr, tui.Hints("↑↓", "Choose", "Enter", "Select", "Esc", "Goodbye", "F1", "Help"), status) + return scr + } + scr := frame() + if notice != "" { + if _, err := d.Message(scr, noticeKind, noticeTitle, notice); err != nil { + return err + } + notice = "" + continue + } + if err := d.Show(scr); err != nil { + return err + } + // a key until idle, the clock redrawn each minute + wait := min(time.Until(idle), time.Until(now.Truncate(time.Minute).Add(time.Minute))) + k, _, err := d.Key(max(wait, time.Millisecond)) + if errors.Is(err, term.ErrTimeout) { + if time.Now().Before(idle) { + continue + } + if limited && !time.Now().Before(lim.deadline) { + return errTimeLimit + } + return term.ErrTimeout + } + if err != nil { + return err + } + if terminated.Load() { + return term.ErrHangup + } + idle = time.Now().Add(s.idleTimeout) + if limited && lim.deadline.Before(idle) { + idle = lim.deadline + } + pick := false + switch k { + case tui.KeyEnter, tui.KeyRight: + pick = true + case 0x04: + return io.EOF + case tui.KeyEsc: + yes, err := d.Confirm(frame(), tui.Info, "Goodbye", "Hang up the line?") + if err != nil { + return err + } + if yes { + return nil + } + case tui.F(1), '?': + if _, err := d.Message(frame(), tui.Info, "Help", menuHelp); err != nil { + return err + } + default: + _, pick = w.Move(d, k) + } + if !pick { + continue + } + e := w.Selected() + if e == nil { + continue + } + if e.ID == "" { + return nil // goodbye + } + it = selectFrom(items, strings.ToUpper(e.Key)) + } + + // the selection has the line: the desk down, the screen clear + d.Close() + sess.Selection = it.name + lim.mark(sess, time.Now()) + logf("start %s user=%s mode=%s", it.name, sess.User, it.mode) + err := launch(t, s, it, sess, lim.deadline, logf) + if errors.Is(err, term.ErrHangup) || terminated.Load() { + return term.ErrHangup + } + if errors.Is(err, errTimeLimit) { + logf("%s: time limit", it.name) + return errTimeLimit + } + if err != nil { + logf("%s: %v", it.name, err) + notice, noticeTitle, noticeKind = it.label+" is not available now.", "Not available", tui.Alert + continue + } + logf("end %s user=%s", it.name, sess.User) + if it.after == "hangup" { + return nil + } + if pausesAfter(it) { + // what it wrote stays until the caller has read it + if err := pause(t, d, s, "Press any key for the menu"); err != nil { + return err + } + } + } +} + +// menuHelp: F1 in the full-screen menu +const menuHelp = "Move the bar with the cursor keys (or the first letter of an item) " + + "and press Enter, or press an item's key in [ ] to start it at once.\n\n" + + "Esc hangs up, after asking. ^L draws the screen again." + +// pausesAfter: whether what an item wrote is kept on the screen until a +// key (programs here; not a server that drew its own screens) +func pausesAfter(it *menuItem) bool { + switch it.mode { + case "exec", "passwd", "callers": + return true + } + return false +} + +// pause: "press a key" under what is on the screen, until one or idle +func pause(t *term.Term, d *tui.Desk, s *settings, say string) error { + if err := t.Write("\r\n\x1b[0;7m " + say + " \x1b[0m"); err != nil { + return err + } + _, _, err := d.Key(s.idleTimeout) + return err +} diff --git a/cmd/modemmenu/fullscreen_test.go b/cmd/modemmenu/fullscreen_test.go new file mode 100644 index 0000000..88f57e1 --- /dev/null +++ b/cmd/modemmenu/fullscreen_test.go @@ -0,0 +1,281 @@ +// Copyright (C) 2026 D-Modem Authors +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/D-Modem/menu/internal/tui" +) + +// TestFullScreenMenu: an ANSI caller at 2400 bit/s gets the menu full +// screen (in code page 437, the desk plain): an item by its key, the +// pause after it, the sysop's setup and back, goodbye by its key; a +// caller whose terminal says nothing gets the menu of lines +func TestFullScreenMenu(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "modemmenu") + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("build: %v\n%s", err, out) + } + users := filepath.Join(dir, "users") + os.WriteFile(users, []byte("alice:{PLAIN}secret\nbob:{PLAIN}pw\n"), 0o600) + conf := filepath.Join(dir, "modemmenu.conf") + os.WriteFile(conf, []byte(fmt.Sprintf(` +[general] +log = none +auth = file +fail_delay = 0 +motd = "The line is down on Sunday." +[file] +path = %s +[groups] +sysops = alice +[item board] +key = B +label = The board +help = Messages and files +mode = exec +command = echo hello from the board +after = menu +[item setup] +key = Z +label = Setup +mode = setup +groups = sysops +`, users)), 0o600) + + start := func(answer bool) *caller { + m, s := testPty(t) + t.Cleanup(func() { m.Close() }) + cmd := exec.Command(bin, "-config", conf) + cmd.Stdin, cmd.Stdout, cmd.Stderr = s, s, s + cmd.Env = append(os.Environ(), "DMODEM_RATE=2400") + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true} + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + s.Close() + t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() }) + if answer { + return newCaller(t, m) + } + return newTermCaller(t, m, func([]byte) []byte { return nil }) + } + + c := start(true) + c.expect("login: ") + c.send("alice\r") + c.expect("Password: ") + c.send("secret\r") + // the desk, cleared in its blue, the motd in a box over the menu + got := c.expect("The line is down on Sunday.") + if !strings.Contains(got, "\x1b[0m\x1b[0;37;44m\x1b[2J") { + t.Errorf("no desk: %q", got) + } + got += c.expect(" OK ") + if strings.Contains(got, "░") || strings.Contains(got, "\xb0") || strings.Contains(got, "╔") { + t.Errorf("a shaded desk, or UTF-8, at 2400 bit/s: %q", got) + } + if !strings.Contains(got, "\xc9\xcd") { // ╔═ in code page 437 + t.Errorf("no code page 437 frame: %q", got) + } + c.send("\r") + c.expect("The board") + c.expect("Messages and files") + c.expect("Goodbye (hang up)") + c.send("b") + c.expect("\x1b[2J") + c.expect("hello from the board") + c.expect("Press any key for the menu") + c.send(" ") + c.expect("The board") + + // the setup, and F9 back to the menu + c.send("z") + c.expect("modemmenu Setup") + c.expect("Login and messages") + c.send("\x1b[20~") + c.expect("The board") + c.send("g") + c.expect("Goodbye.") + + // bob is no sysop: no setup on his menu, and its key does nothing + c = start(true) + c.expect("login: ") + c.send("bob\r") + c.expect("Password: ") + c.send("pw\r") + c.expect(" OK ") + c.send("\r") + got = c.expect("Goodbye (hang up)") + if strings.Contains(got, "Setup") { + t.Errorf("bob sees the setup: %q", got) + } + c.send("z") + time.Sleep(300 * time.Millisecond) + c.send("g") + got = c.expect("Goodbye.") + if strings.Contains(got, "modemmenu Setup") { + t.Errorf("bob got the setup: %q", got) + } + + // a terminal that answers nothing: the menu of lines + c = start(false) + c.expect("login: ") + c.send("alice\r") + c.expect("Password: ") + c.send("secret\r") + c.expect("[B] The board") + c.expect("Selection: ") + c.send("g\r") + c.expect("Goodbye.") +} + +// TestSetupNeedsSysops: an item of mode = setup is for some only +func TestSetupNeedsSysops(t *testing.T) { + dir := t.TempDir() + for _, c := range []struct { + item, err string + }{ + {"mode = setup", "needs users or groups"}, + {"mode = setup\nguest = yes\nusers = alice", "not for guests"}, + {"mode = setup\nusers = alice", ""}, + } { + p := filepath.Join(dir, "c.conf") + os.WriteFile(p, []byte("[general]\nlog = none\nauth = file\n[file]\npath = /dev/null\n[item s]\nkey = S\n"+c.item+"\n"), 0o600) + _, err := loadSettings(p) + if c.err == "" && err != nil || c.err != "" && (err == nil || !strings.Contains(err.Error(), c.err)) { + t.Errorf("%q: %v", c.item, err) + } + } +} + +// keyLine: keys queued for the setup, what it writes kept +type keyLine struct { + in []byte + out bytes.Buffer +} + +func (f *keyLine) ReadOne(time.Duration) (byte, error) { + if len(f.in) == 0 { + return 0, tui.ErrTimeout + } + b := f.in[0] + f.in = f.in[1:] + return b, nil +} + +func (f *keyLine) WriteRaw(s string) error { f.out.WriteString(s); return nil } + +// TestSetup: values changed through the screens and saved, the file's +// comments and order kept; a section added, an item renamed in [menu] +// items too; a file that does not check is not saved without asking +func TestSetup(t *testing.T) { + dir := t.TempDir() + users := filepath.Join(dir, "users") + os.WriteFile(users, []byte("alice:{PLAIN}secret\n"), 0o600) + conf := filepath.Join(dir, "modemmenu.conf") + orig := fmt.Sprintf(`; my line +[general] +log = none +auth = file +; how many tries +attempts = 3 ; three +[file] +path = %s +[menu] +items = bbs +[item bbs] +key = B +label = Board +mode = exec +command = true +`, users) + os.WriteFile(conf, []byte(orig), 0o640) + + // an Esc and what follows must not be taken as Alt: they come apart + const esc = "\x1b\x00" + const ( + up, enter, f9, f10 = "\x1b[A", "\r", "\x1b[20~", "\x1b[21~" + ) + keys := strings.Join([]string{ + enter, // Login and messages + "a", "a", enter, // auth, attempts: typed + "\x15", "5", enter, // + esc, // back to the first window + "g", enter, // Guest account + enter, up, enter, // enabled: from the list, no + esc, // + "m", "m", enter, enter, // Menu items, bbs + "\x1b[F", up, enter, "\x15board", enter, // End, Rename + esc, esc, // + f10, f9, + }, "") + f := &keyLine{in: []byte(keys)} + d := &tui.Desk{Line: f, Rows: 25, Cols: 80, Charset: tui.UTF8} + u, err := newSetup(d, conf) + if err != nil { + t.Fatal(err) + } + u.size = func() (int, int) { return 25, 80 } + if err := u.run(); err != nil { + if scr := d.Screen(); scr != nil { + t.Log("\n" + scr.Text()) + } + t.Fatal(err) + } + b, _ := os.ReadFile(conf) + want := fmt.Sprintf(`; my line +[general] +log = none +auth = file +; how many tries +attempts = 5 ; three +[file] +path = %s +[menu] +items = board +[item board] +key = B +label = Board +mode = exec +command = true + +[guest] +enabled = no +`, users) + if string(b) != want { + t.Errorf("saved:\n%s\nwant:\n%s", b, want) + } + if st, _ := os.Stat(conf); st.Mode().Perm() != 0o640 { + t.Errorf("mode %v", st.Mode()) + } + if _, err := loadSettings(conf); err != nil { + t.Errorf("does not load: %v", err) + } + + // a mistake: the check says, Back keeps the file as it is + keys = strings.Join([]string{enter, "a", "a", enter, "\x15", "many", enter, f10, enter, f9, "d"}, "") + f.in = []byte(keys) + f.out.Reset() + u, _ = newSetup(d, conf) + if err := u.run(); err != nil { + t.Fatal(err) + } + if !strings.Contains(f.out.String(), "not a number") { + t.Errorf("the check's error not shown") + } + if b2, _ := os.ReadFile(conf); string(b2) != want { + t.Errorf("saved after a failed check:\n%s", b2) + } +} diff --git a/cmd/modemmenu/handoff_test.go b/cmd/modemmenu/handoff_test.go index 7af5cde..f2c726a 100644 --- a/cmd/modemmenu/handoff_test.go +++ b/cmd/modemmenu/handoff_test.go @@ -342,7 +342,13 @@ command = echo "pppd up"; exec sleep 1000 if d := time.Since(start); d > 20*time.Second { t.Errorf("hung up %v after PPP started", d) } - log, _ := os.ReadFile(filepath.Join(dir, "server.log")) + // the server logs the session's end just after the line closes + var log []byte + for end := time.Now().Add(5 * time.Second); time.Now().Before(end); time.Sleep(50 * time.Millisecond) { + if log, _ = os.ReadFile(filepath.Join(dir, "server.log")); strings.Contains(string(log), "ended") { + break + } + } if !strings.Contains(string(log), "on the line handed over") || !strings.Contains(string(log), "ended") { t.Errorf("log:\n%s", log) } diff --git a/cmd/modemmenu/launch.go b/cmd/modemmenu/launch.go index cd06d93..936ddcd 100644 --- a/cmd/modemmenu/launch.go +++ b/cmd/modemmenu/launch.go @@ -48,6 +48,8 @@ func launch(t *term.Term, cfg *settings, it *menuItem, s *session, deadline time return changePasswordItem(t, cfg, it, s, deadline) case "callers": return callersItem(t, cfg, it) + case "setup": + return setupItem(t, cfg, it, s, deadline) case "exec": return runExec(t, cfg, it, s, deadline, logf) default: diff --git a/cmd/modemmenu/main.go b/cmd/modemmenu/main.go index a418bfd..d25285e 100644 --- a/cmd/modemmenu/main.go +++ b/cmd/modemmenu/main.go @@ -51,11 +51,20 @@ func main() { listenAt := flag.String("listen", "", "with -serve: listen on this unix socket `path` (or tcp:host:port) instead of [server] listen") upstreamFlag := flag.Bool("upstream", false, "a session of -serve with trust_user (internal)") authdFlag := flag.Bool("authd", false, "answer the [authd] auth socket alone (with -serve it is answered too)") + setupFlag := flag.Bool("setup", false, "change the configuration on a full screen (the sysop's setup)") flag.Parse() if *hash { os.Exit(printHash()) } + if *setupFlag { + // before the file is loaded: the setup is how one that does not + // load is put right + if s, err := loadSettings(*config); err == nil { + openLog(s.log) + } + os.Exit(runSetupLocal(*config)) + } s, err := loadSettings(*config) if err != nil { fmt.Fprintln(os.Stderr, "modemmenu:", err) @@ -248,8 +257,21 @@ func run(t *term.Term, s *settings, sess *session) int { } lim.mark(sess, time.Now()) - t.Write(config.Expand(s.welcome, sess.vars(false), nil)) - t.Write(motd(s, sess)) + desk := deskFor(t, s, sess) + m := motd(s, sess) + if desk == nil || strings.Contains(m, "\x1b") { + // the full screen has the welcome in its window, and a motd of + // plain text in a box; one with ANSI's colors is shown as it is + t.Write(config.Expand(s.welcome, sess.vars(false), nil)) + t.Write(m) + if desk != nil && m != "" { + if err := pause(t, desk, s, "Press any key to continue"); err != nil { + logf("logout user=%s: %v", sess.User, describe(err)) + return 0 + } + } + m = "" + } var first *menuItem if rule != nil && rule.item != "" { first = s.selects(strings.ToUpper(rule.item)) @@ -257,7 +279,11 @@ func run(t *term.Term, s *settings, sess *session) int { first = nil } } - err = menu(t, s, sess, lim, first) + if desk != nil { + err = fullMenu(t, s, sess, lim, first, desk, m) + } else { + err = menu(t, s, sess, lim, first) + } switch { case err == nil: t.Write(config.Expand(s.goodbye, sess.vars(false), nil)) diff --git a/cmd/modemmenu/serve_many_test.go b/cmd/modemmenu/serve_many_test.go index f2bbf63..776e766 100644 --- a/cmd/modemmenu/serve_many_test.go +++ b/cmd/modemmenu/serve_many_test.go @@ -29,7 +29,7 @@ func startServe(t *testing.T, dir, conf, sock string) { if _, _, err := openPty(); err != nil { t.Skip("no pty:", err) } - srv := exec.Command(bin, "-config", conf, "-serve") + srv := exec.Command(bin, "-config", lineMenu(conf), "-serve") out, err := os.Create(filepath.Join(dir, "serve.out")) if err != nil { t.Fatal(err) diff --git a/cmd/modemmenu/server_test.go b/cmd/modemmenu/server_test.go index 671e7a7..8da48ad 100644 --- a/cmd/modemmenu/server_test.go +++ b/cmd/modemmenu/server_test.go @@ -82,7 +82,7 @@ label = Info command = echo "on $(tty | cut -c1-8) dialed=$MODEM_DIALED rate=$MODEM_RATE date=$MODEM_DATE auth=$MODEM_AUTH user=$MODEM_USER pw=$MODEM_PASSWORD up=$MODEMMENU_UPSTREAM_USER" `, filepath.Join(dir, "server.log"), users, sock)), 0o600) - srv := exec.Command(bin, "-config", conf, "-serve") + srv := exec.Command(bin, "-config", lineMenu(conf), "-serve") if err := srv.Start(); err != nil { t.Fatal(err) } @@ -144,7 +144,7 @@ header = yes `, users, sock)), 0o600) m, s := testPty(t) defer m.Close() - cmd := exec.Command(bin, "-config", front) + cmd := exec.Command(bin, "-config", lineMenu(front)) cmd.Stdin, cmd.Stdout, cmd.Stderr = s, s, s cmd.Env = append(os.Environ(), "DMODEM_RATE=26400/26400", "DMODEM_DIALED=5552000") cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true} diff --git a/cmd/modemmenu/session.go b/cmd/modemmenu/session.go index 325b7a7..f079620 100644 --- a/cmd/modemmenu/session.go +++ b/cmd/modemmenu/session.go @@ -39,6 +39,11 @@ type session struct { TermName string `json:"term_name"` // what it said it is: xterm(390), VT220, SyncTERM (CTerm 1.316) Rows string `json:"rows"` Cols string `json:"cols"` + // the characters it shows: utf8, cp437 (SyncTERM), 8bit (not UTF-8), + // "" not asked + charset string + // a DEC terminal itself (by its DA2), not an emulator: no colors + hardware bool // an upstream menu's deadline, from its session header upstreamDeadline time.Time diff --git a/cmd/modemmenu/settings.go b/cmd/modemmenu/settings.go index 2bf660e..0e89f92 100644 --- a/cmd/modemmenu/settings.go +++ b/cmd/modemmenu/settings.go @@ -41,6 +41,7 @@ type menuItem struct { key string // shown in the menu choices []string // what selects it, upper case: key, name, aliases label string + help string // a line of what it is, in the full-screen menu mode string // exec, tcp, rlogin, telnet command string host string @@ -99,6 +100,8 @@ type settings struct { menuPrompt string hangup []string // what hangs up, upper case; the first one shown hotkeys bool // one key selects, without a line end + fullscreen bool // the menu full screen on terminals that can have it + path string // the configuration file's items []*menuItem backends []auth.Authenticator } @@ -110,7 +113,7 @@ func loadSettings(path string) (*settings, error) { if err != nil { return nil, err } - s := &settings{} + s := &settings{path: path} g := c.Section("general") s.log = g.Get("log", "syslog") s.banner = config.Unescape(g.Get("banner", `\r\nD-Modem\r\n`)) @@ -220,6 +223,13 @@ func loadSettings(path string) (*settings, error) { if s.hotkeys, err = m.Bool("hotkeys", false); err != nil { return nil, err } + switch fs := strings.ToLower(m.Get("fullscreen", "auto")); fs { + case "auto", "yes": + s.fullscreen = true + case "no": + default: + return nil, fmt.Errorf("[menu] fullscreen: %q (auto or no)", fs) + } keys := map[string]string{} for _, h := range s.hangup { keys[h] = "[menu] hangup" @@ -286,6 +296,7 @@ func loadItem(name string, sec *config.Section) (*menuItem, error) { } } it.label = sec.Get("label", name) + it.help = sec.Get("help", "") it.mode = strings.ToLower(sec.Get("mode", "exec")) it.command = sec.Get("command", "") it.host = sec.Get("host", "127.0.0.1") @@ -307,7 +318,7 @@ func loadItem(name string, sec *config.Section) (*menuItem, error) { it.runAs = sec.Get("run_as", "") it.tty = strings.ToLower(sec.Get("tty", "raw")) defAfter := "hangup" - if m := strings.ToLower(sec.Get("mode", "")); m == "passwd" || m == "callers" { + if m := strings.ToLower(sec.Get("mode", "")); m == "passwd" || m == "callers" || m == "setup" { // a password changed is no reason to hang up defAfter = "menu" } @@ -366,6 +377,14 @@ func loadItem(name string, sec *config.Section) (*menuItem, error) { switch it.mode { case "passwd", "callers": // built in: no command, no server + case "setup": + // the configuration changed from the line: for some only + if len(it.access.users) == 0 && len(it.access.groups) == 0 { + return nil, fmt.Errorf("[item %s] mode = setup needs users or groups (the sysops')", name) + } + if it.access.guest { + return nil, fmt.Errorf("[item %s] mode = setup is not for guests", name) + } case "exec": if it.command == "" { return nil, fmt.Errorf("[item %s] mode = exec needs a command", name) @@ -381,7 +400,7 @@ func loadItem(name string, sec *config.Section) (*menuItem, error) { return nil, fmt.Errorf("[item %s] path: %q (an absolute path)", name, it.path) } default: - return nil, fmt.Errorf("[item %s] mode: %q (exec, tcp, unix, telnet, rlogin, passwd or callers)", name, it.mode) + return nil, fmt.Errorf("[item %s] mode: %q (exec, tcp, unix, telnet, rlogin, passwd, callers or setup)", name, it.mode) } if it.tty != "raw" && it.tty != "sane" { return nil, fmt.Errorf("[item %s] tty: %q (raw or sane)", name, it.tty) diff --git a/cmd/modemmenu/setup.go b/cmd/modemmenu/setup.go new file mode 100644 index 0000000..1d71b96 --- /dev/null +++ b/cmd/modemmenu/setup.go @@ -0,0 +1,1001 @@ +// Copyright (C) 2026 D-Modem Authors +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "syscall" + "time" + + "github.com/D-Modem/menu/internal/confdoc" + "github.com/D-Modem/menu/internal/confedit" + "github.com/D-Modem/menu/internal/term" + "github.com/D-Modem/menu/internal/tui" + "github.com/D-Modem/menu/samples" + "golang.org/x/sys/unix" +) + +// The sysop's setup: modemmenu.conf on a full screen, as the BBS setup +// programs of the 1990s had theirs. Each section is a window of its +// keys, with the sample's help for the key under the bar; values are typed +// in a box or picked from a list. Saving checks the file as -check does +// first, and keeps its comments and order: only the lines of the keys +// changed are. modemmenu -setup runs it on the console (or over SSH); +// an [item] with mode = setup on the line of a sysop who calls in. + +// kindGroups: the windows of the first one, under their headings (and +// the menus of the bar); a section the sample has that is in none goes +// under Other +var kindGroups = []struct { + label string + kinds []string +}{ + {"Login", []string{"general", "terminal", "callerid", "number", "guest", "signup"}}, + {"Users", []string{"file", "ldap", "sql", "system", "groups"}}, + {"Menu", []string{"menu", "item", "dropfile"}}, + {"Dial-up", []string{"ppp_pool", "autoppp", "aol"}}, + {"Server", []string{"server", "authd"}}, +} + +// kindTitles: a section's window's title +var kindTitles = map[string]string{ + "general": "Login and messages", "terminal": "Terminal detection", "callerid": "Caller ID", + "number": "Numbers by caller ID", "guest": "Guest account", "signup": "New accounts (sign-up)", + "file": "Users file", "ldap": "LDAP", "sql": "SQL database", "system": "System accounts (PAM)", + "groups": "Groups", "menu": "The menu", "item": "Menu items", "dropfile": "Door drop files", + "ppp_pool": "PPP addresses and DNS", "autoppp": "AutoPPP (dialers)", "aol": "AOL networks", + "server": "Server (-serve)", "authd": "Auth socket (authd)", +} + +// setupUI is a setup session: the file, what it was, the windows up. +type setupUI struct { + d *tui.Desk + path string + doc *confedit.Doc + orig *confedit.Doc // as it was read or last saved + isNew bool // the file is not there yet: the sample to begin with + kinds []*confdoc.Kind + byKind map[string]*confdoc.Kind + stack []*page + flash string // a word for the help box, once + warn bool + size func() (rows, cols int) // the terminal's, if it can change (nil: no) + who string // for the log +} + +// page is a window: the first one, a section's, or a list of the named +// sections of a kind ([item NAME]). +type page struct { + what string // "main", "section", "named" + sec string // the section ("item bbs"), or the kind of the named + kind *confdoc.Kind + l *tui.List +} + +// newSetup: the setup of the file at path (the sample when there is none) +func newSetup(d *tui.Desk, path string) (*setupUI, error) { + u := &setupUI{d: d, path: path, byKind: map[string]*confdoc.Kind{}} + b, err := os.ReadFile(path) + switch { + case errors.Is(err, os.ErrNotExist): + b, u.isNew = []byte(samples.Conf), true + u.flash = path + " is not there yet: this is the sample, saved there with F10." + case err != nil: + return nil, err + } + u.doc, u.orig = confedit.Parse(string(b)), confedit.Parse(string(b)) + u.kinds = confdoc.Parse(samples.Conf) + for _, k := range u.kinds { + u.byKind[k.Name] = k + } + d.Name = "modemmenu Setup" + d.Menus = u.menus() + u.stack = []*page{{what: "main", l: &tui.List{Title: "modemmenu Setup", MinW: 50}}} + return u, nil +} + +// groups: kindGroups, and Other for the sample's sections in none +func (u *setupUI) groups() []struct { + label string + kinds []string +} { + out := append(kindGroups[:0:0], kindGroups...) + in := map[string]bool{} + for _, g := range kindGroups { + for _, k := range g.kinds { + in[k] = true + } + } + var other []string + for _, k := range u.kinds { + if !in[k.Name] { + other = append(other, k.Name) + } + } + if len(other) > 0 { + out = append(out, struct { + label string + kinds []string + }{"Other", other}) + } + return out +} + +// menus: the bar's pull-downs +func (u *setupUI) menus() []tui.Menu { + ms := []tui.Menu{{Label: "File", Items: []tui.MenuItem{ + {Label: "Save F10", ID: "save"}, {Label: "Check", ID: "check"}, + {Label: "Undo all changes", ID: "revert"}, {Heading: true}, {Label: "Quit F9", ID: "quit"}}}} + for _, g := range u.groups() { + m := tui.Menu{Label: g.label} + for _, k := range g.kinds { + if u.byKind[k] != nil { + m.Items = append(m.Items, tui.MenuItem{Label: u.title(k), ID: "kind:" + k}) + } + } + ms = append(ms, m) + } + return append(ms, tui.Menu{Label: "Help", Items: []tui.MenuItem{{Label: "Keys", ID: "keys"}, {Label: "About", ID: "about"}}}) +} + +func (u *setupUI) title(kind string) string { + if t, ok := kindTitles[kind]; ok { + return t + } + return "[" + kind + "]" +} + +// named: the sections of a named kind in the file ("item bbs"...) +func (u *setupUI) named(kind string) []string { + var out []string + for _, s := range u.doc.Sections() { + if strings.HasPrefix(s, kind+" ") { + out = append(out, s) + } + } + return out +} + +// fill: a page's entries from the file as it is now +func (u *setupUI) fill(p *page) { + var items []tui.Entry + switch p.what { + case "main": + for _, g := range u.groups() { + items = append(items, tui.Entry{Heading: true, Label: g.label}) + for _, k := range g.kinds { + kd := u.byKind[k] + if kd == nil { + continue + } + e := tui.Entry{ID: "kind:" + k, Label: u.title(k), Help: kd.Help, Mark: "►"} + switch { + case kd.Named: + n := len(u.named(k)) + e.Value = fmt.Sprintf("%d set", n) + case !u.doc.Has(k): + e.Value, e.Unset = "not set", true + case u.doc.Has(k) && hasKey(kd, "enabled"): + v, ok := u.doc.Get(k, "enabled") + if !ok { + v = kd.ByKey["enabled"].Value + } + e.Value = map[bool]string{true: "on", false: "off"}[yes(v)] + } + e.Changed = u.sectionChanged(k, kd.Named) + items = append(items, e) + } + } + case "named": + for _, s := range u.named(p.kind.Name) { + name := strings.TrimPrefix(s, p.kind.Name+" ") + v, _ := u.doc.Get(s, "label") + if p.kind.Name == "number" { + v, _ = u.doc.Get(s, "user") + } + items = append(items, tui.Entry{ID: "sec:" + s, Label: name, Value: v, Mark: "►", + Changed: u.changed(s, ""), Help: "Enter opens it; Del deletes it."}) + } + items = append(items, tui.Entry{Heading: true}, tui.Entry{ID: "add", Label: "Add a new one…", + Help: "A new [" + p.kind.Name + " NAME]: its name first."}) + case "section": + kd := p.kind + if kd.Free != "" { + for _, k := range u.doc.Keys(p.sec) { + v, _ := u.doc.Get(p.sec, k) + items = append(items, tui.Entry{ID: "key:" + k, Label: k, Value: v, Changed: u.changed(p.sec, k), + Help: "Each key is " + kd.Free + ". Del takes it out."}) + } + items = append(items, tui.Entry{Heading: true}, tui.Entry{ID: "addkey", Label: "Add a new one…", Help: "Each key is " + kd.Free + "."}) + break + } + known := map[string]bool{} + for _, k := range kd.Keys { + known[k.Name] = true + e := u.keyEntry(p.sec, k) + if strings.TrimSpace(k.Help) == "" && kd.Help != "" { + // the section's comment is the key's + e.Help = capFirst(strings.TrimSpace(kd.Help)) + } + items = append(items, e) + } + for _, k := range u.doc.Keys(p.sec) { + if !known[k] { + v, _ := u.doc.Get(p.sec, k) + items = append(items, tui.Entry{ID: "key:" + k, Label: k, Value: v, Changed: u.changed(p.sec, k), + Help: "Not a key of [" + kd.Name + "] that modemmenu knows: a mistake, or for a newer version. Del takes it out."}) + } + } + if kd.Named { + items = append(items, tui.Entry{Heading: true}, + tui.Entry{ID: "rename", Label: "Rename…", Help: "A new name for this [" + p.sec + "]."}, + tui.Entry{ID: "delete", Label: "Delete…", Help: "Take [" + p.sec + "] out of the file."}) + } + } + p.l.Items = items + p.l.Fix() +} + +// keyEntry: a key's line in its section's window +func (u *setupUI) keyEntry(sec string, k *confdoc.Key) tui.Entry { + e := tui.Entry{ID: "key:" + k.Name, Label: k.Name, Changed: u.changed(sec, k.Name)} + if v, ok := u.doc.Get(sec, k.Name); ok { + e.Value = v + if v == "" { + e.Value = `""` + } + } else { + e.Value, e.Unset = "(not set)", true + if k.Value != "" { + e.Note = "sample: " + k.Value + } + } + e.Help = keyHelp(k) + if len(choices(k)) > 1 { + e.Mark = "↕" + } + return e +} + +// keyHelp: a key's help as the sample has it +func keyHelp(k *confdoc.Key) string { + h := strings.TrimSpace(k.Help) + if h == "" { + h = "See the comments in the sample." + } + h = strings.ToUpper(h[:1]) + h[1:] + if !strings.HasSuffix(h, ".") && !strings.HasSuffix(h, ")") { + h += "." + } + if k.Value != "" { + h += " Sample: " + k.Value + } + return h +} + +// choiceRE: a value the help names (confdoc writes them "; `name`: what") +var choiceRE = regexp.MustCompile("`([a-z0-9_]+)`:") + +// choices: the values a key takes, when the sample says (yes and no for +// a yes-or-no; the names its help gives); nil when it is anything typed +func choices(k *confdoc.Key) []string { + if isBool(k.Value) { + return []string{"yes", "no"} + } + var out []string + seen := map[string]bool{} + add := func(v string) { + if v != "" && !seen[v] && !strings.ContainsAny(v, " {}/\\\"") { + seen[v] = true + out = append(out, v) + } + } + for _, m := range choiceRE.FindAllStringSubmatch(k.Help, -1) { + add(m[1]) + } + if len(out) == 0 { + return nil + } + add(k.Value) + for _, e := range k.Examples { + add(e) + } + return out +} + +func isBool(v string) bool { + switch strings.ToLower(v) { + case "yes", "no", "true", "false", "on", "off": + return true + } + return false +} + +func yes(v string) bool { + switch strings.ToLower(v) { + case "yes", "true", "on", "1": + return true + } + return false +} + +func hasKey(k *confdoc.Kind, name string) bool { return k.ByKey[name] != nil } + +// changed: whether a key (or, key "", any of a section) is not as it was +func (u *setupUI) changed(sec, key string) bool { + if key == "" { + if u.doc.Has(sec) != u.orig.Has(sec) { + return true + } + keys := append(u.doc.Keys(sec), u.orig.Keys(sec)...) + for _, k := range keys { + if u.changed(sec, k) { + return true + } + } + return false + } + a, aok := u.doc.Get(sec, key) + b, bok := u.orig.Get(sec, key) + return a != b || aok != bok +} + +// sectionChanged: a section's, or with named any of the kind's +func (u *setupUI) sectionChanged(kind string, named bool) bool { + if !named { + return u.changed(kind, "") + } + all := map[string]bool{} + for _, s := range append(u.doc.Sections(), u.orig.Sections()...) { + if strings.HasPrefix(s, kind+" ") { + all[s] = true + } + } + for s := range all { + if u.changed(s, "") { + return true + } + } + return false +} + +// changes: how many keys are not as they were +func (u *setupUI) changes() int { + n := 0 + secs := map[string]bool{} + for _, s := range append(u.doc.Sections(), u.orig.Sections()...) { + secs[s] = true + } + for s := range secs { + keys := map[string]bool{} + for _, k := range append(u.doc.Keys(s), u.orig.Keys(s)...) { + keys[k] = true + } + for k := range keys { + if u.changed(s, k) { + n++ + } + } + if len(keys) == 0 && u.doc.Has(s) != u.orig.Has(s) { + n++ + } + } + return n +} + +// frame: the desk with the windows up (open: a bar menu pulled down) +func (u *setupUI) frame(open int) *tui.Screen { + d := u.d + scr := d.Blank(open) + for i, p := range u.stack { + p.l.Depth = i + p.l.Draw(d, scr, i == len(u.stack)-1) + } + top := u.stack[len(u.stack)-1] + switch e := top.l.Selected(); { + case u.flash != "": + d.HelpBox(scr, "Note", u.flash, u.warn) + case e != nil: + d.HelpBox(scr, e.Label, e.Help, false) + } + status := "" + if n := u.changes(); n > 0 || u.isNew { + status = fmt.Sprintf("%d unsaved", n) + if u.isNew { + status = "not saved yet" + } + } + keys := tui.Hints("F1", "Help", "Enter", "Change", "Esc", "Back", "/", "Menu") + if top.what == "section" { + keys = append(keys, tui.Hint{Key: "Del", Label: "Default"}) + } + keys = append(keys, tui.Hints("F9", "Quit", "F10", "Save")...) + d.KeyBar(scr, keys, status) + return scr +} + +// run: the setup until the sysop quits (or hangs up: the error) +func (u *setupUI) run() error { + d := u.d + defer d.Close() + for { + if u.size != nil { + if r, c := u.size(); (r != d.Rows || c != d.Cols) && tui.OK(r, c) { + d.Rows, d.Cols = r, c + d.Redraw() + } + } + top := u.stack[len(u.stack)-1] + u.fill(top) + scr := u.frame(-1) + u.flash, u.warn = "", false + if err := d.Show(scr); err != nil { + return err + } + k, alt, err := d.Key(-1) + if errors.Is(err, term.ErrTimeout) && u.size != nil { + continue // at the console: no one to hang up on + } + if err != nil { + return err + } + if alt || k == '/' || k == tui.F(12) { + i := 0 + if alt { + if i = d.MenuKey(k); i < 0 { + continue + } + } + id, err := d.PullDown(u.frame, i) + if err != nil { + return err + } + done, err := u.command(id) + if done || err != nil { + return err + } + continue + } + switch k { + case tui.F(10): + if _, err := u.save(); err != nil { + return err + } + case tui.F(9): + if done, err := u.quit(); done || err != nil { + return err + } + case tui.KeyEsc, tui.KeyLeft: + if len(u.stack) > 1 { + u.stack = u.stack[:len(u.stack)-1] + continue + } + if done, err := u.quit(); done || err != nil { + return err + } + case tui.F(1), '?': + if err := u.help(top); err != nil { + return err + } + case tui.KeyDel, tui.KeyBackspace: + if err := u.remove(top); err != nil { + return err + } + case tui.KeyEnter, tui.KeyRight, ' ': + if err := u.pick(top, k == ' '); err != nil { + return err + } + default: + top.l.Move(d, k) + } + } +} + +// command: a pull-down's item; done: the setup is over +func (u *setupUI) command(id string) (bool, error) { + switch { + case id == "": + case id == "save": + _, err := u.save() + return false, err + case id == "check": + err := checkConf(u.path, u.doc.String()) + if err != nil { + _, err = u.d.Message(u.frame(-1), tui.Alert, "Check", err.Error()) + return false, err + } + u.flash = "The configuration checks: " + u.summary() + case id == "revert": + if u.changes() == 0 { + return false, nil + } + ok, err := u.d.Confirm(u.frame(-1), tui.Alert, "Undo", "Undo every change since the file was read or saved?") + if ok { + u.doc = confedit.Parse(u.orig.String()) + u.stack = u.stack[:1] + } + return false, err + case id == "quit": + return u.quit() + case id == "keys": + _, err := u.d.Message(u.frame(-1), tui.Info, "Keys", setupKeys) + return false, err + case id == "about": + _, err := u.d.Message(u.frame(-1), tui.Info, "About", "modemmenu Setup\n\n"+u.path+ + "\n\nThe help of each key is the sample configuration's. Saving keeps the file's comments and order.") + return false, err + case strings.HasPrefix(id, "kind:"): + u.stack = u.stack[:1] + u.open(strings.TrimPrefix(id, "kind:")) + } + return false, nil +} + +// setupKeys: Help > Keys +const setupKeys = "↑ ↓ PgUp PgDn Home End, or a letter: move the bar.\n" + + "Enter: open a window, change a value (typed, or picked from a list).\n" + + "Space: the next of a value's choices. Del: back to the default.\n" + + "Esc: back a window. / or F12 or Alt and a letter: the menus.\n" + + "F1: the help of the line. F10: save. F9: quit. ^L: draw again." + +// open: the window of a kind (its section's, or its named ones') +func (u *setupUI) open(kind string) { + kd := u.byKind[kind] + if kd == nil { + return + } + if kd.Named { + u.stack = append(u.stack, &page{what: "named", sec: kind, kind: kd, + l: &tui.List{Title: u.title(kind), MinW: 44}}) + return + } + u.stack = append(u.stack, &page{what: "section", sec: kind, kind: kd, + l: &tui.List{Title: "[" + kind + "] " + u.title(kind), MinW: 56}}) +} + +// pick: Enter (or Space: the next choice) on the bar's entry +func (u *setupUI) pick(p *page, space bool) error { + e := p.l.Selected() + if e == nil { + return nil + } + d := u.d + switch { + case strings.HasPrefix(e.ID, "kind:"): + if !space { + u.open(strings.TrimPrefix(e.ID, "kind:")) + } + case strings.HasPrefix(e.ID, "sec:"): + if !space { + s := strings.TrimPrefix(e.ID, "sec:") + u.stack = append(u.stack, &page{what: "section", sec: s, kind: p.kind, + l: &tui.List{Title: "[" + s + "]", MinW: 56}}) + } + case e.ID == "add": + name, ok, err := d.Input(u.frame(-1), "New ["+p.kind.Name+" NAME]", "Its name (one word: letters, digits, - and _"+ + map[bool]string{true: "; for a number, the caller's number", false: ""}[p.kind.Name == "number"]+"):", "", 32, false) + if err != nil || !ok || name == "" { + return err + } + if !nameRE.MatchString(name) || u.doc.Has(p.kind.Name+" "+name) { + u.flash, u.warn = fmt.Sprintf("%q is not a name that can be had: one word, not one there already.", name), true + return nil + } + sec := p.kind.Name + " " + name + u.doc.AddSection(sec) + if p.kind.Name == "item" { + u.doc.Set(sec, "label", name) + if v, ok := u.doc.Get(menuSection, "items"); ok { + u.doc.Set(menuSection, "items", strings.TrimSpace(v+" "+name)) + } + } + u.stack = append(u.stack, &page{what: "section", sec: sec, kind: p.kind, l: &tui.List{Title: "[" + sec + "]", MinW: 56}}) + case e.ID == "addkey": + name, ok, err := d.Input(u.frame(-1), "New key", "Its name (one word):", "", 32, false) + if err != nil || !ok || name == "" { + return err + } + name = strings.ToLower(name) + if !nameRE.MatchString(name) { + u.flash, u.warn = fmt.Sprintf("%q is not one word.", name), true + return nil + } + v, ok, err := d.Input(u.frame(-1), name, p.kind.Free, "", 255, false) + if err != nil || !ok { + return err + } + u.doc.Set(p.sec, name, v) + case e.ID == "rename": + kind := p.kind.Name + old := strings.TrimPrefix(p.sec, kind+" ") + name, ok, err := d.Input(u.frame(-1), "Rename", "A new name for ["+p.sec+"]:", old, 32, false) + if err != nil || !ok || name == "" || name == old { + return err + } + if !nameRE.MatchString(name) || u.doc.Has(kind+" "+name) { + u.flash, u.warn = fmt.Sprintf("%q is not a name that can be had: one word, not one there already.", name), true + return nil + } + u.doc.RenameSection(p.sec, kind+" "+name) + if kind == "item" { + u.renameInItems(old, name) + } + p.sec = kind + " " + name + p.l.Title = "[" + p.sec + "]" + case e.ID == "delete": + ok, err := d.Confirm(u.frame(-1), tui.Alert, "Delete", "Take ["+p.sec+"] out of the file?") + if err != nil || !ok { + return err + } + u.doc.RemoveSection(p.sec) + if p.kind.Name == "item" { + u.renameInItems(strings.TrimPrefix(p.sec, "item "), "") + } + u.stack = u.stack[:len(u.stack)-1] + case strings.HasPrefix(e.ID, "key:"): + return u.edit(p, strings.TrimPrefix(e.ID, "key:"), space) + } + return nil +} + +// nameRE: a section's NAME, a key the sysop names +var nameRE = regexp.MustCompile(`^[A-Za-z0-9_+*#.-]{1,32}$`) + +// renameInItems: an item's name in [menu] items changed (to "": out) +func (u *setupUI) renameInItems(old, name string) { + v, ok := u.doc.Get(menuSection, "items") + if !ok { + return + } + var out []string + for _, f := range strings.Fields(strings.ReplaceAll(v, ",", " ")) { + if f == old { + if name == "" { + continue + } + f = name + } + out = append(out, f) + } + u.doc.Set(menuSection, "items", strings.Join(out, " ")) +} + +// edit: a key's value: picked from its choices, or typed +func (u *setupUI) edit(p *page, key string, space bool) error { + d := u.d + var k *confdoc.Key + if p.kind != nil { + k = p.kind.ByKey[key] + } + if k == nil { + k = &confdoc.Key{Name: key} + } + cur, set := u.doc.Get(p.sec, key) + cs := choices(k) + if len(cs) > 1 { + at := -1 + for i, c := range cs { + if strings.EqualFold(c, cur) { + at = i + } + } + if space { + u.doc.Set(p.sec, key, cs[(at+1)%len(cs)]) + return nil + } + def := "(not set: the default)" + list := append(append([]string{}, cs...), def, "Type a value…") + start := at + if !set { + start = len(cs) + } + i, ok, err := d.Choose(u.frame(-1), key, list, nil, max(start, 0)) + if err != nil || !ok { + return err + } + switch { + case i < len(cs): + u.doc.Set(p.sec, key, cs[i]) + return nil + case i == len(cs): + u.doc.Unset(p.sec, key) + return nil + } + } else if space { + return nil + } + prompt := strings.TrimSpace(k.Help) + if prompt != "" { + prompt = strings.ToUpper(prompt[:1]) + prompt[1:] + } + if len(prompt) > 230 { + prompt = prompt[:229] + "…" + } + if !strings.HasSuffix(prompt, ".") && prompt != "" { + prompt += "." + } + if !set && k.Value != "" { + prompt += " Sample: " + k.Value + } + v, ok, err := d.Input(u.frame(-1), "["+p.sec+"] "+key, prompt+" (Empty: not set, the default; \"\" for nothing.)", cur, 1024, false) + if err != nil || !ok { + return err + } + switch v { + case "": + u.doc.Unset(p.sec, key) + case `""`: + u.doc.Set(p.sec, key, "") + default: + u.doc.Set(p.sec, key, v) + } + return nil +} + +// remove: Del on a key (its default again) or a named section (out) +func (u *setupUI) remove(p *page) error { + e := p.l.Selected() + if e == nil { + return nil + } + switch { + case strings.HasPrefix(e.ID, "key:"): + u.doc.Unset(p.sec, strings.TrimPrefix(e.ID, "key:")) + case strings.HasPrefix(e.ID, "sec:"): + s := strings.TrimPrefix(e.ID, "sec:") + ok, err := u.d.Confirm(u.frame(-1), tui.Alert, "Delete", "Take ["+s+"] out of the file?") + if err != nil || !ok { + return err + } + u.doc.RemoveSection(s) + if p.kind.Name == "item" { + u.renameInItems(strings.TrimPrefix(s, "item "), "") + } + } + return nil +} + +// help: F1, the whole help of the bar's line +func (u *setupUI) help(p *page) error { + e := p.l.Selected() + if e == nil { + return nil + } + text := e.Help + if strings.HasPrefix(e.ID, "key:") && p.kind != nil { + if k := p.kind.ByKey[strings.TrimPrefix(e.ID, "key:")]; k != nil { + text = keyHelp(k) + if len(k.Examples) > 0 { + text += "\n\nAlso: " + strings.Join(k.Examples, ", ") + } + } + } + _, err := u.d.Message(u.frame(-1), tui.Info, e.Label, text) + return err +} + +// summary: what the file sets up, for a check that passed +func (u *setupUI) summary() string { + items := len(u.named("item")) + return fmt.Sprintf("%d menu items.", items) +} + +// save: the file checked and written; whether it was +func (u *setupUI) save() (bool, error) { + text := u.doc.String() + if err := checkConf(u.path, text); err != nil { + i, err := u.d.Message(u.frame(-1), tui.Alert, "Check", "The configuration does not check:\n\n"+err.Error()+ + "\n\nmodemmenu would not start with it.", "Back", "Save anyway") + if err != nil || i != 1 { + return false, err + } + } + if err := writeConf(u.path, text); err != nil { + _, err = u.d.Message(u.frame(-1), tui.Alert, "Save", "Not saved: "+err.Error()) + return false, err + } + logf("setup: %s saved (%d changes) %s", u.path, u.changes(), u.who) + serverChanged := u.changed("server", "") + u.orig, u.isNew = confedit.Parse(text), false + u.flash = "Saved to " + u.path + ". The next call reads it." + if serverChanged { + u.flash += " [server] is read when modemmenu -serve starts: restart it." + } + return true, nil +} + +// quit: whether the setup ends (after asking to save what changed) +func (u *setupUI) quit() (bool, error) { + if u.changes() == 0 && !u.isNew { + return true, nil + } + i, err := u.d.Message(u.frame(-1), tui.Info, "Quit", "Save the changes first?", "Save", "Don't save", "Cancel") + if err != nil { + return true, err + } + switch i { + case 0: + saved, err := u.save() + return saved, err + case 1: + return true, nil + } + return false, nil +} + +// checkConf: whether text loads as a configuration at path would (in a +// file beside it, for its relative paths) +func checkConf(path, text string) error { + f, err := os.CreateTemp(filepath.Dir(path), ".modemmenu-check-*.conf") + if err != nil { + f, err = os.CreateTemp("", "modemmenu-check-*.conf") + if err != nil { + return err + } + } + defer os.Remove(f.Name()) + _, err = f.WriteString(text) + f.Close() + if err != nil { + return err + } + _, err = loadSettings(f.Name()) + if err != nil { + return errors.New(strings.ReplaceAll(err.Error(), f.Name(), path)) + } + return nil +} + +// writeConf: text as the file at path: a new file renamed over it (its +// mode and owner kept), or written in place where its directory takes no +// new file +func writeConf(path, text string) error { + mode := os.FileMode(0o640) + uid, gid := -1, -1 + if st, err := os.Stat(path); err == nil { + mode = st.Mode().Perm() + if sys, ok := st.Sys().(*syscall.Stat_t); ok { + uid, gid = int(sys.Uid), int(sys.Gid) + } + } + f, err := os.CreateTemp(filepath.Dir(path), ".modemmenu-*.conf") + if err != nil { + return os.WriteFile(path, []byte(text), mode) + } + tmp := f.Name() + _, err = f.WriteString(text) + if cerr := f.Close(); err == nil { + err = cerr + } + if err == nil { + err = os.Chmod(tmp, mode) + } + if err != nil { + os.Remove(tmp) + return err + } + if uid >= 0 { + os.Chown(tmp, uid, gid) // as it was, where we may + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return err + } + return nil +} + +// runSetupLocal: modemmenu -setup on the terminal it was started on +func runSetupLocal(path string) int { + t := term.New(os.Stdin, os.Stdout) + defer t.Close() + size := func() (int, int) { + ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ) + if err != nil || ws.Row == 0 || ws.Col == 0 { + return 24, 80 + } + return int(ws.Row), int(ws.Col) + } + rows, cols := size() + if !tui.OK(rows, cols) { + fmt.Fprintf(os.Stderr, "modemmenu: the setup needs 40x14 at least (this is %dx%d)\n", cols, rows) + return 1 + } + d := &tui.Desk{Line: t, Rows: rows, Cols: cols, Charset: localCharset(path), Mono: localMono(), Shade: true} + u, err := newSetup(d, path) + if err != nil { + fmt.Fprintln(os.Stderr, "modemmenu:", err) + return 1 + } + u.size = size + d.Idle = time.Minute // the size looked at again + u.who = "on the console" + if err := u.run(); err != nil { + fmt.Fprintln(os.Stderr, "modemmenu:", err) + return 1 + } + return 0 +} + +// localCharset: [terminal] charset if it says, else UTF-8 where the +// locale is, a VT's lines on a VT, ASCII otherwise +func localCharset(path string) tui.Charset { + if b, err := os.ReadFile(path); err == nil { + if v, ok := confedit.Parse(string(b)).Get("terminal", "charset"); ok { + if cs, ok := tui.ParseCharset(v); ok { + return cs + } + } + } + for _, k := range []string{"LC_ALL", "LC_CTYPE", "LANG"} { + if v := os.Getenv(k); v != "" { + v = strings.ToLower(strings.ReplaceAll(v, "-", "")) + if strings.Contains(v, "utf8") { + return tui.UTF8 + } + break + } + } + switch t := os.Getenv("TERM"); { + case strings.HasPrefix(t, "vt"), strings.HasPrefix(t, "xterm"), strings.HasPrefix(t, "screen"), strings.HasPrefix(t, "tmux"): + return tui.DEC + case t == "linux", t == "ansi", t == "pcansi", strings.HasPrefix(t, "syncterm"): + return tui.CP437 + } + return tui.ASCII +} + +// localMono: a terminal without colors (a VT's TERM) +func localMono() bool { + t := os.Getenv("TERM") + return strings.HasPrefix(t, "vt1") || strings.HasPrefix(t, "vt2") || strings.HasPrefix(t, "vt3") || t == "dumb" +} + +// setupItem: an [item] of mode = setup: the setup on the caller's line +func setupItem(t *term.Term, cfg *settings, it *menuItem, s *session, deadline time.Time) error { + d := deskForSetup(t, cfg, s) + if d == nil { + return t.Write("\r\nThe setup needs an ANSI terminal of 80x24 (40x14 at least).\r\n") + } + u, err := newSetup(d, cfg.path) + if err != nil { + return t.Write("\r\nThe setup cannot read " + cfg.path + ": " + err.Error() + "\r\n") + } + u.who = "user=" + s.User + logf("setup: %s by user=%s", cfg.path, s.User) + if !deadline.IsZero() && time.Until(deadline) < d.Idle { + d.Idle = max(time.Until(deadline), time.Second) + } + err = u.run() + if errors.Is(err, term.ErrTimeout) { + if !deadline.IsZero() && !time.Now().Before(deadline) { + return errTimeLimit + } + return t.Write("\r\nIdle too long: the setup ended (what was not saved is not).\r\n") + } + return err +} + +// deskForSetup: the full screen for the setup, whether or not the menu +// is full screen +func deskForSetup(t *term.Term, cfg *settings, s *session) *tui.Desk { + c := *cfg + c.fullscreen = true + return deskFor(t, &c, s) +} + +// capFirst: s with its first letter a capital +func capFirst(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// menuSection: [menu], whose items = the setup keeps up with the items' +const menuSection = "menu" diff --git a/cmd/modemmenu/terminal.go b/cmd/modemmenu/terminal.go index c3f377a..7392bda 100644 --- a/cmd/modemmenu/terminal.go +++ b/cmd/modemmenu/terminal.go @@ -12,6 +12,7 @@ import ( "github.com/D-Modem/menu/internal/config" "github.com/D-Modem/menu/internal/term" + "github.com/D-Modem/menu/internal/tui" "golang.org/x/sys/unix" ) @@ -20,6 +21,7 @@ type terminalSettings struct { detect bool // ask: ANSI, VT52, an ANSI terminal's identity petscii string // auto (a terminal that is not ANSI may be one), yes (every caller), no timeout time.Duration + charset string // the full screen's lines: auto, utf8, cp437, dec, ascii } // loadTerminal: [terminal], checked @@ -27,6 +29,10 @@ func loadTerminal(sec *config.Section) (terminalSettings, error) { var err error ts := terminalSettings{ petscii: strings.ToLower(sec.Get("petscii", "auto")), + charset: strings.ToLower(sec.Get("charset", "auto")), + } + if _, ok := tui.ParseCharset(ts.charset); !ok && ts.charset != "auto" { + return ts, fmt.Errorf("[terminal] charset: %q (auto, utf8, cp437, dec or ascii)", ts.charset) } if ts.detect, err = sec.Bool("detect", true); err != nil { return ts, err @@ -102,9 +108,26 @@ func detectTerminal(t *term.Term, s *settings, sess *session) error { sess.TermName = n } sess.Term = in.Term() + sess.hardware = in.Hardware() if in.Level() > 0 || in.Emulator() { sess.Terminal = "vt" } + // what its characters are, for the full screen's lines + switch { + case ts.charset != "auto" || !s.fullscreen: + case in.CTerm(): + sess.charset = "cp437" + default: + utf8, ok, err := p.UTF8(ts.timeout) + if err != nil { + return err + } + if ok && utf8 { + sess.charset = "utf8" + } else if ok { + sess.charset = "8bit" + } + } return nil } diff --git a/cmd/modemmenu/terminal_test.go b/cmd/modemmenu/terminal_test.go index c2ce994..5c3ae46 100644 --- a/cmd/modemmenu/terminal_test.go +++ b/cmd/modemmenu/terminal_test.go @@ -224,7 +224,7 @@ func TestDetectPETSCII(t *testing.T) { // startOn: modemmenu on the slave of a pty func startOn(t *testing.T, bin, conf string, s *os.File, env ...string) *exec.Cmd { - cmd := exec.Command(bin, "-config", conf) + cmd := exec.Command(bin, "-config", lineMenu(conf)) cmd.Stdin, cmd.Stdout, cmd.Stderr = s, s, s cmd.Env = append(os.Environ(), env...) cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true} diff --git a/docs/README.md b/docs/README.md index 8dd3eb6..90e5b8f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,8 +2,9 @@ - [Building](building.md): Go, PAM, `make install` - [Running it from app_dmodem](running.md): a program per call, one server on a unix socket, FreePBX -- [Configuration](configuration.md): `modemmenu.conf`, the connect rate, terminals, the message of the day, the menu and its items, guests and sign-up, caller ID, time limits, drop files, last callers, handing callers on +- [Configuration](configuration.md): `modemmenu.conf`, the connect rate, terminals, the message of the day, the menu (full screen or lines) and its items, guests and sign-up, caller ID, time limits, drop files, last callers, handing callers on - [Configuration reference](reference.md): every section and key, from the sample +- [The sysop's setup](setup.md): `modemmenu -setup`, the configuration on a full screen, and on the line for sysops - [Authentication](authentication.md): users file, LDAP, SQL, system accounts (PAM), changing passwords, the auth socket - [PPP dialers](ppp.md): AutoPPP and PAP, the pppd plugin - [AOL callers](aol.md): the classic AOL client dialing in, its network played diff --git a/docs/configuration.md b/docs/configuration.md index c29897c..507467c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -65,7 +65,8 @@ mode = passwd Comments start with `;` or `#`, on a line of their own or after a value (following a space or tab, outside `"quotes"`); `\;` and `\#` are the characters themselves. `modemmenu -check -config FILE` checks a -configuration. +configuration, and `modemmenu -setup -config FILE` changes it on a full +screen ([the sysop's setup](setup.md)). ## The connect rate @@ -171,6 +172,44 @@ menu shows the key, its labels lined up. - `[menu] title`, `prompt` and `items` (the order) set the rest; see the sample. +## The full-screen menu + +A caller whose terminal can have it (ANSI, or a VT that said what it +is, of 40x14 at least) gets the menu full screen: the items in a window +on a blue desk, the welcome at its top, the bar moved with the cursor +keys (or an item's first letter) and Enter, or an item's key in `[ ]` +starting it at once. The motd comes up in a box over it (one with +ANSI's escapes is shown as it is first, then "Press any key"). An item's +`help` is shown under the menu; the bar at the top has the user, the +rate and the time, the one at the foot the minutes left. Esc asks +before hanging up; F1 shows the keys. After a program (`mode = exec`, +`passwd`, `callers`) that comes back to the menu, "Press any key for the +menu" keeps what it wrote on the screen until the caller has read it. + +The login stays lines, as before: PPP dialers (AutoPPP) and AOL +clients read and answer it, and a terminal's answers are not known +until then. Others (VT52, a Commodore, a terminal that answers +nothing) get the menu of lines. + +```ini +[menu] +fullscreen = auto ; no: lines for every caller + +[terminal] +charset = auto ; utf8, cp437, dec or ascii + +[item bbs] +help = Messages, files and doors on the board +``` + +For its lines, modemmenu asks an ANSI terminal whether it shows UTF-8 +(an `é` written and the cursor's place asked); SyncTERM is code page +437. Without UTF-8 a VT or xterm gets its line-drawing set, others code +page 437. Only the cells that change are sent, and below 9600 bit/s the +desk is plain blue (cleared in a few bytes) instead of shaded: the +first screen is about 1 KB at 2400 bit/s, moving the bar a few hundred +bytes. + ## Who sees what diff --git a/docs/reference.md b/docs/reference.md index 20f9a93..f72b0f2 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -38,15 +38,15 @@ The login: the log, the backends, what the caller sees, the time limits. |---|---|---| | `log` | `syslog` | syslog (auth facility), a file, or none. Never the line: stderr is the caller's screen. | | `auth` | `file` | the backends, asked in this order: file, ldap, sql, system (PAM). A backend that does not know the user passes to the next; a wrong password stops there. | -| `banner` | `\r\nWelcome to D-Modem.\r\n` | the backends, asked in this order: file, ldap, sql, system (PAM). A backend that does not know the user passes to the next; a wrong password stops there. | -| `banner_file` | `/etc/asterisk/modemmenu.banner` | the backends, asked in this order: file, ldap, sql, system (PAM). A backend that does not know the user passes to the next; a wrong password stops there. | -| `login_prompt` | `\r\nlogin: ` | the backends, asked in this order: file, ldap, sql, system (PAM). A backend that does not know the user passes to the next; a wrong password stops there. | -| `password_prompt` | `Password: ` | the backends, asked in this order: file, ldap, sql, system (PAM). A backend that does not know the user passes to the next; a wrong password stops there. | +| `banner` | `\r\nWelcome to D-Modem.\r\n` | what the caller sees first (or a file of it, which may have ANSI's colors), then the prompts of the login. | +| `banner_file` | `/etc/asterisk/modemmenu.banner` | what the caller sees first (or a file of it, which may have ANSI's colors), then the prompts of the login. | +| `login_prompt` | `\r\nlogin: ` | what the caller sees first (or a file of it, which may have ANSI's colors), then the prompts of the login. | +| `password_prompt` | `Password: ` | what the caller sees first (or a file of it, which may have ANSI's colors), then the prompts of the login. | | `connect_message` | `\r\nConnected at {speed}.\r\n` | after the banner, before the login prompt: the connect rate ({speed}: "33600 bit/s", or "50666 bit/s down, 31200 bit/s up"); left out when it is not known; "" for none. | -| `welcome` | `\r\nHello {user}, on {date} at {time}.\r\n` | after the banner, before the login prompt: the connect rate ({speed}: "33600 bit/s", or "50666 bit/s down, 31200 bit/s up"); left out when it is not known; "" for none. | +| `welcome` | `\r\nHello {user}, on {date} at {time}.\r\n` | after a login, before the motd ({placeholders}; on the full-screen menu, at the top of its window) | | `motd` | `\r\nThe line is down for maintenance on Sunday.\r\n` | the message of the day, after the welcome and before the menu: motd, then the file (read at each login), {placeholders} in both. | | `motd_file` | `/etc/motd` | the message of the day, after the welcome and before the menu: motd, then the file (read at each login), {placeholders} in both. | -| `goodbye` | `\r\nGoodbye.\r\n` | the message of the day, after the welcome and before the menu: motd, then the file (read at each login), {placeholders} in both. | +| `goodbye` | `\r\nGoodbye.\r\n` | at the end of the call, before the line is hung up. | | `password_echo` | `*` | the echo of each password character, or none. | | `attempts` | `3` | logins before the call is hung up, seconds after a wrong one. | | `fail_delay` | `3` | logins before the call is hung up, seconds after a wrong one. | @@ -56,7 +56,7 @@ The login: the log, the backends, what the caller sees, the time limits. | `max_input` | `64` | the longest line the caller may type at a prompt: a user name or a menu choice (a password twice as long) | | `time_limit` | `0` | time limits (0: none), from the login: a call, and a user's calls in a day (kept in state_dir/usage). A selection is hung up at the limit. {time_left} (whole minutes) shows what is left, e.g. in [menu] prompt. | | `daily_limit` | `0` | time limits (0: none), from the login: a call, and a user's calls in a day (kept in state_dir/usage). A selection is hung up at the limit. {time_left} (whole minutes) shows what is left, e.g. in [menu] prompt. | -| `state_dir` | `/var/lib/modemmenu` | time limits (0: none), from the login: a call, and a user's calls in a day (kept in state_dir/usage). A selection is hung up at the limit. {time_left} (whole minutes) shows what is left, e.g. in [menu] prompt. | +| `state_dir` | `/var/lib/modemmenu` | where the usage, the last callers and the nodes are kept. | | `time_warning` | `5m 1m` | these long before the limit, the message is written on the line in the middle of a running selection (not one with time_warning = no), once for each: 5m 1m warns at 5 minutes and at 1 minute left; 0: none. {time_left} is in minutes, {seconds_left} in seconds; \a beeps. | | `time_warning_message` | `\r\n\r\n*** {time_left} minute(s) left in this call ***\r\n` | these long before the limit, the message is written on the line in the middle of a running selection (not one with time_warning = no), once for each: 5m 1m warns at 5 minutes and at 1 minute left; 0: none. {time_left} is in minutes, {seconds_left} in seconds; \a beeps. | @@ -69,6 +69,7 @@ the caller's terminal, asked before the banner: ANSI (and its size), VT52, what | `detect` | `yes` | See the section above. | | `petscii` | `auto` | auto: watch the login for a Commodore's keys; yes: every caller is one. | | `timeout` | `2s` | for each question a terminal may not answer. | +| `charset` | `auto` | the full-screen menu's lines and shading: auto (UTF-8 when the terminal shows it, code page 437 for SyncTERM and other BBS terminals, a VT's line-drawing set for a DEC terminal), utf8, cp437, dec or ascii. | ## `[groups]` @@ -263,6 +264,7 @@ The menu: its title, prompt, items and the words that hang up. | `items` | `bbs ppp shell passwd` | the items in order (default: every [item ...] in file order) | | `hangup` | `G goodbye bye logoff` | the caller types a choice and Enter: an item's key, its name or one of its aliases, in any case (P, p, ppp). ? shows the menu again. What hangs up (the first one is shown in the menu): | | `hotkeys` | `no` | yes: one key selects at once, without Enter (one-character keys only) | +| `fullscreen` | `auto` | auto: the menu full screen for a caller whose terminal can have it (ANSI, or a VT that said what it is, 40x14 at least): the items in a window on a blue desk, chosen with the cursor keys or an item's key, the welcome at its top and the motd in a box; others get the menu of lines. no: lines for every caller. The login is always lines (dialers and AOL clients read and answer it) | ## `[item NAME]` @@ -272,10 +274,11 @@ There may be any number of these, each with its own NAME. | key | sample | what it does | |---|---|---| -| `key` | `B` | its key in the menu, one or more characters (default: its name's first letter) Also: `P`, `PW`, `S`, `M`, `L`, `LC`, `U`, `K`, `W`, `N`, `C`, `GO`. | +| `key` | `B` | its key in the menu, one or more characters (default: its name's first letter) Also: `P`, `PW`, `S`, `M`, `L`, `LC`, `Z`, `U`, `K`, `W`, `N`, `C`, `GO`. | | `aliases` | `...` | more words that select it (its name always does) Also: `dialup net`. | -| `label` | `...` | its line in the menu. Also: `Bulletin board`, `PPP (dial-up networking)`, `Change your password`, `Shell`, `More services`, `The BBS`, `Legend of the Red Dragon`, `Last callers`, `Upload a file (ZMODEM)`, `Kermit file server`, `The web (lynx)`, `Mail`, `Usenet news`, `Chat (IRC)`, `Gopherspace`. | -| `mode` | `exec` | a program on the line: command (by /bin/sh -c); `passwd`: built in: the caller changes their password (min_password = 8 characters); `callers`: built in: the last callers (count = 10), the time, user and rate of each login (state_dir/callers, the latest 200; kept only with such an item); `tcp`: a TCP server, raw: host, port; send is sent first; `unix`: a server on a unix socket, raw: path; send too (tcp, telnet and rlogin take a path as well, for their protocol on a unix socket); `telnet`: a telnet server: host, port (23), terminal (ANSI); `rlogin`: an rlogin server: host, port (513), and rlogin_client_user ({password}), rlogin_server_user ({user}), rlogin_terminal (ansi/{baud}) Also: `rlogin`, `tcp`, `passwd`, `unix`, `callers`. | +| `label` | `...` | its line in the menu. Also: `Bulletin board`, `PPP (dial-up networking)`, `Change your password`, `Shell`, `More services`, `The BBS`, `Legend of the Red Dragon`, `Last callers`, `Setup`, `Upload a file (ZMODEM)`, `Kermit file server`, `The web (lynx)`, `Mail`, `Usenet news`, `Chat (IRC)`, `Gopherspace`. | +| `help` | `...` | a line of what it is, under the full-screen menu. Also: `Messages, files and doors on the board`. | +| `mode` | `exec` | a program on the line: command (by /bin/sh -c); `passwd`: built in: the caller changes their password (min_password = 8 characters); `callers`: built in: the last callers (count = 10), the time, user and rate of each login (state_dir/callers, the latest 200; kept only with such an item); `setup`: built in: the sysop's setup, modemmenu.conf on full screens (as modemmenu -setup); needs users or groups; `tcp`: a TCP server, raw: host, port; send is sent first; `unix`: a server on a unix socket, raw: path; send too (tcp, telnet and rlogin take a path as well, for their protocol on a unix socket); `telnet`: a telnet server: host, port (23), terminal (ANSI); `rlogin`: an rlogin server: host, port (513), and rlogin_client_user ({password}), rlogin_server_user ({user}), rlogin_terminal (ansi/{baud}) Also: `rlogin`, `tcp`, `passwd`, `unix`, `callers`, `setup`. | | `run_as` | `{user}` | exec as this system user (modemmenu must be root) | | `tty` | `raw` | the line as app_dmodem has it; sane: cooked, echo, CR to LF, for programs that want a terminal. Also: `sane`. | | `pass_password` | `yes` | the password in {password} and MODEM_PASSWORD. Also: `no`. | diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..0512900 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,74 @@ +# The sysop's setup + +`modemmenu -setup` changes `modemmenu.conf` on a full screen, as the BBS +setup programs of the 1990s did: a menu bar of pull-down menus, a blue +desk, a window for each section cascading over the one it was opened +from, the help of the line under the bar in a box below, and the keys on +a bar at the foot. + +![The full-screen menu and the setup](https://raw.githubusercontent.com/D-Modem/menu/main/.github/screens.png) + +``` +modemmenu -setup -config /etc/asterisk/modemmenu.conf +``` + +- The first window has every section, under Login, Users, Menu, + Dial-up and Server (the same as the bar's menus). Enter opens one: its + keys and their values, `(not set)` and the sample's value for those the + file leaves out. +- **Enter** changes the value under the bar: typed in a box (cursor keys, + Home, End, Del, ^U), or picked from a list for a key the sample gives + the choices of (`yes`/`no`, `petscii = auto|yes|no`...). **Space** steps + through the choices; **Del** takes the key out (its default again). An + empty box is "not set"; type `""` for an empty value. +- `[item NAME]` and `[number NAME]` are lists: add one, open it, rename + it or delete it. An item renamed or deleted is renamed or taken out of + `[menu] items` too. `[groups]` takes groups of the sysop's own names. +- **F1** shows the whole help of the line, **/** (or F12, or Alt and a + letter) the menus, **Esc** goes back a window, **F10** saves, **F9** + quits (asking to save what changed). `^L` draws the screen again. +- The help of every key is the sample's ([reference](reference.md)), built + into the program. + +Saving checks the file first, as `modemmenu -check` does; a file that +does not check is saved only when asked again ("Save anyway"). The file +keeps its comments, blank lines and order: a changed key is changed on +its own line (a comment after it kept), a new key goes after the last of +its section, a new section at the end. The file is written beside the +old one and renamed over it, its mode and owner kept. Each call reads +the file anew, so the next call has the changes; `[server]` is read when +`modemmenu -serve` starts. + +With no file yet, the setup starts from the sample and saves it there. + +## On the line + +A sysop who calls in can have the setup on the menu, as an item of +`mode = setup`: + +```ini +[item setup] +key = Z +label = Setup +mode = setup +groups = sysops +``` + +It must have `users` or `groups` (modemmenu refuses one without), and is +never a guest's. It needs an ANSI terminal of 40x14 at least (80x24 is +better); others are told so. The idle timeout and the call's time limit +hold in it; what was not saved when the call ends is lost. The file is +written as the user modemmenu runs as, which must be able to write it. + +## Terminals + +The screens are drawn in UTF-8 where the terminal shows it, in code page +437 for SyncTERM and the ANSI terminals of BBSs, in a VT's line-drawing +set (`ESC ( 0`) for a VT or xterm without UTF-8, and in ASCII when told +(`[terminal] charset`). A DEC terminal itself (no colors) gets bold and +reverse for the colors. Only the cells that change are sent: moving +the bar costs a hundred bytes or two. At the console, `-setup` takes +UTF-8 from the locale (`LANG`), else the VT set for `TERM=vt*` or +`xterm*`, code page 437 for `TERM=linux` or `ansi`. + +**See also:** [Configuration](configuration.md#the-full-screen-menu) diff --git a/internal/confdoc/confdoc.go b/internal/confdoc/confdoc.go new file mode 100644 index 0000000..98a67a9 --- /dev/null +++ b/internal/confdoc/confdoc.go @@ -0,0 +1,199 @@ +// Copyright (C) 2026 D-Modem Authors +// SPDX-License-Identifier: AGPL-3.0-only + +// Package confdoc reads what samples/modemmenu.conf.sample says of each +// section and key in its comments: the help, the sample's value and the +// other values it shows. tools/confref makes the configuration reference +// of it, and modemmenu -setup the help of its screens. +package confdoc + +import ( + "regexp" + "strings" +) + +// Kind is a kind of section: [general], [item NAME] ... +type Kind struct { + Name string + Named bool + Help string + Free string // what its keys are, when they are the sysop's own names + Keys []*Key + ByKey map[string]*Key +} + +// Key is one key of a kind of section. +type Key struct { + Name string + Help string + Value string // the sample's (or its first commented example's) + Examples []string // the other values the sample shows +} + +var ( + headerRE = regexp.MustCompile(`^;?\[([a-z_]+)(?:\s+(\S+))?\]$`) + keyRE = regexp.MustCompile(`^(;?)([a-z][a-z0-9_]*)\s*=\s*(.*)$`) + docKeyRE = regexp.MustCompile(`^;\s{2,4}([a-z][a-z0-9_]*) = (\S+)\s*(.*)$`) + docMoreRE = regexp.MustCompile(`^;\s{5,}(.*)$`) + // a value of a described key and what it does ("passwd built in") + docValueRE = regexp.MustCompile(`^([a-z0-9_]+)\s{2,}(\S.*)$`) +) + +// Unquote is a sample value as the sysop writes it: without its quotes. +func Unquote(v string) string { + if i := strings.Index(v, " ; "); i >= 0 && !strings.HasPrefix(v, `"`) { + v = v[:i] + } + v = strings.TrimSpace(v) + if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { + return v[1 : len(v)-1] + } + return v +} + +// kinds' help where the sample has none before them, or has the first +// example's +var kindHelp = map[string]string{ + "general": "The login: the log, the backends, what the caller sees, the time limits.", + "menu": "The menu: its title, prompt, items and the words that hang up.", + "item": "A menu item: a program on the line, a server to connect the caller to, or a built-in (see the item keys below).", +} + +// kinds whose keys are the sysop's own names, and what such a key is +var freeKinds = map[string]string{ + "groups": "a group: its name, then its members (`sysops = alice bob`)", +} + +func Parse(sample string) []*Kind { + var kinds []*Kind + byName := map[string]*Kind{} + get := func(name string) *Kind { + if k := byName[name]; k != nil { + return k + } + k := &Kind{Name: name, ByKey: map[string]*Key{}} + byName[name] = k + kinds = append(kinds, k) + return k + } + var cur *Kind + var prose []string + keyOf := func(k *Kind, name string) *Key { + if x := k.ByKey[name]; x != nil { + return x + } + x := &Key{Name: name} + k.ByKey[name] = x + k.Keys = append(k.Keys, x) + return x + } + var doc *Key // the key an indented doc line describes + var pending []*Key // described keys not yet given to a kind + adopt := func(k *Kind) { + for _, d := range pending { + x := keyOf(k, d.Name) + if x.Value == "" { + x.Value = d.Value + } + if x.Help == "" { + x.Help = d.Help + } + } + pending = nil + } + docBlock := false + inHeader := true // the file's own comment before the first section + afterKey := false // a comment now starts the help of the keys after it + for _, raw := range strings.Split(sample, "\n") { + line := strings.TrimRight(raw, " \t\r") + if line == "" { + prose, doc, docBlock, afterKey = nil, nil, false, false + continue + } + if m := headerRE.FindStringSubmatch(line); m != nil { + inHeader = false + cur = get(m[1]) + adopt(cur) + if m[2] != "" { + cur.Named = true + } + if cur.Help == "" && len(prose) > 0 { + cur.Help = strings.Join(prose, " ") + } + prose, doc, afterKey = nil, nil, false + continue + } + if inHeader { + continue + } + // an indented description of keys ("; key = B its key"): of + // the kind of section whose keys or header come next + if m := docKeyRE.FindStringSubmatch(line); m != nil && (docBlock || len(prose) > 0) { + docBlock = true + doc = &Key{Name: m[1], Value: Unquote(m[2]), Help: m[3]} + pending = append(pending, doc) + continue + } + if m := docMoreRE.FindStringSubmatch(line); m != nil && doc != nil { + more := m[1] + if v := docValueRE.FindStringSubmatch(more); v != nil { + more = "; `" + v[1] + "`: " + v[2] + } + sep := " " + if strings.HasPrefix(more, ";") { + sep = "" + } + doc.Help = strings.TrimSpace(doc.Help + sep + more) + continue + } + if m := keyRE.FindStringSubmatch(line); m != nil && cur != nil { + adopt(cur) + x := keyOf(cur, m[2]) + v := Unquote(m[3]) + if x.Help == "" && len(prose) > 0 { + x.Help = strings.Join(prose, " ") + } + if x.Value == "" && m[1] == "" { + x.Value = v + } else if v != "" && v != x.Value { + seen := false + for _, e := range x.Examples { + seen = seen || e == v + } + if !seen { + x.Examples = append(x.Examples, v) + } + } + afterKey = true + continue + } + if strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") { + t := strings.TrimSpace(strings.TrimLeft(line, ";#")) + if doc != nil || docBlock || afterKey { + // prose after a block of key descriptions starts afresh + prose, doc, docBlock, afterKey = nil, nil, false, false + } + prose = append(prose, t) + } + } + if cur != nil { + adopt(cur) + } + for _, k := range kinds { + if h, ok := kindHelp[k.Name]; ok { + k.Help = h + } + if f, ok := freeKinds[k.Name]; ok { + k.Free, k.Keys = f, nil + } + } + // a key with no value of its own: its first example is it + for _, k := range kinds { + for _, x := range k.Keys { + if x.Value == "" && len(x.Examples) > 0 { + x.Value, x.Examples = x.Examples[0], x.Examples[1:] + } + } + } + return kinds +} diff --git a/internal/confedit/confedit.go b/internal/confedit/confedit.go new file mode 100644 index 0000000..c2206e6 --- /dev/null +++ b/internal/confedit/confedit.go @@ -0,0 +1,301 @@ +// Copyright (C) 2026 D-Modem Authors +// SPDX-License-Identifier: AGPL-3.0-only + +// Package confedit changes modemmenu.conf as a sysop would in an editor: +// a key's value on its own line (its comment after it kept), a new key +// after the last of its section, a new section at the end. The comments, +// the blank lines and the order of everything else stay as they are. +package confedit + +import ( + "strings" + + "github.com/D-Modem/menu/internal/config" +) + +// Doc is a configuration file's lines. +type Doc struct { + lines []string +} + +// Parse is a Doc of a file's text. +func Parse(text string) *Doc { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.TrimSuffix(text, "\n") + d := &Doc{} + if text != "" { + d.lines = strings.Split(text, "\n") + } + return d +} + +// String is the file's text. +func (d *Doc) String() string { + if len(d.lines) == 0 { + return "" + } + return strings.Join(d.lines, "\n") + "\n" +} + +// header is the section a line opens ("" and false: none). +func header(line string) (string, bool) { + t := strings.TrimSpace(line) + if t == "" || t[0] != '[' { + return "", false + } + end := strings.IndexByte(t, ']') + if end < 0 { + return "", false + } + return strings.Join(strings.Fields(t[1:end]), " "), true +} + +// keyLine is a line's key and its raw value (after =), ok false for a +// comment, a blank line or a header. +func keyLine(line string) (key, raw string, ok bool) { + t := strings.TrimSpace(line) + if t == "" || t[0] == ';' || t[0] == '#' || t[0] == '[' { + return "", "", false + } + eq := strings.IndexByte(line, '=') + if eq < 0 { + return "", "", false + } + return strings.ToLower(strings.TrimSpace(line[:eq])), line[eq+1:], true +} + +// value is a raw value as the configuration reads it. +func value(raw string) string { + v := strings.TrimSpace(config.StripComment(raw)) + if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { + v = v[1 : len(v)-1] + } + return v +} + +// span is where a section is: its header's line, and the line after its +// last key (or after the header). +type span struct{ header, lastKey int } + +// spans are each [name] of the file, in order (a name may come more than +// once: the configuration takes them as one). +func (d *Doc) spans(name string) []span { + var out []span + cur := "" + in := false + for i, l := range d.lines { + if h, ok := header(l); ok { + cur, in = h, h == name + if in { + out = append(out, span{i, i + 1}) + } + continue + } + if _, _, ok := keyLine(l); ok && in && cur == name { + out[len(out)-1].lastKey = i + 1 + } + } + return out +} + +// Sections are the sections' names, in the order they first come. +func (d *Doc) Sections() []string { + var out []string + seen := map[string]bool{} + for _, l := range d.lines { + if h, ok := header(l); ok && !seen[h] { + seen[h] = true + out = append(out, h) + } + } + return out +} + +// Has is whether the file has [name]. +func (d *Doc) Has(name string) bool { return len(d.spans(name)) > 0 } + +// find is the line of a section's key (its last, which is the one that +// counts), -1 none. +func (d *Doc) find(section, key string) int { + at := -1 + for _, sp := range d.spans(section) { + for i := sp.header + 1; i < len(d.lines); i++ { + if _, ok := header(d.lines[i]); ok { + break + } + if k, _, ok := keyLine(d.lines[i]); ok && k == key { + at = i + } + } + } + return at +} + +// Keys are a section's keys, in file order, each once. +func (d *Doc) Keys(section string) []string { + var out []string + seen := map[string]bool{} + for _, sp := range d.spans(section) { + for i := sp.header + 1; i < len(d.lines); i++ { + if _, ok := header(d.lines[i]); ok { + break + } + if k, _, ok := keyLine(d.lines[i]); ok && !seen[k] { + seen[k] = true + out = append(out, k) + } + } + } + return out +} + +// Get is a key's value, and whether it is set. +func (d *Doc) Get(section, key string) (string, bool) { + i := d.find(section, key) + if i < 0 { + return "", false + } + _, raw, _ := keyLine(d.lines[i]) + return value(raw), true +} + +// Quote is v as it is written after "key = ": in quotes when it would +// not read back the same without. +func Quote(v string) string { + if v == "" { + return `""` + } + if strings.TrimSpace(v) != v || value(v) != v { + return `"` + v + `"` + } + return v +} + +// commentAt is where a raw value's comment begins (len(raw): none). +func commentAt(raw string) int { + quoted := false + for i := 0; i < len(raw); i++ { + c := raw[i] + switch { + case c == '\\' && i+1 < len(raw) && (raw[i+1] == ';' || raw[i+1] == '#'): + i++ + case c == '"': + quoted = !quoted + case (c == ';' || c == '#') && !quoted && (i == 0 || raw[i-1] == ' ' || raw[i-1] == '\t'): + // the spaces before the comment go with it + j := i + for j > 0 && (raw[j-1] == ' ' || raw[j-1] == '\t') { + j-- + } + return j + } + } + return len(raw) +} + +// Set gives a key a value: on its line if it has one (its comment kept, +// and its quotes if it had them), else on a new line after the section's +// last key (the section added at the end if there is none). +func (d *Doc) Set(section, key, v string) { + q := Quote(v) + if i := d.find(section, key); i >= 0 { + l := d.lines[i] + eq := strings.IndexByte(l, '=') + raw := l[eq+1:] + c := commentAt(raw) + old := strings.TrimSpace(raw[:c]) + if strings.HasPrefix(old, `"`) && !strings.HasPrefix(q, `"`) { + q = `"` + v + `"` + } + lead := " " + if len(raw) > 0 && raw[0] != ' ' && raw[0] != '\t' { + lead = "" + } + d.lines[i] = l[:eq+1] + lead + q + raw[c:] + return + } + sps := d.spans(section) + if len(sps) == 0 { + d.AddSection(section) + sps = d.spans(section) + } + at := sps[len(sps)-1].lastKey + d.insert(at, key+" = "+q) +} + +// Unset takes a key out (every line of it): its default again. +func (d *Doc) Unset(section, key string) { + for { + i := d.find(section, key) + if i < 0 { + return + } + d.lines = append(d.lines[:i], d.lines[i+1:]...) + } +} + +// AddSection puts [name] at the end, after a blank line. +func (d *Doc) AddSection(name string) { + if d.Has(name) { + return + } + if n := len(d.lines); n > 0 && strings.TrimSpace(d.lines[n-1]) != "" { + d.lines = append(d.lines, "") + } + d.lines = append(d.lines, "["+name+"]") +} + +// RemoveSection takes out [name] and its keys, and the comments among +// them; the comment just before the next section stays with that one. +func (d *Doc) RemoveSection(name string) { + for { + sps := d.spans(name) + if len(sps) == 0 { + return + } + sp := sps[len(sps)-1] + end := len(d.lines) + for i := sp.header + 1; i < len(d.lines); i++ { + if _, ok := header(d.lines[i]); ok { + end = i + break + } + } + // the next section's own comment and the blank line before it + if end < len(d.lines) { + for end > sp.header+1 && isComment(d.lines[end-1]) { + end-- + } + } + for end > sp.header+1 && strings.TrimSpace(d.lines[end-1]) == "" { + end-- + } + start := sp.header + // its own comment, just above it + for start > 0 && isComment(d.lines[start-1]) { + start-- + } + if start > 0 && strings.TrimSpace(d.lines[start-1]) == "" && (end >= len(d.lines) || strings.TrimSpace(d.lines[end]) == "") { + start-- + } + d.lines = append(d.lines[:start], d.lines[end:]...) + } +} + +// RenameSection gives [old] (each of its headers) a new name. +func (d *Doc) RenameSection(old, name string) { + for _, sp := range d.spans(old) { + d.lines[sp.header] = "[" + name + "]" + } +} + +func isComment(l string) bool { + t := strings.TrimSpace(l) + return t != "" && (t[0] == ';' || t[0] == '#') +} + +func (d *Doc) insert(at int, line string) { + d.lines = append(d.lines, "") + copy(d.lines[at+1:], d.lines[at:]) + d.lines[at] = line +} diff --git a/internal/confedit/confedit_test.go b/internal/confedit/confedit_test.go new file mode 100644 index 0000000..dde68ce --- /dev/null +++ b/internal/confedit/confedit_test.go @@ -0,0 +1,134 @@ +// Copyright (C) 2026 D-Modem Authors +// SPDX-License-Identifier: AGPL-3.0-only + +package confedit + +import ( + "os" + "path/filepath" + "testing" + + "github.com/D-Modem/menu/internal/config" +) + +const sample = `; the file's comment + +[general] +; the log +log = syslog ; or a file +banner = "\r\nHi\r\n" +attempts=3 + +; the menu +[menu] +title = Main + +; a BBS +[item bbs] +key = B +label = Board + +; the shell +[item shell] +key = S +` + +// TestSet: values changed in place with their comments and quotes, new +// keys after their section's last, new sections at the end +func TestSet(t *testing.T) { + d := Parse(sample) + d.Set("general", "log", "/var/log/modemmenu.log") + d.Set("general", "banner", "Hello") + d.Set("general", "attempts", "5") + d.Set("general", "motd", " spaced ") + d.Set("menu", "hotkeys", "yes") + d.Set("aol", "enabled", "yes") + d.Set("item bbs", "help", "a; b") + want := `; the file's comment + +[general] +; the log +log = /var/log/modemmenu.log ; or a file +banner = "Hello" +attempts=5 +motd = " spaced " + +; the menu +[menu] +title = Main +hotkeys = yes + +; a BBS +[item bbs] +key = B +label = Board +help = a; b + +; the shell +[item shell] +key = S + +[aol] +enabled = yes +` + if got := d.String(); got != want { + t.Errorf("got\n%s\nwant\n%s", got, want) + } + // and the configuration reads what was set + p := filepath.Join(t.TempDir(), "c.conf") + os.WriteFile(p, []byte(d.String()), 0o600) + c, err := config.Parse(p) + if err != nil { + t.Fatal(err) + } + for _, kv := range [][3]string{{"general", "log", "/var/log/modemmenu.log"}, {"general", "motd", " spaced "}, + {"general", "banner", "Hello"}, {"item bbs", "help", "a; b"}, {"aol", "enabled", "yes"}} { + if v := c.Section(kv[0]).Get(kv[1], "?"); v != kv[2] { + t.Errorf("[%s] %s = %q, want %q", kv[0], kv[1], v, kv[2]) + } + if v, _ := d.Get(kv[0], kv[1]); v != kv[2] { + t.Errorf("Get [%s] %s = %q, want %q", kv[0], kv[1], v, kv[2]) + } + } + for _, v := range []string{"a ; b", "x # y", `"q"`, ""} { + d.Set("general", "motd", v) + os.WriteFile(p, []byte(d.String()), 0o600) + c, _ := config.Parse(p) + if got := c.Section("general").Get("motd", "?"); got != v { + t.Errorf("motd %q read back as %q", v, got) + } + } +} + +// TestRemove: a key and a section out, the next one's comment kept +func TestRemove(t *testing.T) { + d := Parse(sample) + d.Unset("general", "attempts") + d.RemoveSection("item bbs") + d.RenameSection("item shell", "item sh") + want := `; the file's comment + +[general] +; the log +log = syslog ; or a file +banner = "\r\nHi\r\n" + +; the menu +[menu] +title = Main + +; the shell +[item sh] +key = S +` + if got := d.String(); got != want { + t.Errorf("got\n%s\nwant\n%s", got, want) + } + d.RemoveSection("item sh") + if got := d.String(); got != "; the file's comment\n\n[general]\n; the log\nlog = syslog ; or a file\nbanner = \"\\r\\nHi\\r\\n\"\n\n; the menu\n[menu]\ntitle = Main\n" { + t.Errorf("got\n%s", got) + } + if s := d.Sections(); len(s) != 2 || s[1] != "menu" { + t.Errorf("sections %q", s) + } +} diff --git a/internal/term/detect.go b/internal/term/detect.go index b246ed2..c7b559d 100644 --- a/internal/term/detect.go +++ b/internal/term/detect.go @@ -365,3 +365,30 @@ func (p *Prober) Identify(timeout time.Duration) (VTInfo, error) { p.keep(got) return in, nil } + +// UTF8: whether the terminal shows UTF-8: é written as its two bytes, +// and where the cursor went (one cell on, or two for a terminal of 8-bit +// characters), wiped after. ok is false when it did not answer. +func (p *Prober) UTF8(timeout time.Duration) (utf8, ok bool, err error) { + if err := p.T.WriteRaw("\r\xc3\xa9\x1b[6n"); err != nil { + return false, false, err + } + got, err := p.collect(timeout, 64, cprPattern.Match) + if err != nil { + return false, false, err + } + m := cprPattern.FindSubmatch(got) + p.keep(got) + if err := p.T.WriteRaw("\r \r"); err != nil { + return false, false, err + } + if m == nil { + return false, false, nil + } + col, _ := strconv.Atoi(string(m[2])) + return col == 2, true, nil +} + +// CTerm: whether it said it is SyncTERM (whose characters are code page +// 437's) +func (in VTInfo) CTerm() bool { return in.cterm != "" } diff --git a/internal/tui/charset.go b/internal/tui/charset.go new file mode 100644 index 0000000..422ab30 --- /dev/null +++ b/internal/tui/charset.go @@ -0,0 +1,149 @@ +// Copyright (C) 2026 D-Modem Authors +// SPDX-License-Identifier: AGPL-3.0-only + +package tui + +import ( + "strings" + "unicode/utf8" +) + +// Charset is what the terminal shows the screen's lines and shading in. +type Charset int + +const ( + UTF8 Charset = iota // as they are + CP437 // the IBM PC's code page: what BBS terminals have + DEC // a VT's line-drawing set (ESC ( 0), the rest ASCII + ASCII // + - | and letters for the arrows +) + +// ParseCharset is a charset by its name: utf8, cp437, dec, ascii. +func ParseCharset(s string) (Charset, bool) { + switch strings.ToLower(strings.ReplaceAll(s, "-", "")) { + case "utf8": + return UTF8, true + case "cp437", "ibm", "pc", "437": + return CP437, true + case "dec", "vt", "vt100": + return DEC, true + case "ascii": + return ASCII, true + } + return UTF8, false +} + +func (c Charset) String() string { + return [...]string{"utf8", "cp437", "dec", "ascii"}[c] +} + +// cp437High is code page 437 from 0x80 to 0xFF. +const cp437High = "ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»" + + "░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀" + + "αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ " + +var cp437 = func() map[rune]byte { + m := map[rune]byte{} + i := 0x80 + for _, r := range cp437High { + m[r] = byte(i) + i++ + } + // its glyphs in the control range (► ▲ ▼...) are not sent: some + // terminals take them as controls and the columns after slip + m['►'], m['↕'], m['…'] = 0xaf, 0xf0, 0xaf + return m +}() + +// what the PC's code page has no place for that the screens draw +var plain = map[rune]string{ + '◄': "<", '↑': "^", '↓': "v", '→': ">", '←': "<", '…': "~", '►': ">", + '↕': "|", '▲': "^", '▼': "v", '•': "*", '✓': "*", +} + +// the VT's line-drawing characters (in ESC ( 0), double lines as single +var decGraphics = map[rune]byte{ + '┘': 'j', '┐': 'k', '┌': 'l', '└': 'm', '┼': 'n', '─': 'q', '├': 't', '┤': 'u', '┴': 'v', '┬': 'w', '│': 'x', + '╝': 'j', '╗': 'k', '╔': 'l', '╚': 'm', '═': 'q', '║': 'x', '╟': 't', '╢': 'u', + '░': 'a', '▒': 'a', '▓': 'a', '·': '~', '°': 'f', '±': 'g', '≤': 'y', '≥': 'z', '£': '}', +} + +// and as ASCII +var asciiLines = map[rune]byte{ + '┘': '+', '┐': '+', '┌': '+', '└': '+', '┼': '+', '─': '-', '├': '+', '┤': '+', '┴': '+', '┬': '+', '│': '|', + '╝': '+', '╗': '+', '╔': '+', '╚': '+', '═': '=', '║': '|', '╟': '+', '╢': '+', + '░': ' ', '▒': ' ', '▓': '#', '█': '#', '·': '.', '■': '#', +} + +// Encode is s (text and escape sequences) as a terminal of cs takes it. +func Encode(s string, cs Charset) []byte { + if cs == UTF8 { + return []byte(s) + } + out := make([]byte, 0, len(s)) + graphics := false // DEC: ESC ( 0 is in + for i := 0; i < len(s); { + if s[i] == 0x1b { + // an escape sequence, as it is: ESC [ ... final, or ESC and one + j := i + 1 + if j < len(s) && s[j] == '[' { + j++ + for j < len(s) && (s[j] < 0x40 || s[j] > 0x7e) { + j++ + } + } + j = min(j+1, len(s)) + out = append(out, s[i:j]...) + i = j + continue + } + r, n := utf8.DecodeRuneInString(s[i:]) + i += n + if r < 0x80 { + if graphics { + out, graphics = append(out, "\x1b(B"...), false + } + out = append(out, byte(r)) + continue + } + switch cs { + case CP437: + if b, ok := cp437[r]; ok { + out = append(out, b) + } else if p, ok := plain[r]; ok { + out = append(out, p...) + } else { + out = append(out, '?') + } + case DEC: + if b, ok := decGraphics[r]; ok { + if !graphics { + out, graphics = append(out, "\x1b(0"...), true + } + out = append(out, b) + continue + } + if graphics { + out, graphics = append(out, "\x1b(B"...), false + } + out = append(out, ascii(r)...) + default: + out = append(out, ascii(r)...) + } + } + if graphics { + out = append(out, "\x1b(B"...) + } + return out +} + +// ascii is r in ASCII, as near as it goes. +func ascii(r rune) string { + if b, ok := asciiLines[r]; ok { + return string(b) + } + if p, ok := plain[r]; ok { + return p + } + return "?" +} diff --git a/internal/tui/desk.go b/internal/tui/desk.go new file mode 100644 index 0000000..f78edbf --- /dev/null +++ b/internal/tui/desk.go @@ -0,0 +1,370 @@ +// Copyright (C) 2026 D-Modem Authors +// SPDX-License-Identifier: AGPL-3.0-only + +package tui + +import ( + "strings" + "time" + "unicode" +) + +// Line is the terminal: one byte at a time, within a time, and bytes out. +type Line interface { + ReadOne(time.Duration) (byte, error) + WriteRaw(string) error +} + +// Desk is the full screen on a terminal: what it shows now, and how. +type Desk struct { + Line Line + Rows int + Cols int + Charset Charset + Mono bool // no colors: bright as bold, light bars reversed (a VT) + Name string // at the right of the menu bar + // the desk shaded (░), not plain blue: plain is cleared in a few + // bytes, for a slow line + Shade bool + Menus []Menu // the menu bar's pull-downs; none: a bar with Name + // how long a key may take where the screens wait without a limit of + // their own (0: a day); ErrTimeout after + Idle time.Duration + + on *Screen // what the terminal shows (nil: not known) + last *Screen // what was shown last + pending []byte + lastCR bool +} + +// Menu is a pull-down menu of the menu bar. +type Menu struct { + Label string + Items []MenuItem +} + +// MenuItem is a menu's line: an ID it gives back, or a heading line. +type MenuItem struct { + Label string + ID string + Heading bool +} + +// Hint is a key on the bar at the foot, and what it does. +type Hint struct{ Key, Label string } + +// OK is whether a terminal of rows and cols has room for the desk. +func OK(rows, cols int) bool { return rows >= 14 && cols >= 40 } + +// Show sends what changed between the terminal's screen and scr. +func (d *Desk) Show(scr *Screen) error { + old, out := d.on, "" + if old == nil || old.Cols != scr.Cols || old.Rows != scr.Rows { + // the screen cleared in the desk's colors: its blank cells need + // nothing more + out = "\x1b[0m" + sgr(Attr{LightGray, Blue}, d.Mono) + "\x1b[2J" + old = NewScreen(scr.Cols, scr.Rows) + old.Fill(0, 0, scr.Rows, scr.Cols, ' ', Attr{LightGray, Blue}) + } + out += scr.Render(old, d.Mono) + "\x1b[?25l" + d.on = scr.Clone() + d.last = d.on + return d.Line.WriteRaw(string(Encode(out, d.Charset))) +} + +// Redraw: the next Show sends the whole screen (the terminal's is not +// known: a program ran on it, or the caller asked with ^L). +func (d *Desk) Redraw() { d.on = nil } + +// Close takes the desk down: colors reset, the screen cleared, the cursor +// shown at the top. +func (d *Desk) Close() error { + d.on = nil + return d.Line.WriteRaw("\x1b[0m\x1b[2J\x1b[H\x1b[?25h") +} + +// Key is the next key within wait (< 0: Idle; ErrTimeout without one); alt: a letter +// or digit typed with Alt. ^L redraws the screen at the next Show and is +// given back too. +func (d *Desk) Key(wait time.Duration) (rune, bool, error) { + k, alt, err := d.readKey(wait) + if err == nil && k == 0x0c { + d.Redraw() + } + return k, alt, err +} + +// Blank is the bare desk: the shading, the menu bar (open: the menu shown +// pulled down, -1 none), the key bar's row empty. +func (d *Desk) Blank(open int) *Screen { + scr := NewScreen(d.Cols, d.Rows) + if d.Shade { + scr.Fill(1, 0, d.Rows-2, d.Cols, '░', Attr{LightGray, Blue}) + } else { + scr.Fill(1, 0, d.Rows-2, d.Cols, ' ', Attr{LightGray, Blue}) + } + bar := Attr{Black, LightGray} + scr.Fill(0, 0, 1, d.Cols, ' ', bar) + scr.Fill(d.Rows-1, 0, 1, d.Cols, ' ', bar) + bm := barMenus(d.Menus) + for i, m := range bm { + a, hot := bar, Attr{Red, LightGray} + if i == open { + a, hot = Attr{Black, Green}, Attr{Red, Green} + } + scr.Put(0, m.col-1, " "+m.label+" ", a) + if m.hot >= 0 { + scr.Put(0, m.col+m.hot, string([]rune(m.label)[m.hot]), hot) + } + } + end := 0 + if len(bm) > 0 { + l := bm[len(bm)-1] + end = l.col + Width(l.label) + 1 + } + if name := " " + d.Name + " "; d.Name != "" && end+Width(name)+2 < d.Cols { + if len(bm) == 0 { + scr.Put(0, 1, name, bar) + } else { + scr.Put(0, d.Cols-Width(name)-1, name, bar) + } + } + return scr +} + +// Status puts s at the right of the menu bar (over Name). +func (d *Desk) Status(scr *Screen, s string) { + if s == "" { + return + } + s = " " + Trunc(s, d.Cols/2) + " " + scr.Put(0, d.Cols-Width(s)-1, s, Attr{Black, LightGray}) +} + +// KeyBar draws the bar of keys at the foot, and status at its right. +func (d *Desk) KeyBar(scr *Screen, keys []Hint, status string) { + cols, row := d.Cols, d.Rows-1 + bar, hot := Attr{Black, LightGray}, Attr{Red, LightGray} + scr.Fill(row, 0, 1, cols, ' ', bar) + room := cols - 1 + if status != "" { + room -= min(Width(status)+3, cols/2) + } + x := 1 + for _, k := range keys { + n := Width(k.Key) + 1 + Width(k.Label) + if x+n > room { + break + } + scr.Put(row, x, k.Key, hot) + scr.Put(row, x+Width(k.Key)+1, k.Label, bar) + x += n + 2 + } + if status != "" { + st := Trunc(status, cols/2-2) + scr.Put(row, cols-Width(st)-2, st, Attr{Blue, LightGray}) + } +} + +// HasHelp is whether the screen has rows for the help box. +func (d *Desk) HasHelp() bool { return d.Rows >= 20 } + +// Bottom is the last row a window may use (above the help box, if there +// is one, and the window's shadow). +func (d *Desk) Bottom() int { + if d.HasHelp() { + return d.Rows - 7 + } + return d.Rows - 3 +} + +// HelpBox draws the box of help above the key bar: a title and text (two +// lines of it; more is cut). tone: 0 plain, 1 a warning. +func (d *Desk) HelpBox(scr *Screen, title, text string, warn bool) { + if !d.HasHelp() { + return + } + row, cols := d.Rows-5, d.Cols + scr.Box(row, 0, 4, cols, Single, Attr{LightCyan, Blue}, Attr{White, Blue}) + if title != "" { + scr.Title(row, 0, cols, title, Attr{Yellow, Blue}) + } + a := Attr{White, Blue} + if warn { + a = Attr{LightRed, Blue} + } + lines := Wrap(text, cols-4) + if len(lines) > 2 { + lines[1] = Trunc(lines[1], cols-14) + " (F1: more)" + lines = lines[:2] + } + for i, l := range lines { + scr.Put(row+1+i, 2, l, a) + } +} + +// barMenu is a menu's place on the bar. +type barMenu struct { + label string + col int // where its label begins + hot int // its hot key's rune in label (-1: none) + key rune +} + +// barMenus are the bar's menus placed, each with a hot key no other has. +func barMenus(menus []Menu) []barMenu { + var out []barMenu + used := map[rune]bool{} + col := 3 + for _, m := range menus { + b := barMenu{label: m.Label, col: col, hot: -1} + for i, r := range []rune(m.Label) { + u := unicode.ToUpper(r) + if unicode.IsLetter(r) && !used[u] { + b.hot, b.key, used[u] = i, u, true + break + } + } + out = append(out, b) + col += Width(m.Label) + 2 + } + return out +} + +// MenuKey is the menu whose hot key (with Alt) k is; -1 none. +func (d *Desk) MenuKey(k rune) int { + for i, m := range barMenus(d.Menus) { + if m.key == unicode.ToUpper(k) { + return i + } + } + return -1 +} + +// PullDown shows the menu bar's menu i pulled down over base (the screen +// with that menu open: base(i)), until an item is picked (its ID) or the +// menus are left (""). +func (d *Desk) PullDown(base func(open int) *Screen, i int) (string, error) { + bm := barMenus(d.Menus) + if i < 0 || i >= len(bm) { + return "", nil + } + first := func(m Menu) int { + for j, it := range m.Items { + if !it.Heading { + return j + } + } + return -1 + } + cur, top := first(d.Menus[i]), 0 + for { + scr := base(i) + m := d.Menus[i] + width := 14 + for _, it := range m.Items { + width = max(width, Width(it.Label)+4) + } + width = min(width, d.Cols-2) + col := max(min(bm[i].col-2, d.Cols-width-2), 0) + n := min(len(m.Items), d.Rows-6) + if cur >= 0 { + top = min(top, cur) + if cur >= top+n { + top = cur - n + 1 + } + } + box := Attr{Black, LightGray} + scr.Box(1, col, n+2, width, Single, box, box) + scr.Shadow(1, col, n+2, width) + for r := 0; r < n; r++ { + j := top + r + it := m.Items[j] + row := 2 + r + if it.Heading { + scr.Rule(row, col, width, Single, box) + if it.Label != "" { + scr.Put(row, col+2, " "+Trunc(it.Label, width-6)+" ", Attr{DarkGray, LightGray}) + } + continue + } + a := box + if j == cur { + a = Attr{Black, Green} + } + scr.PutW(row, col+1, width-2, " "+Trunc(it.Label, width-3), a) + } + if top > 0 { + scr.Put(2, col+width-1, "▲", box) + } + if top+n < len(m.Items) { + scr.Put(1+n, col+width-1, "▼", box) + } + d.KeyBar(scr, Hints("Enter", "Select", "Esc", "Close", "←→", "Menus", "↑↓", "Items"), "") + if err := d.Show(scr); err != nil { + return "", err + } + k, alt, err := d.Key(-1) + if err != nil { + return "", err + } + move := func(dir int) { + for j := cur + dir; j >= 0 && j < len(m.Items); j += dir { + if !m.Items[j].Heading { + cur = j + return + } + } + } + switch { + case alt: + if j := d.MenuKey(k); j >= 0 { + i, cur, top = j, first(d.Menus[j]), 0 + } + case k == KeyEsc || k == '/' || k == F(12) || k == F(10): + return "", nil + case k == KeyLeft: + i = (i + len(d.Menus) - 1) % len(d.Menus) + cur, top = first(d.Menus[i]), 0 + case k == KeyRight: + i = (i + 1) % len(d.Menus) + cur, top = first(d.Menus[i]), 0 + case k == KeyUp: + move(-1) + case k == KeyDown || k == KeyTab: + move(1) + case k == KeyHome: + cur = -1 + move(1) + case k == KeyEnd: + cur = len(m.Items) + move(-1) + case k == KeyEnter || k == ' ': + if cur >= 0 { + return m.Items[cur].ID, nil + } + default: + // a letter: the next item beginning with it + for o := 1; o <= len(m.Items); o++ { + j := (cur + o + len(m.Items)) % len(m.Items) + it := m.Items[j] + if !it.Heading && strings.HasPrefix(strings.ToUpper(it.Label), string(unicode.ToUpper(k))) { + cur = j + break + } + } + } + } +} + +// Hints is keys and what they do, in pairs: Hints("Enter", "Select", ...). +func Hints(kv ...string) []Hint { + var out []Hint + for i := 0; i+1 < len(kv); i += 2 { + out = append(out, Hint{Key: kv[i], Label: kv[i+1]}) + } + return out +} + +// Screen is the screen last shown (nil: none yet). +func (d *Desk) Screen() *Screen { return d.last } diff --git a/internal/tui/dialog.go b/internal/tui/dialog.go new file mode 100644 index 0000000..61ee6ee --- /dev/null +++ b/internal/tui/dialog.go @@ -0,0 +1,245 @@ +// Copyright (C) 2026 D-Modem Authors +// SPDX-License-Identifier: AGPL-3.0-only + +package tui + +import ( + "strings" + "unicode" +) + +// Dialog kinds: the colors of a dialog. +const ( + Info = iota // black on gray + Alert // white on red: an error, a question that loses something +) + +// Message shows a dialog over base: a title, text (wrapped) and buttons +// (none: "OK"). The button chosen, -1 for Esc. A button's first letter +// chooses it too. +func (d *Desk) Message(base *Screen, kind int, title, text string, buttons ...string) (int, error) { + if len(buttons) == 0 { + buttons = []string{"OK"} + } + frame, inside, btn, btnOn := Attr{White, LightGray}, Attr{Black, LightGray}, Attr{Black, Cyan}, Attr{White, Green} + if kind == Alert { + frame, inside, btn, btnOn = Attr{White, Red}, Attr{White, Red}, Attr{Black, LightGray}, Attr{White, Green} + } + bw := 0 + for _, b := range buttons { + bw += Width(b) + 6 + } + w := max(bw+4, Width(title)+10, 36) + if Width(text) > 60 { + w = max(w, 64) + } + w = min(w, d.Cols-4) + lines := []string{} + for _, para := range strings.Split(text, "\n") { + lines = append(lines, Wrap(para, w-6)...) + } + for _, l := range lines { + w = max(w, min(Width(l)+6, d.Cols-4)) + } + maxLines := d.Rows - 8 + if len(lines) > maxLines { + lines = append(lines[:maxLines-1], "…") + } + h := len(lines) + 5 + row := max(1, (d.Rows-h)/2) + col := (d.Cols - w) / 2 + cur := 0 + for { + scr := base.Clone() + scr.Box(row, col, h, w, Double, frame, inside) + scr.Shadow(row, col, h, w) + scr.Title(row, col, w, title, frame) + for i, l := range lines { + scr.Put(row+2+i, col+3, l, inside) + } + x := col + (w-bw)/2 + 1 + for i, b := range buttons { + a := btn + if i == cur { + a = btnOn + } + label := " " + b + " " + scr.Put(row+h-2, x, label, a) + if i == cur { + scr.Put(row+h-2, x, "►", a) + } + x += Width(label) + 2 + } + if err := d.Show(scr); err != nil { + return -1, err + } + k, _, err := d.Key(-1) + if err != nil { + return -1, err + } + switch k { + case KeyEsc: + return -1, nil + case KeyEnter, ' ': + return cur, nil + case KeyLeft, KeyUp: + cur = (cur + len(buttons) - 1) % len(buttons) + case KeyRight, KeyDown, KeyTab: + cur = (cur + 1) % len(buttons) + default: + for i, b := range buttons { + if k < KeyUp && strings.HasPrefix(strings.ToUpper(b), string(unicode.ToUpper(k))) { + return i, nil + } + } + } + } +} + +// Confirm asks a yes-or-no question: whether the answer is yes. +func (d *Desk) Confirm(base *Screen, kind int, title, text string) (bool, error) { + i, err := d.Message(base, kind, title, text, "Yes", "No") + return i == 0, err +} + +// Input is a box over base to type a line in: prompt above it, value to +// begin with. The line, and whether it was taken (Enter) or not (Esc). +// Mask hides what is typed. Cursor keys, Home, End, Del, BS and ^U edit. +func (d *Desk) Input(base *Screen, title, prompt, value string, limit int, mask bool) (string, bool, error) { + if limit <= 0 { + limit = 255 + } + w := min(d.Cols-4, 72) + fw := w - 6 // the field's width + plines := Wrap(prompt, w-6) + if prompt == "" { + plines = nil + } + plines = plines[:min(len(plines), 4)] + h := 5 + len(plines) + row := max(1, (d.Rows-h)/2-1) + col := (d.Cols - w) / 2 + line := []rune(value) + pos, off := len(line), 0 + frame, inside := Attr{White, Blue}, Attr{White, Blue} + field := Attr{Black, Cyan} + for { + if pos < off { + off = pos + } + if pos >= off+fw { + off = pos - fw + 1 + } + scr := base.Clone() + scr.Box(row, col, h, w, Double, frame, inside) + scr.Shadow(row, col, h, w) + scr.Title(row, col, w, title, Attr{Yellow, Blue}) + for i, l := range plines { + scr.Put(row+1+i, col+3, l, inside) + } + fr := row + 2 + len(plines) + shown := line[off:min(len(line), off+fw)] + s := string(shown) + if mask { + s = strings.Repeat("*", len(shown)) + } + scr.PutW(fr, col+3, fw, s, field) + if off > 0 { + scr.Put(fr, col+2, "◄", Attr{Yellow, Blue}) + } + if off+fw < len(line) { + scr.Put(fr, col+3+fw, "►", Attr{Yellow, Blue}) + } + d.KeyBar(scr, Hints("Enter", "Done", "Esc", "Cancel", "^U", "Clear", "←→", "Move"), "") + if err := d.Show(scr); err != nil { + return value, false, err + } + // the cursor where the next character goes, and shown + cx := col + 3 + pos - off + 1 + if err := d.Line.WriteRaw("\x1b[" + itoa(fr+1) + ";" + itoa(cx) + "H\x1b[?25h"); err != nil { + return value, false, err + } + k, _, err := d.Key(-1) + if err != nil { + return value, false, err + } + switch { + case k == KeyEnter: + return string(line), true, nil + case k == KeyEsc: + return value, false, nil + case k == KeyLeft: + pos = max(pos-1, 0) + case k == KeyRight: + pos = min(pos+1, len(line)) + case k == KeyHome || k == 0x01: + pos = 0 + case k == KeyEnd || k == 0x05: + pos = len(line) + case k == KeyBackspace: + if pos > 0 { + line = append(line[:pos-1], line[pos:]...) + pos-- + } + case k == KeyDel || k == 0x04: + if pos < len(line) { + line = append(line[:pos], line[pos+1:]...) + } + case k == 0x15: + line, pos, off = nil, 0, 0 + case k == 0x0b: // ^K: to the end + line = line[:pos] + case k >= ' ' && k < KeyUp && k != 0x7f && len(line) < limit: + line = append(line[:pos], append([]rune{k}, line[pos:]...)...) + pos++ + } + } +} + +// Choose is a list over base to pick one of choices from (cur: the one +// the bar starts on): its index, and whether one was picked. notes are +// shown beside the choices (may be nil). +func (d *Desk) Choose(base *Screen, title string, choices, notes []string, cur int) (int, bool, error) { + l := &List{Title: title, Center: true, Cur: cur} + for i, c := range choices { + e := Entry{Label: c} + if i < len(notes) && notes[i] != "" { + e.Value = notes[i] + e.Unset = true + } + l.Items = append(l.Items, e) + } + l.Fix() + for { + scr := base.Clone() + l.Draw(d, scr, true) + d.KeyBar(scr, Hints("Enter", "Pick", "Esc", "Cancel", "↑↓", "Move"), "") + if err := d.Show(scr); err != nil { + return cur, false, err + } + k, _, err := d.Key(-1) + if err != nil { + return cur, false, err + } + switch k { + case KeyEnter, ' ': + return l.Cur, true, nil + case KeyEsc, KeyLeft: + return cur, false, nil + default: + l.Move(d, k) + } + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..d86b2ef --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,197 @@ +// Copyright (C) 2026 D-Modem Authors +// SPDX-License-Identifier: AGPL-3.0-only + +package tui + +import ( + "errors" + "time" + "unicode/utf8" + + "github.com/D-Modem/menu/internal/term" +) + +// Keys that are no character, as runes past Unicode's private area. +const ( + KeyUp rune = 0x10ff00 + iota + KeyDown + KeyLeft + KeyRight + KeyHome + KeyEnd + KeyPgUp + KeyPgDn + KeyIns + KeyDel + KeyF1 // F1 to F12: KeyF1+0 to KeyF1+11 +) + +const ( + KeyEnter rune = '\r' + KeyEsc rune = 0x1b + KeyTab rune = '\t' + KeyBackspace rune = 0x08 +) + +// F is function key n (1-12). +func F(n int) rune { return KeyF1 + rune(n-1) } + +// ErrTimeout: no key came in time (the line's own). +var ErrTimeout = term.ErrTimeout + +// escWait is how long the rest of an escape sequence may take after its +// ESC: a byte at 300 bit/s takes 33 ms, and modems send in bursts. +const escWait = 400 * time.Millisecond + +// a VT's ESC [ n ~ keys +var tildeKeys = map[int]rune{1: KeyHome, 2: KeyIns, 3: KeyDel, 4: KeyEnd, 5: KeyPgUp, 6: KeyPgDn, + 7: KeyHome, 8: KeyEnd, 11: F(1), 12: F(2), 13: F(3), 14: F(4), 15: F(5), 17: F(6), 18: F(7), + 19: F(8), 20: F(9), 21: F(10), 23: F(11), 24: F(12)} + +// readKey is a key from read (which returns ErrTimeout itself, or any +// error); alt: a letter typed with Alt (ESC and it at once). +func (d *Desk) readKey(wait time.Duration) (k rune, alt bool, err error) { + for { + b, err := d.next(wait) + if err != nil { + return 0, false, err + } + switch { + case b == '\n' && d.lastCR: + d.lastCR = false + continue + case b == '\r' || b == '\n': + d.lastCR = b == '\r' + return KeyEnter, false, nil + case b == 0x7f || b == 0x08: + d.lastCR = false + return KeyBackspace, false, nil + case b == 0x1b: + d.lastCR = false + return d.escape() + case b >= 0xc0 && d.Charset == UTF8: + d.lastCR = false + buf := []byte{b} + for len(buf) < 4 && !utf8.FullRune(buf) { + c, err := d.next(escWait) + if err != nil { + break + } + buf = append(buf, c) + } + r, _ := utf8.DecodeRune(buf) + return r, false, nil + case b >= 0x80: + continue // not a key the screens take + } + d.lastCR = false + return rune(b), false, nil + } +} + +// escape is the key whose ESC came. +func (d *Desk) escape() (rune, bool, error) { + b, err := d.next(escWait) + if errors.Is(err, ErrTimeout) { + return KeyEsc, false, nil + } + if err != nil { + return 0, false, err + } + switch b { + case '[': + case 'O': + c, err := d.next(escWait) + if err != nil { + return KeyEsc, false, nil + } + switch c { + case 'A', 'B', 'C', 'D', 'H', 'F': + return cursorKey(c), false, nil + case 'P', 'Q', 'R', 'S': + return F(int(c-'P') + 1), false, nil + } + return KeyEsc, false, nil + case 0x1b: + d.unread(b) + return KeyEsc, false, nil + default: + if b >= 'a' && b <= 'z' { + b -= 'a' - 'A' + } + if b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' { + return rune(b), true, nil + } + d.unread(b) // a key of its own, after Esc + return KeyEsc, false, nil + } + // CSI: parameters, then the final byte + n, params := 0, 0 + for i := 0; i < 16; i++ { + c, err := d.next(escWait) + if err != nil { + return KeyEsc, false, nil + } + switch { + case c >= '0' && c <= '9': + if params == 0 { + n = n*10 + int(c-'0') + } + case c == ';': + params++ + case c == '~': + if k, ok := tildeKeys[n]; ok { + return k, false, nil + } + return KeyEsc, false, nil + case c >= 0x40 && c <= 0x7e: + if k := cursorKey(c); k != 0 { + return k, false, nil + } + return KeyEsc, false, nil + } + } + return KeyEsc, false, nil +} + +// cursorKey is ESC [ c's key (0: none). +func cursorKey(c byte) rune { + switch c { + case 'A': + return KeyUp + case 'B': + return KeyDown + case 'C': + return KeyRight + case 'D': + return KeyLeft + case 'H': + return KeyHome + case 'F': + return KeyEnd + case 'P', 'Q', 'R', 'S': + return F(int(c-'P') + 1) + } + return 0 +} + +// next is the next byte (what was put back first). +func (d *Desk) next(wait time.Duration) (byte, error) { + if len(d.pending) > 0 { + b := d.pending[0] + d.pending = d.pending[1:] + return b, nil + } + if wait < 0 { + wait = d.Idle + if wait <= 0 { + wait = 24 * time.Hour + } + } + if wait == 0 { + return 0, ErrTimeout + } + return d.Line.ReadOne(wait) +} + +func (d *Desk) unread(b byte) { d.pending = append([]byte{b}, d.pending...) } diff --git a/internal/tui/list.go b/internal/tui/list.go new file mode 100644 index 0000000..9ba5369 --- /dev/null +++ b/internal/tui/list.go @@ -0,0 +1,320 @@ +// Copyright (C) 2026 D-Modem Authors +// SPDX-License-Identifier: AGPL-3.0-only + +package tui + +import ( + "strings" + "unicode" +) + +// Entry is a line of a List: a label and its value, or a heading. +type Entry struct { + ID string + Key string // shown before the label, as [B]; typing it picks the entry + Label string + Value string // at the right of the labels + Unset bool // Value is a default, not set: shown dim + Note string // after the value, dim + Help string // in the help box while the bar is on it + Mark string // at the window's right edge: ► a window, ↕ a choice + Heading bool // a line across the window, Label in it + Off bool // shown, not chosen + Changed bool // a * before it +} + +// choosable is whether the bar may rest on e. +func (e *Entry) choosable() bool { return !e.Heading && !e.Off } + +// List is a window of entries with a bar on one of them. +type List struct { + Title string + Notes []string // lines above the entries (three at most) + Items []Entry + Cur int + top int + // where it goes: Depth cascades it down and right of the first window + // (0); Center puts it in the middle of the desk + Depth int + Center bool + MinW int +} + +// colors of a window, as it is up or under another +type winColors struct { + frame, title, label, value, unset, note, mark, off, head, key Attr + bar, barValue, barKey Attr + box BoxStyle +} + +var ( + activeWin = winColors{ + frame: Attr{White, Blue}, title: Attr{Yellow, Blue}, label: Attr{White, Blue}, + value: Attr{Yellow, Blue}, unset: Attr{LightGray, Blue}, note: Attr{Cyan, Blue}, + mark: Attr{LightRed, Blue}, off: Attr{DarkGray, Blue}, head: Attr{LightCyan, Blue}, + key: Attr{LightCyan, Blue}, bar: Attr{White, Cyan}, barValue: Attr{Yellow, Cyan}, + barKey: Attr{Yellow, Cyan}, box: Double, + } + inactiveWin = winColors{ + frame: Attr{LightGray, Blue}, title: Attr{LightGray, Blue}, label: Attr{LightGray, Blue}, + value: Attr{LightGray, Blue}, unset: Attr{LightGray, Blue}, note: Attr{LightGray, Blue}, + mark: Attr{LightGray, Blue}, off: Attr{DarkGray, Blue}, head: Attr{LightGray, Blue}, + key: Attr{LightGray, Blue}, bar: Attr{Black, LightGray}, barValue: Attr{Black, LightGray}, + barKey: Attr{Black, LightGray}, box: Single, + } +) + +// geometry is a window's place: top row and left column, height and width +// (frame and all), and the rows of notes and of entries. +type geometry struct{ row, col, h, w, notes, list int } + +// widths: the labels' column (with keys) and the values'. +func (l *List) widths() (kw, lw, vw int) { + for _, e := range l.Items { + if e.Heading { + lw = max(lw, Width(e.Label)+4-12) + continue + } + if e.Key != "" { + kw = max(kw, Width(e.Key)+3) + } + lw = max(lw, Width(e.Label)) + v := e.Value + if e.Note != "" { + v += " " + e.Note + } + vw = max(vw, Width(v)) + } + return +} + +func (l *List) geom(d *Desk) geometry { + first, last := 2, d.Bottom() + kw, lw, vw := l.widths() + for _, n := range l.Notes { + vw = max(vw, Width(n)-lw-kw-4) + } + maxW := d.Cols - 4 + width := 2 + 2 + kw + lw + 3 + if vw > 0 { + width += 2 + min(max(vw, 12), 40) + } + width = max(width, Width(l.Title)+10, min(l.MinW, maxW), 30) + width = min(width, maxW) + off := min(l.Depth, 5) + g := geometry{w: width, col: 2 + 3*off, row: first + off} + g.notes = min(len(l.Notes), 3) + if g.notes > 0 { + g.notes++ // the rule under them + } + g.list = max(len(l.Items), 1) + g.h = 2 + g.notes + g.list + if l.Center { + g.col = (d.Cols - g.w) / 2 + g.row = max(first, first+(last-first+1-g.h)/2) + } + if g.col+g.w+2 > d.Cols { + g.col = max(d.Cols-2-g.w, 0) + } + room := last - g.row + 1 + if g.h > room { + if room < 2+g.notes+5 { // too far down: up as far as needed + g.row = max(first, last+1-(2+g.notes+5)) + room = last - g.row + 1 + } + g.h = min(g.h, room) + g.list = max(g.h-2-g.notes, 1) + } + return g +} + +// Fix puts the bar on an entry it may rest on (the first after Cur). +func (l *List) Fix() { + if l.Cur >= 0 && l.Cur < len(l.Items) && l.Items[l.Cur].choosable() { + return + } + if j := l.step(l.Cur, 1); j >= 0 { + l.Cur = j + } else if j := l.step(l.Cur, -1); j >= 0 { + l.Cur = j + } +} + +// step is the next entry the bar may rest on from i, by dir (-1 if none). +func (l *List) step(i, dir int) int { + for j := i + dir; j >= 0 && j < len(l.Items); j += dir { + if l.Items[j].choosable() { + return j + } + } + return -1 +} + +// Selected is the entry under the bar (nil: none). +func (l *List) Selected() *Entry { + if l.Cur < 0 || l.Cur >= len(l.Items) || !l.Items[l.Cur].choosable() { + return nil + } + return &l.Items[l.Cur] +} + +// Move handles the keys that move the bar: whether k was one. A letter +// moves to the next entry it begins (or whose Key it is, picked: true). +func (l *List) Move(d *Desk, k rune) (moved, picked bool) { + g := l.geom(d) + to := func(j int) { + if j >= 0 { + l.Cur = j + } + } + switch k { + case KeyUp: + to(l.step(l.Cur, -1)) + case KeyDown, KeyTab: + to(l.step(l.Cur, 1)) + case KeyHome: + to(l.step(-1, 1)) + case KeyEnd: + to(l.step(len(l.Items), -1)) + case KeyPgUp, KeyPgDn: + dir := 1 + if k == KeyPgUp { + dir = -1 + } + j := min(max(l.Cur+dir*(g.list-1), 0), len(l.Items)-1) + if n := l.step(j-dir, dir); n >= 0 { + to(n) + } else { + to(l.step(j, -dir)) + } + default: + if k < ' ' || k >= KeyUp || k == ' ' { + return false, false + } + u := strings.ToUpper(string(k)) + for i, e := range l.Items { + if e.choosable() && e.Key != "" && strings.ToUpper(e.Key) == u { + l.Cur = i + return true, true + } + } + n := len(l.Items) + for o := 1; o <= n; o++ { + j := (l.Cur + o + n) % n + e := l.Items[j] + if e.choosable() && strings.HasPrefix(strings.ToUpper(e.Label), u) { + l.Cur = j + return true, false + } + } + return false, false + } + return true, false +} + +// Draw draws the window on scr, active (on top: a double frame, the bar +// bright) or under another. +func (l *List) Draw(d *Desk, scr *Screen, active bool) { + c := inactiveWin + if active { + c = activeWin + } + g := l.geom(d) + scr.Box(g.row, g.col, g.h, g.w, c.box, c.frame, c.label) + scr.Shadow(g.row, g.col, g.h, g.w) + if l.Title != "" { + scr.Title(g.row, g.col, g.w, l.Title, c.title) + } + inner := g.w - 2 + for i := 0; i < min(len(l.Notes), 3); i++ { + scr.PutW(g.row+1+i, g.col+1, inner, " "+Trunc(l.Notes[i], inner-2), c.note) + } + if g.notes > 0 { + scr.Rule(g.row+g.notes, g.col, g.w, c.box, c.frame) + } + cur := l.Cur + if cur >= 0 && cur < l.top { + l.top = cur + if l.top > 0 && l.Items[l.top-1].Heading { + l.top-- // a heading just above comes too + } + } + if cur >= l.top+g.list { + l.top = cur - g.list + 1 + } + l.top = max(min(l.top, len(l.Items)-g.list), 0) + kw, lw, vmax := l.widths() + _ = vmax + if vmax > 0 { + lw = min(lw, max(inner*11/20-kw, inner-6-kw-max(vmax, 8))) + } else { + lw = inner - 4 - kw + } + vw := inner - 2 - kw - lw - 2 - 2 + first := g.row + 1 + g.notes + for r := 0; r < g.list; r++ { + i := l.top + r + row := first + r + if i >= len(l.Items) { + break + } + e := l.Items[i] + if e.Heading { + scr.Put(row, g.col+1, strings.Repeat("─", inner), c.off) + if e.Label != "" { + scr.Put(row, g.col+2, " "+Trunc(e.Label, inner-4)+" ", c.head) + } + continue + } + label, value, unset, note, mark, key := c.label, c.value, c.unset, c.note, c.mark, c.key + if e.Off { + label, value, unset, note, key = c.off, c.off, c.off, c.off, c.off + } + if i == cur && active || i == cur && !e.Off { + scr.Fill(row, g.col+1, 1, inner, ' ', c.bar) + label, unset, note, mark, value, key = c.bar, c.bar, c.bar, c.barValue, c.barValue, c.barKey + } + if e.Changed { + scr.Put(row, g.col+1, "*", mark) + } + x := g.col + 2 + if kw > 0 { + if e.Key != "" { + scr.Put(row, x, "[", label) + scr.Put(row, x+1, e.Key, key) + scr.Put(row, x+1+Width(e.Key), "]", label) + } + x += kw + } + scr.PutW(row, x, lw, Trunc(e.Label, lw), label) + if vw > 0 { + va := value + if e.Unset { + va = unset + } + x += lw + 2 + n := scr.Put(row, x, Trunc(e.Value, vw), va) + if e.Note != "" && n+3 < vw && vw >= 20 { + scr.Put(row, x+n+2, Trunc(e.Note, vw-n-2), note) + } + } + if e.Mark != "" { + scr.Put(row, g.col+g.w-3, e.Mark, value) + } + } + if l.top > 0 { + scr.Put(first, g.col+g.w-1, "▲", c.frame) + } + if l.top+g.list < len(l.Items) { + scr.Put(first+g.list-1, g.col+g.w-1, "▼", c.frame) + } +} + +// capFirst is s with its first letter a capital. +func capFirst(s string) string { + r := []rune(s) + if len(r) > 0 { + r[0] = unicode.ToUpper(r[0]) + } + return string(r) +} diff --git a/internal/tui/screen.go b/internal/tui/screen.go new file mode 100644 index 0000000..a3765ab --- /dev/null +++ b/internal/tui/screen.go @@ -0,0 +1,345 @@ +// Copyright (C) 2026 D-Modem Authors +// SPDX-License-Identifier: AGPL-3.0-only + +// Package tui is the full screen of an ANSI terminal, drawn in character +// cells as the setup programs and BBSs of the 1990s drew theirs: a shaded +// blue desk, windows with shadows over it, a menu bar of pull-down menus +// at the top and a bar of keys at the foot. What is drawn is kept, and +// only the cells that change are sent, so that a caller at 2400 bit/s is +// not sent the whole screen for a moved bar. A terminal without UTF-8 +// gets code page 437, a VT the DEC line-drawing set, others ASCII. +// +// The screen buffer and its rendering come from the bbs's setup screens +// (D-Modem/bbs internal/textui/desk.go). +package tui + +import ( + "strconv" + "strings" + "unicode/utf8" +) + +// The sixteen colors of a PC's text mode: 0-7 as backgrounds too. +const ( + Black = iota + Blue + Green + Cyan + Red + Magenta + Brown + LightGray + DarkGray + LightBlue + LightGreen + LightCyan + LightRed + LightMagenta + Yellow + White +) + +// Attr is a cell's colors: foreground (0-15) and background (0-7). +type Attr struct{ FG, BG uint8 } + +// A is an Attr. +func A(fg, bg uint8) Attr { return Attr{fg, bg} } + +// cell is a character cell. +type cell struct { + r rune + a Attr +} + +// Screen is a screen's cells. +type Screen struct { + Cols, Rows int + c []cell +} + +// NewScreen is a blank screen. +func NewScreen(cols, rows int) *Screen { + s := &Screen{Cols: cols, Rows: rows, c: make([]cell, cols*rows)} + for i := range s.c { + s.c[i] = cell{' ', Attr{LightGray, Black}} + } + return s +} + +// Clone is a copy of s. +func (s *Screen) Clone() *Screen { + n := &Screen{Cols: s.Cols, Rows: s.Rows, c: make([]cell, len(s.c))} + copy(n.c, s.c) + return n +} + +// at is the cell at row and column (from 0), nil off the screen. +func (s *Screen) at(row, col int) *cell { + if row < 0 || col < 0 || row >= s.Rows || col >= s.Cols { + return nil + } + return &s.c[row*s.Cols+col] +} + +// Rune is the character at row and column (0 off the screen). +func (s *Screen) Rune(row, col int) rune { + if c := s.at(row, col); c != nil { + return c.r + } + return 0 +} + +// AttrAt is the colors at row and column. +func (s *Screen) AttrAt(row, col int) Attr { + if c := s.at(row, col); c != nil { + return c.a + } + return Attr{} +} + +// Line is a row's characters, for tests and logs. +func (s *Screen) Line(row int) string { + var b strings.Builder + for x := 0; x < s.Cols; x++ { + b.WriteRune(s.Rune(row, x)) + } + return b.String() +} + +// Text is the whole screen's characters, a line to a row. +func (s *Screen) Text() string { + var b strings.Builder + for y := 0; y < s.Rows; y++ { + b.WriteString(strings.TrimRight(s.Line(y), " ") + "\n") + } + return b.String() +} + +// Put writes str from row, col in a, cut at the screen's edge; how many +// cells it took. +func (s *Screen) Put(row, col int, str string, a Attr) int { + n := 0 + for _, r := range str { + if c := s.at(row, col+n); c != nil { + *c = cell{r, a} + } + n++ + } + return n +} + +// PutW writes str in exactly w cells: cut, or padded with spaces. +func (s *Screen) PutW(row, col, w int, str string, a Attr) { + s.Put(row, col, Fit(str, w), a) +} + +// Fill fills a rectangle with r in a. +func (s *Screen) Fill(row, col, h, w int, r rune, a Attr) { + for y := row; y < row+h; y++ { + for x := col; x < col+w; x++ { + if c := s.at(y, x); c != nil { + *c = cell{r, a} + } + } + } +} + +// Paint sets the colors of a rectangle, keeping its characters. +func (s *Screen) Paint(row, col, h, w int, a Attr) { + for y := row; y < row+h; y++ { + for x := col; x < col+w; x++ { + if c := s.at(y, x); c != nil { + c.a = a + } + } + } +} + +// BoxStyle is a box's lines: corners, sides and the joins of a line +// across it. +type BoxStyle struct{ tl, tr, bl, br, h, v, lj, rj rune } + +var ( + Double = BoxStyle{'╔', '╗', '╚', '╝', '═', '║', '╟', '╢'} + Single = BoxStyle{'┌', '┐', '└', '┘', '─', '│', '├', '┤'} +) + +// Box draws a box's frame and fills its inside. +func (s *Screen) Box(row, col, h, w int, st BoxStyle, frame, inside Attr) { + s.Fill(row+1, col+1, h-2, w-2, ' ', inside) + for x := col + 1; x < col+w-1; x++ { + s.Put(row, x, string(st.h), frame) + s.Put(row+h-1, x, string(st.h), frame) + } + for y := row + 1; y < row+h-1; y++ { + s.Put(y, col, string(st.v), frame) + s.Put(y, col+w-1, string(st.v), frame) + } + s.Put(row, col, string(st.tl), frame) + s.Put(row, col+w-1, string(st.tr), frame) + s.Put(row+h-1, col, string(st.bl), frame) + s.Put(row+h-1, col+w-1, string(st.br), frame) +} + +// Rule draws a line across a box at row, joined to its sides. +func (s *Screen) Rule(row, col, w int, st BoxStyle, a Attr) { + s.Put(row, col, string(st.lj), a) + for x := col + 1; x < col+w-1; x++ { + s.Put(row, x, "─", a) + } + s.Put(row, col+w-1, string(st.rj), a) +} + +// Title puts a title in a box's top line, centered: " Title ". +func (s *Screen) Title(row, col, w int, t string, a Attr) { + t = " " + Trunc(t, w-6) + " " + s.Put(row, col+(w-len([]rune(t)))/2, t, a) +} + +// Shadow darkens the cells to the right of and below a rectangle, as a +// window's shadow on the desk. +func (s *Screen) Shadow(row, col, h, w int) { + dark := Attr{DarkGray, Black} + s.Paint(row+1, col+w, h, 2, dark) + s.Paint(row+h, col+2, 1, w, dark) +} + +// Render is what turns the terminal's screen, old (nil: unknown), into s: +// each changed run of cells, with its colors. The last cell of the last +// line is left alone (writing it scrolls some terminals). +func (s *Screen) Render(old *Screen, mono bool) string { + var b strings.Builder + if old == nil || old.Cols != s.Cols || old.Rows != s.Rows { + b.WriteString("\x1b[0m\x1b[2J") + old = nil + } + cur := Attr{255, 255} + for y := 0; y < s.Rows; y++ { + last := s.Cols + if y == s.Rows-1 { + last-- + } + at := -1 // where the cursor is after the last cell written (-1: unknown) + for x := 0; x < last; x++ { + c := s.c[y*s.Cols+x] + if old != nil && old.c[y*s.Cols+x] == c { + // unchanged: skipped, unless a change follows so near + // that writing the cells costs less than moving + if at != x || !s.changedWithin(old, y, x, last, 8) { + continue + } + } + if at != x { + b.WriteString("\x1b[" + strconv.Itoa(y+1) + ";" + strconv.Itoa(x+1) + "H") + } + if c.a != cur { + b.WriteString(sgr(c.a, mono)) + cur = c.a + } + r := c.r + if r < ' ' || r == 0x7f || !utf8.ValidRune(r) { + r = ' ' + } + b.WriteRune(r) + at = x + 1 + } + } + return b.String() +} + +// changedWithin is whether a cell of row y from x, before last, within n +// of x, differs from old's. +func (s *Screen) changedWithin(old *Screen, y, x, last, n int) bool { + for i := x; i < min(x+n, last); i++ { + if old.c[y*s.Cols+i] != s.c[y*s.Cols+i] { + return true + } + } + return false +} + +// sgr is the escape that gives a's colors (in mono: bright as bold, a light +// background as reverse). +func sgr(a Attr, mono bool) string { + if mono { + s := "\x1b[0" + if a.FG >= 8 && a.FG != DarkGray { + s += ";1" + } + if a.BG == LightGray || a.BG == Cyan || a.BG == Green { + s += ";7" + } + return s + "m" + } + ansi := []int{0, 4, 2, 6, 1, 5, 3, 7} // the PC's order to ANSI's + s := "\x1b[0" + if a.FG >= 8 { + s += ";1" + } + return s + ";" + strconv.Itoa(30+ansi[a.FG%8]) + ";" + strconv.Itoa(40+ansi[a.BG%8]) + "m" +} + +// Fit is s cut or padded with spaces to exactly w runes. +func Fit(s string, w int) string { + if w <= 0 { + return "" + } + r := []rune(s) + if len(r) > w { + return string(r[:w]) + } + return s + strings.Repeat(" ", w-len(r)) +} + +// Trunc is s cut to w runes, with "…" where it was cut. +func Trunc(s string, w int) string { + r := []rune(s) + if w <= 0 { + return "" + } + if len(r) <= w { + return s + } + if w == 1 { + return string(r[:1]) + } + return string(r[:w-1]) + "…" +} + +// Wrap breaks s into lines of at most w runes, at spaces where it can. +func Wrap(s string, w int) []string { + var out []string + for _, para := range strings.Split(s, "\n") { + words := strings.Fields(para) + if len(words) == 0 { + out = append(out, "") + continue + } + line := "" + for _, wd := range words { + for len([]rune(wd)) > w { + if line != "" { + out = append(out, line) + line = "" + } + r := []rune(wd) + out = append(out, string(r[:w])) + wd = string(r[w:]) + } + switch { + case line == "": + line = wd + case len([]rune(line))+1+len([]rune(wd)) <= w: + line += " " + wd + default: + out = append(out, line) + line = wd + } + } + out = append(out, line) + } + return out +} + +// Width is s's width in cells. +func Width(s string) int { return utf8.RuneCountInString(s) } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..2b5b1a8 --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,170 @@ +// Copyright (C) 2026 D-Modem Authors +// SPDX-License-Identifier: AGPL-3.0-only + +package tui + +import ( + "bytes" + "strings" + "testing" + "time" +) + +// fakeLine: keys queued, what is written kept +type fakeLine struct { + in []byte + out bytes.Buffer +} + +func (f *fakeLine) ReadOne(time.Duration) (byte, error) { + if len(f.in) == 0 { + return 0, ErrTimeout + } + b := f.in[0] + f.in = f.in[1:] + return b, nil +} + +func (f *fakeLine) WriteRaw(s string) error { f.out.WriteString(s); return nil } + +// TestRender: the whole screen at first, then only what changed +func TestRender(t *testing.T) { + a := NewScreen(20, 5) + a.Put(1, 2, "hello", Attr{White, Blue}) + full := a.Render(nil, false) + if !strings.HasPrefix(full, "\x1b[0m\x1b[2J") || !strings.Contains(full, "hello") { + t.Fatalf("full: %q", full) + } + b := a.Clone() + b.Put(1, 3, "a", Attr{White, Blue}) + if d := b.Render(a, false); d != "\x1b[2;4H\x1b[0;1;37;44ma" { + t.Errorf("one cell: %q", d) + } + if d := b.Render(b, false); d != "" { + t.Errorf("nothing changed: %q", d) + } + // two changes close together: the cells between written, not a move + c := b.Clone() + c.Put(1, 2, "J", Attr{White, Blue}) + c.Put(1, 5, "y", Attr{White, Blue}) + if d := c.Render(b, false); d != "\x1b[2;3H\x1b[0;1;37;44mJaly" { + t.Errorf("a run: %q", d) + } +} + +// TestDeskPlain: a plain desk is cleared in its colors, its blank cells +// not sent +func TestDeskPlain(t *testing.T) { + f := &fakeLine{} + d := &Desk{Line: f, Rows: 24, Cols: 80, Charset: CP437} + scr := d.Blank(-1) + d.Show(scr) + plain := f.out.Len() + f.out.Reset() + d.Shade = true + d.Redraw() + d.Show(d.Blank(-1)) + if shaded := f.out.Len(); plain > 600 || shaded < 1800 { + t.Errorf("a plain desk took %d bytes, a shaded one %d", plain, shaded) + } + // a moved bar: a few bytes + l := &List{Title: "Menu", Items: []Entry{{Key: "A", Label: "One"}, {Key: "B", Label: "Two"}}} + s1 := d.Blank(-1) + l.Draw(d, s1, true) + d.Show(s1) + f.out.Reset() + l.Move(d, KeyDown) + s2 := d.Blank(-1) + l.Draw(d, s2, true) + d.Show(s2) + if n := f.out.Len(); n > 200 { + t.Errorf("moving the bar took %d bytes: %q", n, f.out.String()) + } +} + +// TestEncode: the lines in each charset +func TestEncode(t *testing.T) { + s := "\x1b[1;1H╔═╗║░► x" + for _, c := range []struct { + cs Charset + want string + }{ + {UTF8, s}, + {CP437, "\x1b[1;1H\xc9\xcd\xbb\xba\xb0\xaf x"}, + {DEC, "\x1b[1;1H\x1b(0lqkxa\x1b(B> x"}, + {ASCII, "\x1b[1;1H+=+| > x"}, + } { + if got := string(Encode(s, c.cs)); got != c.want { + t.Errorf("%v: %q, want %q", c.cs, got, c.want) + } + } + // nothing of the control range: some terminals take it as controls + for r := range cp437 { + if b := cp437[r]; b < 0x20 { + t.Errorf("%c is sent as control %#x", r, b) + } + } +} + +// TestKeys: the keys of VTs, xterm and ANSI terminals, and Alt +func TestKeys(t *testing.T) { + f := &fakeLine{in: []byte("\x1b[A\x1bOB\x1b[5~\x1b[11~\x1bOP\x1b[21~\r\nx\x1bf\x1b\x7f\xc3\xa9")} + d := &Desk{Line: f, Charset: UTF8} + want := []struct { + k rune + alt bool + }{{KeyUp, false}, {KeyDown, false}, {KeyPgUp, false}, {F(1), false}, {F(1), false}, {F(10), false}, + {KeyEnter, false}, {'x', false}, {'F', true}, {KeyEsc, false}, {KeyBackspace, false}, {'é', false}} + for i, w := range want { + k, alt, err := d.Key(time.Second) + if err != nil || k != w.k || alt != w.alt { + t.Errorf("key %d: %q %v %v, want %q %v", i, k, alt, err, w.k, w.alt) + } + } + if _, _, err := d.Key(time.Second); err != ErrTimeout { + t.Errorf("no more keys: %v", err) + } +} + +// TestDialogs: a question answered, a line typed and edited, a choice +func TestDialogs(t *testing.T) { + f := &fakeLine{} + d := &Desk{Line: f, Rows: 24, Cols: 80} + base := d.Blank(-1) + f.in = []byte("\x1b[C\r") + if yes, err := d.Confirm(base, Info, "Q", "Sure?"); yes || err != nil { + t.Errorf("right, Enter: %v %v", yes, err) + } + f.in = []byte("n") + if i, _ := d.Message(base, Info, "Q", "Save?", "Save", "No", "Cancel"); i != 1 { + t.Errorf("n: %d", i) + } + f.in = []byte("\x1b[Dxy\x7f\x1b[H>\r") + if v, ok, _ := d.Input(base, "T", "type", "abc", 0, false); !ok || v != ">abxc" { + t.Errorf("edited: %q %v", v, ok) + } + f.in = []byte("\x1b") + if v, ok, _ := d.Input(base, "T", "type", "abc", 0, false); ok || v != "abc" { + t.Errorf("Esc: %q %v", v, ok) + } + f.in = []byte("\x1b[B\x1b[B\r") + if i, ok, _ := d.Choose(base, "Pick", []string{"a", "b", "c"}, nil, 0); !ok || i != 2 { + t.Errorf("choice %d %v", i, ok) + } +} + +// TestPullDown: a menu pulled down, moved across, an item picked +func TestPullDown(t *testing.T) { + f := &fakeLine{in: []byte("\x1b[C\x1b[B\r")} + d := &Desk{Line: f, Rows: 24, Cols: 80, Menus: []Menu{ + {Label: "File", Items: []MenuItem{{Label: "Save", ID: "save"}}}, + {Label: "Setup", Items: []MenuItem{{Label: "One", ID: "one"}, {Heading: true}, {Label: "Two", ID: "two"}}}, + }} + id, err := d.PullDown(d.Blank, 0) + if err != nil || id != "two" { + t.Errorf("picked %q %v", id, err) + } + if d.MenuKey('s') != 1 || d.MenuKey('f') != 0 || d.MenuKey('q') != -1 { + t.Errorf("menu keys") + } +} diff --git a/mkdocs.yml b/mkdocs.yml index 153caf4..3fef771 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,6 +16,7 @@ nav: - Building: building.md - Configuration: configuration.md - Configuration reference: reference.md + - Setup screens: setup.md - Authentication: authentication.md - PPP: ppp.md - Dial-up network: dialup-network.md diff --git a/samples/modemmenu.conf.sample b/samples/modemmenu.conf.sample index fafb264..4075e11 100644 --- a/samples/modemmenu.conf.sample +++ b/samples/modemmenu.conf.sample @@ -53,6 +53,8 @@ log = syslog ; backend that does not know the user passes to the next; a wrong password ; stops there auth = file +; what the caller sees first (or a file of it, which may have ANSI's +; colors), then the prompts of the login banner = "\r\nWelcome to D-Modem.\r\n" ;banner_file = /etc/asterisk/modemmenu.banner login_prompt = "\r\nlogin: " @@ -61,11 +63,14 @@ password_prompt = "Password: " ; "33600 bit/s", or "50666 bit/s down, 31200 bit/s up"); left out when it ; is not known; "" for none connect_message = "\r\nConnected at {speed}.\r\n" +; after a login, before the motd ({placeholders}; on the full-screen menu, +; at the top of its window) welcome = "\r\nHello {user}, on {date} at {time}.\r\n" ; the message of the day, after the welcome and before the menu: motd, ; then the file (read at each login), {placeholders} in both ;motd = "\r\nThe line is down for maintenance on Sunday.\r\n" ;motd_file = /etc/motd +; at the end of the call, before the line is hung up goodbye = "\r\nGoodbye.\r\n" ; the echo of each password character, or none password_echo = * @@ -85,6 +90,7 @@ user_pattern = ^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$ ; {time_left} (whole minutes) shows what is left, e.g. in [menu] prompt time_limit = 0 daily_limit = 0 +; where the usage, the last callers and the nodes are kept state_dir = /var/lib/modemmenu ; these long before the limit, the message is written on the line in the ; middle of a running selection (not one with time_warning = no), once @@ -103,6 +109,10 @@ detect = yes petscii = auto ; for each question a terminal may not answer timeout = 2s +; the full-screen menu's lines and shading: auto (UTF-8 when the terminal +; shows it, code page 437 for SyncTERM and other BBS terminals, a VT's +; line-drawing set for a DEC terminal), utf8, cp437, dec or ascii +charset = auto ; groups of users for the items' groups = (members of system groups count ; too, for logins by auth = system) @@ -344,18 +354,29 @@ items = bbs ppp shell passwd hangup = G goodbye bye logoff ; yes: one key selects at once, without Enter (one-character keys only) hotkeys = no +; auto: the menu full screen for a caller whose terminal can have it (ANSI, +; or a VT that said what it is, 40x14 at least): the items in a window on a +; blue desk, chosen with the cursor keys or an item's key, the welcome at +; its top and the motd in a box; others get the menu of lines. no: lines +; for every caller. The login is always lines (dialers and AOL clients +; read and answer it) +fullscreen = auto ; An item: ; key = B its key in the menu, one or more characters ; (default: its name's first letter) ; aliases = ... more words that select it (its name always does) ; label = ... its line in the menu +; help = ... a line of what it is, under the full-screen menu ; mode = exec a program on the line: command (by /bin/sh -c) ; passwd built in: the caller changes their password ; (min_password = 8 characters) ; callers built in: the last callers (count = 10), the time, ; user and rate of each login (state_dir/callers, ; the latest 200; kept only with such an item) +; setup built in: the sysop's setup, modemmenu.conf on +; full screens (as modemmenu -setup); needs users +; or groups ; tcp a TCP server, raw: host, port; send is sent first ; unix a server on a unix socket, raw: path; send too ; (tcp, telnet and rlogin take a path as well, for @@ -414,6 +435,7 @@ hotkeys = no [item bbs] key = B label = Bulletin board +help = Messages, files and doors on the board mode = rlogin host = 127.0.0.1 port = 513 @@ -502,6 +524,13 @@ after = menu ;mode = callers ;count = 10 +; the sysop's setup, on the line: the configuration on full screens +;[item setup] +;key = Z +;label = Setup +;mode = setup +;groups = sysops + ; files by ZMODEM (lrzsz): rz receives what the caller's terminal sends, ; binary, restricted to the directory, renaming rather than overwriting ;[item upload] diff --git a/samples/samples.go b/samples/samples.go new file mode 100644 index 0000000..67d821f --- /dev/null +++ b/samples/samples.go @@ -0,0 +1,13 @@ +// Copyright (C) 2026 D-Modem Authors +// SPDX-License-Identifier: AGPL-3.0-only + +// Package samples has the sample configuration in the program, for the +// help of modemmenu -setup's screens. +package samples + +import _ "embed" + +// Conf is modemmenu.conf.sample. +// +//go:embed modemmenu.conf.sample +var Conf string diff --git a/tools/confref/main.go b/tools/confref/main.go index 74db8fc..b950802 100644 --- a/tools/confref/main.go +++ b/tools/confref/main.go @@ -16,201 +16,18 @@ import ( "fmt" "log" "os" - "regexp" "strings" -) - -// kind is a kind of section: [general], [item NAME] ... -type kind struct { - name string - named bool - help string - free string // what its keys are, when they are the sysop's own names - keys []*key - byKey map[string]*key -} -// key is one key of a kind of section. -type key struct { - name string - help string - value string // the sample's (or its first commented example's) - examples []string // the other values the sample shows -} - -var ( - headerRE = regexp.MustCompile(`^;?\[([a-z_]+)(?:\s+(\S+))?\]$`) - keyRE = regexp.MustCompile(`^(;?)([a-z][a-z0-9_]*)\s*=\s*(.*)$`) - docKeyRE = regexp.MustCompile(`^;\s{2,4}([a-z][a-z0-9_]*) = (\S+)\s*(.*)$`) - docMoreRE = regexp.MustCompile(`^;\s{5,}(.*)$`) - // a value of a described key and what it does ("passwd built in") - docValueRE = regexp.MustCompile(`^([a-z0-9_]+)\s{2,}(\S.*)$`) + "github.com/D-Modem/menu/internal/confdoc" ) -// unquote is a sample value as the sysop writes it: without its quotes. -func unquote(v string) string { - if i := strings.Index(v, " ; "); i >= 0 && !strings.HasPrefix(v, `"`) { - v = v[:i] - } - v = strings.TrimSpace(v) - if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { - return v[1 : len(v)-1] - } - return v -} - -// kinds' help where the sample has none before them, or has the first -// example's -var kindHelp = map[string]string{ - "general": "The login: the log, the backends, what the caller sees, the time limits.", - "menu": "The menu: its title, prompt, items and the words that hang up.", - "item": "A menu item: a program on the line, a server to connect the caller to, or a built-in (see the item keys below).", -} - -// kinds whose keys are the sysop's own names, and what such a key is -var freeKinds = map[string]string{ - "groups": "a group: its name, then its members (`sysops = alice bob`)", -} - -func parse(sample string) []*kind { - var kinds []*kind - byName := map[string]*kind{} - get := func(name string) *kind { - if k := byName[name]; k != nil { - return k - } - k := &kind{name: name, byKey: map[string]*key{}} - byName[name] = k - kinds = append(kinds, k) - return k - } - var cur *kind - var prose []string - keyOf := func(k *kind, name string) *key { - if x := k.byKey[name]; x != nil { - return x - } - x := &key{name: name} - k.byKey[name] = x - k.keys = append(k.keys, x) - return x - } - var doc *key // the key an indented doc line describes - var pending []*key // described keys not yet given to a kind - adopt := func(k *kind) { - for _, d := range pending { - x := keyOf(k, d.name) - if x.value == "" { - x.value = d.value - } - if x.help == "" { - x.help = d.help - } - } - pending = nil - } - docBlock := false - inHeader := true // the file's own comment before the first section - afterKey := false // a comment now starts the help of the keys after it - for _, raw := range strings.Split(sample, "\n") { - line := strings.TrimRight(raw, " \t\r") - if line == "" { - prose, doc, docBlock, afterKey = nil, nil, false, false - continue - } - if m := headerRE.FindStringSubmatch(line); m != nil { - inHeader = false - cur = get(m[1]) - adopt(cur) - if m[2] != "" { - cur.named = true - } - if cur.help == "" && len(prose) > 0 { - cur.help = strings.Join(prose, " ") - } - prose, doc, afterKey = nil, nil, false - continue - } - if inHeader { - continue - } - // an indented description of keys ("; key = B its key"): of - // the kind of section whose keys or header come next - if m := docKeyRE.FindStringSubmatch(line); m != nil && (docBlock || len(prose) > 0) { - docBlock = true - doc = &key{name: m[1], value: unquote(m[2]), help: m[3]} - pending = append(pending, doc) - continue - } - if m := docMoreRE.FindStringSubmatch(line); m != nil && doc != nil { - more := m[1] - if v := docValueRE.FindStringSubmatch(more); v != nil { - more = "; `" + v[1] + "`: " + v[2] - } - sep := " " - if strings.HasPrefix(more, ";") { - sep = "" - } - doc.help = strings.TrimSpace(doc.help + sep + more) - continue - } - if m := keyRE.FindStringSubmatch(line); m != nil && cur != nil { - adopt(cur) - x := keyOf(cur, m[2]) - v := unquote(m[3]) - if x.help == "" && len(prose) > 0 { - x.help = strings.Join(prose, " ") - } - if x.value == "" && m[1] == "" { - x.value = v - } else if v != "" && v != x.value { - seen := false - for _, e := range x.examples { - seen = seen || e == v - } - if !seen { - x.examples = append(x.examples, v) - } - } - afterKey = true - continue - } - if strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") { - t := strings.TrimSpace(strings.TrimLeft(line, ";#")) - if doc != nil || docBlock || afterKey { - // prose after a block of key descriptions starts afresh - prose, doc, docBlock, afterKey = nil, nil, false, false - } - prose = append(prose, t) - } - } - if cur != nil { - adopt(cur) - } - for _, k := range kinds { - if h, ok := kindHelp[k.name]; ok { - k.help = h - } - if f, ok := freeKinds[k.name]; ok { - k.free, k.keys = f, nil - } - } - // a key with no value of its own: its first example is it - for _, k := range kinds { - for _, x := range k.keys { - if x.value == "" && len(x.examples) > 0 { - x.value, x.examples = x.examples[0], x.examples[1:] - } - } - } - return kinds -} +type kind = confdoc.Kind func header(k *kind) string { - if k.named { - return "[" + k.name + " NAME]" + if k.Named { + return "[" + k.Name + " NAME]" } - return "[" + k.name + "]" + return "[" + k.Name + "]" } func anchor(k *kind) string { @@ -255,7 +72,7 @@ full. |---|---| `) for _, k := range kinds { - help := k.help + help := k.Help if i := strings.Index(help, ". "); i >= 0 { help = help[:i+1] } @@ -263,36 +80,36 @@ full. } for _, k := range kinds { fmt.Fprintf(&b, "\n## `%s`\n\n", header(k)) - if k.help != "" { - b.WriteString(prose(k.help) + "\n\n") + if k.Help != "" { + b.WriteString(prose(k.Help) + "\n\n") } - if k.named { + if k.Named { b.WriteString("There may be any number of these, each with its own NAME.\n\n") } - if k.free != "" { - fmt.Fprintf(&b, "Each key is %s.\n", k.free) + if k.Free != "" { + fmt.Fprintf(&b, "Each key is %s.\n", k.Free) } - if len(k.keys) == 0 { + if len(k.Keys) == 0 { continue } b.WriteString("| key | sample | what it does |\n|---|---|---|\n") - for _, x := range k.keys { - help := prose(x.help) + for _, x := range k.Keys { + help := prose(x.Help) if help == "" { help = "See the section above." } - if len(x.examples) > 0 { + if len(x.Examples) > 0 { var ex []string - for _, e := range x.examples { + for _, e := range x.Examples { ex = append(ex, code(e)) } help += " Also: " + strings.Join(ex, ", ") + "." } v := "" - if x.value != "" { - v = code(x.value) + if x.Value != "" { + v = code(x.Value) } - fmt.Fprintf(&b, "| `%s` | %s | %s |\n", x.name, v, cell(help)) + fmt.Fprintf(&b, "| `%s` | %s | %s |\n", x.Name, v, cell(help)) } } b.WriteString("\n**See also:** [Configuration](configuration.md)\n") @@ -308,7 +125,7 @@ func main() { if len(os.Args) > 1 { out = os.Args[1] } - if err := os.WriteFile(out, []byte(reference(parse(string(sample)))), 0o644); err != nil { + if err := os.WriteFile(out, []byte(reference(confdoc.Parse(string(sample)))), 0o644); err != nil { log.Fatal(err) } }