xterm: the terminal holds node-terminal-emulator; its resources through xorg's slot (hq ADR 0208)
It requires x11-display, names itself in TERMINAL, and contributes its X resources to the xresources slot normal: today's palette and clipboard keys, JetBrainsMono Nerd Font, 10000 lines of scrollback, all scoped to XTerm* instead of every Xt program. It owns no file. The tools are the seat's open, which starts a terminal through the account's service manager so it outlives the runtime's restarts, and font (in force, what fontconfig resolves it to, set for new terminals) and colours.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
// xterm's tools (novox/hq ADR 0208, research 026/05): node-terminal-emulator's verb `open`, and the
|
||||
// module's own `font` and `colours`.
|
||||
//
|
||||
// A terminal opened by `open` is handed to the account's own service manager (internal/desktop's
|
||||
// Launch), never left a child of the runtime, which the service manager empties at every restart.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(xterm{d: desktop.Machine("i3", "sway")})); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func call(run func(ctx context.Context, a desktop.Args) (any, error)) func(map[string]any) (any, error) {
|
||||
return func(args map[string]any) (any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
return run(ctx, desktop.Args(args))
|
||||
}
|
||||
}
|
||||
|
||||
func tools(x xterm) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "node-terminal-emulator.open",
|
||||
Description: "Open a terminal window in the operator's session: the account's login shell, or a command " +
|
||||
"run through it, in a directory (the home when none is named). hold keeps the window open after " +
|
||||
"the command ends. Answers the unit it runs as.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"command": desktop.Str("what to run in it (optional; the login shell when absent)"),
|
||||
"directory": desktop.Str("where it starts (optional; the account's home)"),
|
||||
"title": desktop.Str("the window's title (optional)"),
|
||||
"hold": desktop.Flag("keep the window open after the command ends (default false)"),
|
||||
}),
|
||||
Run: call(x.open),
|
||||
},
|
||||
{
|
||||
Name: "xterm_font",
|
||||
Description: "The face and size xterm uses, and the installed font that face resolves to. With face " +
|
||||
"and/or size, set them for terminals opened from now on (an installed face only); the module's " +
|
||||
"resources set the default again at the next login.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"face": desktop.Str("a font family, as fc-list names it"),
|
||||
"size": desktop.Num("points, 6 to 48"),
|
||||
}),
|
||||
Run: call(x.font),
|
||||
},
|
||||
{
|
||||
Name: "xterm_colours",
|
||||
Description: "xterm's colours in force: background, foreground, cursor and the sixteen palette entries, " +
|
||||
"as #rrggbb, each beside the module's default and whether it differs.",
|
||||
Input: desktop.Schema(map[string]any{}),
|
||||
Run: call(x.colours),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
type xterm struct{ d desktop.Desk }
|
||||
|
||||
// The module's defaults: the face research 026/04 chose and both workstations' palette.
|
||||
const (
|
||||
Face = "JetBrainsMono Nerd Font"
|
||||
Size = 12
|
||||
)
|
||||
|
||||
// Palette is the colours both workstations ran, by resource name, in the order they are written.
|
||||
var Palette = [][2]string{
|
||||
{"background", "#000000"}, {"foreground", "#a8a8a8"},
|
||||
{"color0", "#000000"}, {"color8", "#666666"},
|
||||
{"color1", "#9e1828"}, {"color9", "#bc5766"},
|
||||
{"color2", "#008800"}, {"color10", "#61a171"},
|
||||
{"color3", "#d2bb4b"}, {"color11", "#e7db52"},
|
||||
{"color4", "#414171"}, {"color12", "#5085af"},
|
||||
{"color5", "#963c59"}, {"color13", "#a97a99"},
|
||||
{"color6", "#418179"}, {"color14", "#6ba4a4"},
|
||||
{"color7", "#bebebe"}, {"color15", "#ffffff"},
|
||||
}
|
||||
|
||||
// Resources is the module's contribution to the X resources (the xresources slot `normal`), as the
|
||||
// manifest carries it; the manifest test holds the two to one text.
|
||||
func Resources() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they\n")
|
||||
b.WriteString("! outrank any `*name` resource of yours and touch no other X program.\n")
|
||||
for _, kv := range [][2]string{
|
||||
{"termName", "xterm-256color"}, {"saveLines", "10000"}, {"renderFont", "true"},
|
||||
{"faceName", Face}, {"faceSize", strconv.Itoa(Size)},
|
||||
{"boldMode", "false"}, {"alwaysBoldMode", "false"}, {"allowBoldFonts", "true"},
|
||||
} {
|
||||
fmt.Fprintf(&b, "XTerm*%s: %s\n", kv[0], kv[1])
|
||||
}
|
||||
for _, kv := range Palette {
|
||||
fmt.Fprintf(&b, "XTerm*%s: %s\n", kv[0], kv[1])
|
||||
}
|
||||
b.WriteString("XTerm*VT100.Translations: #override \\n\\\n")
|
||||
b.WriteString(" Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \\n\\\n")
|
||||
b.WriteString(" Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// openScript runs in the new terminal: into the directory, then the login shell, or a command
|
||||
// through it so the account's environment and aliases are there.
|
||||
const openScript = `cd -- "$1" || exit 1
|
||||
if [ -n "$2" ]; then exec "${SHELL:-/bin/sh}" -lc "$2"; fi
|
||||
exec "${SHELL:-/bin/sh}" -l`
|
||||
|
||||
// OpenArgs is the xterm command line open starts.
|
||||
func OpenArgs(dir, command, title string, hold bool) []string {
|
||||
args := []string{"xterm"}
|
||||
if title != "" {
|
||||
args = append(args, "-title", title)
|
||||
}
|
||||
if hold {
|
||||
args = append(args, "-hold")
|
||||
}
|
||||
return append(args, "-e", "/bin/sh", "-c", openScript, "sh", dir, command)
|
||||
}
|
||||
|
||||
func (x xterm) open(ctx context.Context, a desktop.Args) (any, error) {
|
||||
s, err := x.d.Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := a.Opt("directory", desktop.Home())
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
if dir, err = desktop.InHome(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||
return nil, fmt.Errorf("%s is not a directory here", dir)
|
||||
}
|
||||
hold, _, err := a.Bool("hold")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
argv := OpenArgs(dir, a.Opt("command", ""), a.Opt("title", ""), hold)
|
||||
launched, err := x.d.Launch(ctx, s, "xterm", argv...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"session": s.Display, "directory": dir, "command": a.Opt("command", "(the login shell)"), "launched": launched}, nil
|
||||
}
|
||||
|
||||
// Effective is a resource's value as an XTerm sees it in `xrdb -query`: its XTerm*-scoped value, else
|
||||
// the global *-scoped one.
|
||||
func Effective(query, name string) string {
|
||||
var global string
|
||||
for _, l := range strings.Split(query, "\n") {
|
||||
k, v, ok := strings.Cut(l, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(k) {
|
||||
case "XTerm*" + name, "XTerm." + name, "XTerm*vt100." + name, "XTerm*VT100." + name:
|
||||
return strings.TrimSpace(v)
|
||||
case "*" + name:
|
||||
global = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return global
|
||||
}
|
||||
|
||||
var faceName = regexp.MustCompile(`^[A-Za-z0-9 ._-]{1,64}$`)
|
||||
|
||||
func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
s, err := x.d.Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := s.Env(x.d.Base)
|
||||
face := a.Opt("face", "")
|
||||
size, sized, err := a.Number("size")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if face != "" || sized {
|
||||
var res []string
|
||||
if face != "" {
|
||||
if !faceName.MatchString(face) {
|
||||
return nil, fmt.Errorf("a face is a family name, as fc-list names it")
|
||||
}
|
||||
if fam := x.d.Run(ctx, env, nil, "fc-list", face, "family"); strings.TrimSpace(fam.Stdout) == "" {
|
||||
return nil, fmt.Errorf("no installed font is %q: the fonts module installs the mesh's faces", face)
|
||||
}
|
||||
res = append(res, "XTerm*faceName: "+face)
|
||||
}
|
||||
if sized {
|
||||
if size < 6 || size > 48 {
|
||||
return nil, fmt.Errorf("size is from 6 to 48 points")
|
||||
}
|
||||
res = append(res, "XTerm*faceSize: "+strconv.FormatFloat(size, 'f', -1, 64))
|
||||
}
|
||||
if r := x.d.Run(ctx, env, []byte(strings.Join(res, "\n")+"\n"), "xrdb", "-nocpp", "-merge", "-"); !r.OK() {
|
||||
return nil, r.Err()
|
||||
}
|
||||
}
|
||||
q := x.d.Run(ctx, env, nil, "xrdb", "-query")
|
||||
if !q.OK() {
|
||||
return nil, q.Err()
|
||||
}
|
||||
inForce := Effective(q.Stdout, "faceName")
|
||||
answer := map[string]any{
|
||||
"session": s.Display, "face": inForce, "size": Effective(q.Stdout, "faceSize"),
|
||||
"default": map[string]any{"face": Face, "size": Size},
|
||||
}
|
||||
if inForce != "" {
|
||||
m := x.d.Run(ctx, env, nil, "fc-match", "-f", "%{family[0]}|%{style[0]}|%{file}", inForce)
|
||||
if parts := strings.SplitN(m.Stdout, "|", 3); m.OK() && len(parts) == 3 {
|
||||
answer["resolves_to"] = map[string]any{"family": parts[0], "style": parts[1], "file": parts[2],
|
||||
"exact": strings.EqualFold(parts[0], inForce)}
|
||||
}
|
||||
}
|
||||
if face != "" || sized {
|
||||
answer["lasts"] = "for terminals opened from now on, until the next login"
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
var rgbSpec = regexp.MustCompile(`^rgb:([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})$`)
|
||||
|
||||
// Hex writes an X colour (`rgb:9e/18/28`, `#9e1828`) as #rrggbb; anything else (a colour name) as it is.
|
||||
func Hex(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if m := rgbSpec.FindStringSubmatch(v); m != nil {
|
||||
out := "#"
|
||||
for _, c := range m[1:] {
|
||||
n, _ := strconv.ParseUint(c, 16, 32)
|
||||
max := uint64(1)<<(4*len(c)) - 1
|
||||
out += fmt.Sprintf("%02x", (n*255+max/2)/max)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return strings.ToLower(v)
|
||||
}
|
||||
|
||||
func (x xterm) colours(ctx context.Context, a desktop.Args) (any, error) {
|
||||
res, s, err := x.d.InSession(ctx, "xrdb", "-query")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !res.OK() {
|
||||
return nil, res.Err()
|
||||
}
|
||||
type colour struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Default string `json:"default"`
|
||||
Differs bool `json:"differs,omitempty"`
|
||||
}
|
||||
var out []colour
|
||||
for _, kv := range Palette {
|
||||
v := Hex(Effective(res.Stdout, kv[0]))
|
||||
out = append(out, colour{kv[0], v, kv[1], v != kv[1]})
|
||||
}
|
||||
cursor := Hex(Effective(res.Stdout, "cursorColor"))
|
||||
return map[string]any{"session": s.Display, "colours": out, "cursor": cursor}, nil
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Tools []string `json:"tools"`
|
||||
Environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
} `json:"environment"`
|
||||
Shell []struct {
|
||||
For, Slot, Code string
|
||||
} `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestItHoldsTheTerminalSeatOnAnXDisplayAndNamesItselfTheTerminal(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != "node-terminal-emulator" || strings.Join(m.Claims[0].Serves, ",") != "open" {
|
||||
t.Fatalf("%+v", m.Claims)
|
||||
}
|
||||
if strings.Join(m.Requires, ",") != "x11-display" || m.Environment.Variables["TERMINAL"] != "xterm" || len(m.Environment.Variables) != 1 {
|
||||
t.Fatalf("%v %v", m.Requires, m.Environment)
|
||||
}
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools(xterm{}) {
|
||||
served[tool.Name] = true
|
||||
}
|
||||
if !served["node-terminal-emulator.open"] || len(served) != len(m.Tools)+1 {
|
||||
t.Fatalf("%v %v", served, m.Tools)
|
||||
}
|
||||
for _, n := range m.Tools {
|
||||
if !served[n] {
|
||||
t.Errorf("%s", n)
|
||||
}
|
||||
}
|
||||
if len(m.Resources) != 1 || m.Resources[0]["package"] != "xterm" {
|
||||
t.Fatalf("%v", m.Resources)
|
||||
}
|
||||
if a := m.Build.Artifacts[0]; a["binary"] != "xterm-tools" || a["from"] != "cmd/xterm-tools" {
|
||||
t.Fatalf("a binary not named xterm, so nothing looking for xterm by name finds the tools: %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItsResourcesAreOneContributionInTheNormalSlotScopedToXTerm(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Shell) != 1 || m.Shell[0].For != "xresources" || m.Shell[0].Slot != "normal" {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
if m.Shell[0].Code != Resources() {
|
||||
t.Fatal("the manifest's resources are not the ones the bundle describes: regenerate the manifest from Resources()")
|
||||
}
|
||||
for _, l := range strings.Split(strings.TrimSpace(Resources()), "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(l, "!"), strings.HasPrefix(l, " "):
|
||||
case strings.HasPrefix(l, "#"):
|
||||
t.Errorf("merged with -nocpp, a # line is skipped: %q", l)
|
||||
case !strings.HasPrefix(l, "XTerm*"):
|
||||
t.Errorf("a resource of another program, or of every one: %q", l)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(Resources(), "XTerm*faceName: JetBrainsMono Nerd Font") || !strings.Contains(Resources(), "copy-selection(CLIPBOARD)") {
|
||||
t.Fatal("the chosen face and the clipboard keys")
|
||||
}
|
||||
}
|
||||
|
||||
type fake struct {
|
||||
ran []string
|
||||
stdin []string
|
||||
query string
|
||||
}
|
||||
|
||||
func (f *fake) desk(session bool) desktop.Desk {
|
||||
return desktop.Desk{
|
||||
Find: func() (*desktop.Session, error) {
|
||||
if !session {
|
||||
return nil, &desktop.NoSession{Reason: "none"}
|
||||
}
|
||||
return &desktop.Session{Display: ":1", XAuthority: "/h/.Xauthority", Type: "x11", RuntimeDir: "/run/user/1000", Bus: "unix:path=/run/user/1000/bus"}, nil
|
||||
},
|
||||
Run: func(_ context.Context, _ []string, stdin []byte, name string, args ...string) desktop.Result {
|
||||
f.ran = append(f.ran, strings.Join(append([]string{name}, args...), " "))
|
||||
f.stdin = append(f.stdin, string(stdin))
|
||||
switch name {
|
||||
case "xrdb":
|
||||
return desktop.Result{Stdout: f.query}
|
||||
case "fc-list":
|
||||
if strings.Contains(args[0], "Missing") {
|
||||
return desktop.Result{}
|
||||
}
|
||||
return desktop.Result{Stdout: args[0] + "\n"}
|
||||
case "fc-match":
|
||||
return desktop.Result{Stdout: "JetBrainsMono Nerd Font|Regular|/usr/share/fonts/TTF/J.ttf"}
|
||||
}
|
||||
return desktop.Result{}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenHandsTheTerminalToTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
|
||||
f := &fake{}
|
||||
dir := t.TempDir()
|
||||
got, err := xterm{d: f.desk(true)}.open(context.Background(), desktop.Args{"directory": dir, "command": "htop", "hold": true, "title": "top"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.ran) != 1 || !strings.HasPrefix(f.ran[0], "systemd-run --user --collect --quiet --unit=mesh-xterm-") {
|
||||
t.Fatalf("%q", f.ran)
|
||||
}
|
||||
for _, want := range []string{"--setenv=DISPLAY=:1", "--setenv=XAUTHORITY=/h/.Xauthority", "-- xterm -title top -hold -e /bin/sh -c", "sh " + dir + " htop"} {
|
||||
if !strings.Contains(f.ran[0], want) {
|
||||
t.Errorf("lacks %q: %s", want, f.ran[0])
|
||||
}
|
||||
}
|
||||
if !strings.Contains(asJSON(got), `"unit":"mesh-xterm-`) {
|
||||
t.Fatalf("%s", asJSON(got))
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).open(context.Background(), desktop.Args{"directory": "/no/such/dir"}); err == nil {
|
||||
t.Fatal("a directory that is not there")
|
||||
}
|
||||
if _, err := (xterm{d: (&fake{}).desk(false)}).open(context.Background(), desktop.Args{}); !desktop.IsNoSession(err) {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheTerminalsScriptStartsInTheDirectoryAndRunsTheCommandThroughTheShell(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
args := OpenArgs(dir, "pwd > out", "", false)
|
||||
sh := args[len(args)-6:] // /bin/sh -c script sh dir command
|
||||
cmd := exec.Command(sh[0], sh[1:]...)
|
||||
cmd.Env = []string{"SHELL=/bin/sh", "PATH=/usr/bin:/bin", "HOME=" + dir}
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(dir, "out"))
|
||||
if strings.TrimSpace(string(b)) != dir {
|
||||
t.Fatalf("%q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFontAndColoursAreReadAsAnXTermSeesThem(t *testing.T) {
|
||||
q := "*faceName:\tHack Nerd Font\nXTerm*faceName:\tJetBrainsMono Nerd Font\nXTerm*faceSize:\t12\n" +
|
||||
"XTerm*background:\t#000000\n*foreground:\trgb:a8/a8/a8\nXTerm*color1:\trgb:ff/00/00\n"
|
||||
if Effective(q, "faceName") != "JetBrainsMono Nerd Font" || Effective(q, "foreground") != "rgb:a8/a8/a8" {
|
||||
t.Fatal("XTerm's own value outranks the global one; the global one stands alone")
|
||||
}
|
||||
for in, want := range map[string]string{"rgb:9e/18/28": "#9e1828", "rgb:f/0/8": "#ff0088", "rgb:ffff/0000/8080": "#ff0080", "#ABCDEF": "#abcdef", "red": "red"} {
|
||||
if got := Hex(in); got != want {
|
||||
t.Errorf("%s: %s", in, got)
|
||||
}
|
||||
}
|
||||
f := &fake{query: q}
|
||||
got, err := xterm{d: f.desk(true)}.colours(context.Background(), desktop.Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := asJSON(got)
|
||||
if !strings.Contains(j, `{"name":"color1","value":"#ff0000","default":"#9e1828","differs":true}`) || !strings.Contains(j, `{"name":"foreground","value":"#a8a8a8","default":"#a8a8a8"}`) {
|
||||
t.Fatalf("%s", j)
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).font(context.Background(), desktop.Args{"face": "Missing Font"}); err == nil {
|
||||
t.Fatal("a face that is not installed")
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).font(context.Background(), desktop.Args{"size": 99.0}); err == nil {
|
||||
t.Fatal("size out of range")
|
||||
}
|
||||
f.ran, f.stdin = nil, nil
|
||||
got, err = xterm{d: f.desk(true)}.font(context.Background(), desktop.Args{"face": "Inter", "size": 13.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.stdin[1] != "XTerm*faceName: Inter\nXTerm*faceSize: 13\n" || !strings.Contains(asJSON(got), `"lasts"`) {
|
||||
t.Fatalf("%q %s", f.stdin, asJSON(got))
|
||||
}
|
||||
}
|
||||
|
||||
func asJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user