detached 96202ff

fix(popup): per-process stylesheet, printable font names

The stylesheet is popup-<pid>.css and is removed with the bar (callers use CSSPath/RemoveCSS); the font name is quoted literally and non-printable runes are dropped.
commit
96202fffecf8303b4e4038e297eccc9deeb8f43f
author
brice · · 2026-10-09 06:35 UTC
parent
2742b07
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +51 -10
M internal/popup/css.go +28 -9
@@ -6,6 +6,7 @@ import (
66 "path/filepath"
77 "strconv"
88 "strings"
9+ "unicode"
910 )
1011
1112 // Inset is the space the popup stylesheet puts between the surface edge and
@@ -21,11 +22,13 @@ type Style struct {
2122
2223 func hex(c [3]uint8) string { return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) }
2324
24-// cssString makes a font name safe inside a quoted CSS string.
25+// cssString makes a font name safe inside a quoted CSS string: it drops the
26+// characters that could end the string or the declaration, and everything that
27+// does not print (control characters, bidi marks, unassigned runes).
2528 func cssString(s string) string {
2629 return strings.Map(func(r rune) rune {
2730 switch {
28- case r == '"' || r == '\\' || r == '{' || r == '}' || r == ';' || r < ' ' || r == 0x7f:
31+ case r == '"' || r == '\\' || r == '{' || r == '}' || r == ';' || !unicode.IsPrint(r):
2932 return -1
3033 }
3134 return r
@@ -37,7 +40,7 @@ func CSS(s Style) string {
3740 fg, bg, ac := hex(s.FG), hex(s.BG), hex(s.Accent)
3841 size := strconv.FormatFloat(s.Size, 'f', -1, 64)
3942 var b strings.Builder
40- fmt.Fprintf(&b, "app { background-color: %s; color: %s; font-family: %q; font-size: %spx; padding: 4px; border: 1px solid %s; }\n",
43+ fmt.Fprintf(&b, "app { background-color: %s; color: %s; font-family: \"%s\"; font-size: %spx; padding: 4px; border: 1px solid %s; }\n",
4144 bg, fg, cssString(s.Font), size, ac)
4245 b.WriteString(".title { font-weight: bold; }\n")
4346 b.WriteString(".body { opacity: 0.85; }\n")
@@ -49,15 +52,32 @@ func CSS(s Style) string {
4952 return b.String()
5053 }
5154
52-// WriteCSS stores css as popup.css in $XDG_RUNTIME_DIR/neferbar (directory
53-// 0700, file 0600, replaced atomically) and returns the path. nefergui reads
54-// its stylesheet from a path.
55-func WriteCSS(css string) (string, error) {
55+// CSSPath is the stylesheet of this process, popup-<pid>.css in
56+// $XDG_RUNTIME_DIR/neferbar: two bars never overwrite each other's file.
57+func CSSPath() (string, error) {
5658 rt := os.Getenv("XDG_RUNTIME_DIR")
5759 if rt == "" {
5860 return "", fmt.Errorf("popup: XDG_RUNTIME_DIR is not set")
5961 }
60- dir := filepath.Join(rt, "neferbar")
62+ return filepath.Join(rt, "neferbar", "popup-"+strconv.Itoa(os.Getpid())+".css"), nil
63+}
64+
65+// RemoveCSS deletes this process's stylesheet, if it was written.
66+func RemoveCSS() {
67+ if p, err := CSSPath(); err == nil {
68+ _ = os.Remove(p)
69+ }
70+}
71+
72+// WriteCSS stores css as CSSPath (directory 0700, file 0600, replaced
73+// atomically) and returns the path. nefergui reads its stylesheet from a path.
74+// Call RemoveCSS when done.
75+func WriteCSS(css string) (string, error) {
76+ path, err := CSSPath()
77+ if err != nil {
78+ return "", err
79+ }
80+ dir := filepath.Dir(path)
6181 if err := os.MkdirAll(dir, 0o700); err != nil {
6282 return "", fmt.Errorf("popup: %w", err)
6383 }
@@ -76,7 +96,6 @@ func WriteCSS(css string) (string, error) {
7696 if err == nil {
7797 err = os.Chmod(tmp, 0o600)
7898 }
79- path := filepath.Join(dir, "popup.css")
8099 if err == nil {
81100 err = os.Rename(tmp, path)
82101 }
@@ -6,6 +6,7 @@ import (
6 "path/filepath"6 "path/filepath"
7 "strconv"7 "strconv"
8 "strings"8 "strings"
9+ "unicode"
9 )10 )
10 11
11 // Inset is the space the popup stylesheet puts between the surface edge and12 // Inset is the space the popup stylesheet puts between the surface edge and
@@ -21,11 +22,13 @@ type Style struct {
21 22
22 func hex(c [3]uint8) string { return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) }23 func hex(c [3]uint8) string { return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) }
23 24
24-// cssString makes a font name safe inside a quoted CSS string.25+// cssString makes a font name safe inside a quoted CSS string: it drops the
26+// characters that could end the string or the declaration, and everything that
27+// does not print (control characters, bidi marks, unassigned runes).
25 func cssString(s string) string {28 func cssString(s string) string {
26 return strings.Map(func(r rune) rune {29 return strings.Map(func(r rune) rune {
27 switch {30 switch {
28- case r == '"' || r == '\\' || r == '{' || r == '}' || r == ';' || r < ' ' || r == 0x7f:31+ case r == '"' || r == '\\' || r == '{' || r == '}' || r == ';' || !unicode.IsPrint(r):
29 return -132 return -1
30 }33 }
31 return r34 return r
@@ -37,7 +40,7 @@ func CSS(s Style) string {
37 fg, bg, ac := hex(s.FG), hex(s.BG), hex(s.Accent)40 fg, bg, ac := hex(s.FG), hex(s.BG), hex(s.Accent)
38 size := strconv.FormatFloat(s.Size, 'f', -1, 64)41 size := strconv.FormatFloat(s.Size, 'f', -1, 64)
39 var b strings.Builder42 var b strings.Builder
40- fmt.Fprintf(&b, "app { background-color: %s; color: %s; font-family: %q; font-size: %spx; padding: 4px; border: 1px solid %s; }\n",43+ fmt.Fprintf(&b, "app { background-color: %s; color: %s; font-family: \"%s\"; font-size: %spx; padding: 4px; border: 1px solid %s; }\n",
41 bg, fg, cssString(s.Font), size, ac)44 bg, fg, cssString(s.Font), size, ac)
42 b.WriteString(".title { font-weight: bold; }\n")45 b.WriteString(".title { font-weight: bold; }\n")
43 b.WriteString(".body { opacity: 0.85; }\n")46 b.WriteString(".body { opacity: 0.85; }\n")
@@ -49,15 +52,32 @@ func CSS(s Style) string {
49 return b.String()52 return b.String()
50 }53 }
51 54
52-// WriteCSS stores css as popup.css in $XDG_RUNTIME_DIR/neferbar (directory55+// CSSPath is the stylesheet of this process, popup-<pid>.css in
53-// 0700, file 0600, replaced atomically) and returns the path. nefergui reads56+// $XDG_RUNTIME_DIR/neferbar: two bars never overwrite each other's file.
54-// its stylesheet from a path.57+func CSSPath() (string, error) {
55-func WriteCSS(css string) (string, error) {
56 rt := os.Getenv("XDG_RUNTIME_DIR")58 rt := os.Getenv("XDG_RUNTIME_DIR")
57 if rt == "" {59 if rt == "" {
58 return "", fmt.Errorf("popup: XDG_RUNTIME_DIR is not set")60 return "", fmt.Errorf("popup: XDG_RUNTIME_DIR is not set")
59 }61 }
60- dir := filepath.Join(rt, "neferbar")62+ return filepath.Join(rt, "neferbar", "popup-"+strconv.Itoa(os.Getpid())+".css"), nil
63+}
64+
65+// RemoveCSS deletes this process's stylesheet, if it was written.
66+func RemoveCSS() {
67+ if p, err := CSSPath(); err == nil {
68+ _ = os.Remove(p)
69+ }
70+}
71+
72+// WriteCSS stores css as CSSPath (directory 0700, file 0600, replaced
73+// atomically) and returns the path. nefergui reads its stylesheet from a path.
74+// Call RemoveCSS when done.
75+func WriteCSS(css string) (string, error) {
76+ path, err := CSSPath()
77+ if err != nil {
78+ return "", err
79+ }
80+ dir := filepath.Dir(path)
61 if err := os.MkdirAll(dir, 0o700); err != nil {81 if err := os.MkdirAll(dir, 0o700); err != nil {
62 return "", fmt.Errorf("popup: %w", err)82 return "", fmt.Errorf("popup: %w", err)
63 }83 }
@@ -76,7 +96,6 @@ func WriteCSS(css string) (string, error) {
76 if err == nil {96 if err == nil {
77 err = os.Chmod(tmp, 0o600)97 err = os.Chmod(tmp, 0o600)
78 }98 }
79- path := filepath.Join(dir, "popup.css")
80 if err == nil {99 if err == nil {
81 err = os.Rename(tmp, path)100 err = os.Rename(tmp, path)
82 }101 }
M internal/popup/popup_test.go +23 -1
@@ -4,6 +4,7 @@ import (
44 "os"
55 "path/filepath"
66 "slices"
7+ "strconv"
78 "strings"
89 "testing"
910
@@ -95,6 +96,14 @@ separator { background-color: #cccccc; opacity: 0.3; margin: 4px 0; }
9596 if got != want {
9697 t.Fatalf("CSS =\n%s\nwant\n%s", got, want)
9798 }
99+ // Non-ASCII names stay as they are: %q would have turned them into escapes.
100+ if got := CSS(Style{Font: "JetBrainsMono Nerd Font Mono é", Size: 10}); !strings.Contains(got, `font-family: "JetBrainsMono Nerd Font Mono é";`) {
101+ t.Fatalf("font-family changed:\n%s", got)
102+ }
103+ // Runes that do not print are dropped: controls, bidi overrides, DEL, BOM.
104+ if got := cssString("a\u202eb\u0000c\x7fd\ufeffe\u200bf\n"); got != "abcdef" {
105+ t.Fatalf("cssString = %q", got)
106+ }
98107 // A font name cannot break out of the declaration.
99108 evil := CSS(Style{Font: `x"; } body { `, Size: 10})
100109 if strings.Count(evil, "{") != strings.Count(want, "{") {
@@ -109,7 +118,7 @@ func TestWriteCSSPermissions(t *testing.T) {
109118 if err != nil {
110119 t.Fatal(err)
111120 }
112- if path != filepath.Join(dir, "neferbar", "popup.css") {
121+ if path != filepath.Join(dir, "neferbar", "popup-"+strconv.Itoa(os.Getpid())+".css") {
113122 t.Fatalf("path = %s", path)
114123 }
115124 for p, want := range map[string]os.FileMode{filepath.Dir(path): 0o700, path: 0o600} {
@@ -128,6 +137,19 @@ func TestWriteCSSPermissions(t *testing.T) {
128137 if ents, _ := os.ReadDir(filepath.Dir(path)); len(ents) != 1 {
129138 t.Fatalf("directory holds %d entries, want 1", len(ents))
130139 }
140+ // Another bar's file is left alone; ours is removed.
141+ other := filepath.Join(filepath.Dir(path), "popup-1.css")
142+ if err = os.WriteFile(other, []byte("x"), 0o600); err != nil {
143+ t.Fatal(err)
144+ }
145+ RemoveCSS()
146+ if _, err = os.Stat(path); !os.IsNotExist(err) {
147+ t.Fatalf("RemoveCSS left %s (%v)", path, err)
148+ }
149+ if _, err = os.Stat(other); err != nil {
150+ t.Fatalf("RemoveCSS touched another bar's file: %v", err)
151+ }
152+ RemoveCSS() // twice is fine
131153 t.Setenv("XDG_RUNTIME_DIR", "")
132154 if _, err = WriteCSS("x"); err == nil {
133155 t.Fatal("no runtime dir must fail")
@@ -4,6 +4,7 @@ import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6 "slices"6 "slices"
7+ "strconv"
7 "strings"8 "strings"
8 "testing"9 "testing"
9 10
@@ -95,6 +96,14 @@ separator { background-color: #cccccc; opacity: 0.3; margin: 4px 0; }
95 if got != want {96 if got != want {
96 t.Fatalf("CSS =\n%s\nwant\n%s", got, want)97 t.Fatalf("CSS =\n%s\nwant\n%s", got, want)
97 }98 }
99+ // Non-ASCII names stay as they are: %q would have turned them into escapes.
100+ if got := CSS(Style{Font: "JetBrainsMono Nerd Font Mono é", Size: 10}); !strings.Contains(got, `font-family: "JetBrainsMono Nerd Font Mono é";`) {
101+ t.Fatalf("font-family changed:\n%s", got)
102+ }
103+ // Runes that do not print are dropped: controls, bidi overrides, DEL, BOM.
104+ if got := cssString("a\u202eb\u0000c\x7fd\ufeffe\u200bf\n"); got != "abcdef" {
105+ t.Fatalf("cssString = %q", got)
106+ }
98 // A font name cannot break out of the declaration.107 // A font name cannot break out of the declaration.
99 evil := CSS(Style{Font: `x"; } body { `, Size: 10})108 evil := CSS(Style{Font: `x"; } body { `, Size: 10})
100 if strings.Count(evil, "{") != strings.Count(want, "{") {109 if strings.Count(evil, "{") != strings.Count(want, "{") {
@@ -109,7 +118,7 @@ func TestWriteCSSPermissions(t *testing.T) {
109 if err != nil {118 if err != nil {
110 t.Fatal(err)119 t.Fatal(err)
111 }120 }
112- if path != filepath.Join(dir, "neferbar", "popup.css") {121+ if path != filepath.Join(dir, "neferbar", "popup-"+strconv.Itoa(os.Getpid())+".css") {
113 t.Fatalf("path = %s", path)122 t.Fatalf("path = %s", path)
114 }123 }
115 for p, want := range map[string]os.FileMode{filepath.Dir(path): 0o700, path: 0o600} {124 for p, want := range map[string]os.FileMode{filepath.Dir(path): 0o700, path: 0o600} {
@@ -128,6 +137,19 @@ func TestWriteCSSPermissions(t *testing.T) {
128 if ents, _ := os.ReadDir(filepath.Dir(path)); len(ents) != 1 {137 if ents, _ := os.ReadDir(filepath.Dir(path)); len(ents) != 1 {
129 t.Fatalf("directory holds %d entries, want 1", len(ents))138 t.Fatalf("directory holds %d entries, want 1", len(ents))
130 }139 }
140+ // Another bar's file is left alone; ours is removed.
141+ other := filepath.Join(filepath.Dir(path), "popup-1.css")
142+ if err = os.WriteFile(other, []byte("x"), 0o600); err != nil {
143+ t.Fatal(err)
144+ }
145+ RemoveCSS()
146+ if _, err = os.Stat(path); !os.IsNotExist(err) {
147+ t.Fatalf("RemoveCSS left %s (%v)", path, err)
148+ }
149+ if _, err = os.Stat(other); err != nil {
150+ t.Fatalf("RemoveCSS touched another bar's file: %v", err)
151+ }
152+ RemoveCSS() // twice is fine
131 t.Setenv("XDG_RUNTIME_DIR", "")153 t.Setenv("XDG_RUNTIME_DIR", "")
132 if _, err = WriteCSS("x"); err == nil {154 if _, err = WriteCSS("x"); err == nil {
133 t.Fatal("no runtime dir must fail")155 t.Fatal("no runtime dir must fail")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 5

Tags 4

↑↓ moveenter openesc close