detached 38d28a5

feat(text): draw italic, bold italic, underline and strikethrough, and warn when the font is missing

commit
38d28a55710b441361c6cc5f8385c28774260ab9
author
brice · · 2026-10-05 15:03 UTC
parent
41ad4a0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +422 -120
M README.md +144 -36
@@ -1,77 +1,185 @@
11 # neferbar
22
3-A one-cell-high, terminal-style status bar for Wayland (wlr-layer-shell). Modules are scripts that print ANSI text; the bar draws it with a monospace Nerd Font through raw Vulkan. It is read-only, follows the monitor's fractional scale, and renders only when a module changes, so an animated module can reach the compositor's frame rate and an idle bar costs nothing.
3+A dead simple status bar for Wayland: **one line of terminal text, nothing else.**
44
5-## Requirements
5+Each part of the bar is a script. A script prints text, and neferbar shows it. If you can write `echo`, you can write a module.
66
7-- A compositor with `zwlr_layer_shell_v1`, `zwp_linux_dmabuf_v1` v4 and `wp_linux_drm_syncobj_v1` (and, for fractional scale, `wp_fractional_scale_v1` with `wp_viewporter`). GNOME is not supported.
8-- Linux 6.6 or newer, a Vulkan 1.3 driver with DMA-BUF export, and a Nerd Font installed (`fc-match` must find it).
9-- Go 1.27.
7+```
8+ workspace 2 firefox 14:32:07 cpu 12% vol 40% bat 88%
9+```
10+
11+- **Text only.** ASCII, Unicode and Nerd Font icons. No images, no widgets, no clicks.
12+- **Exactly one character high.** The bar is as tall as a terminal row, and it follows your monitor's scale.
13+- **Scripts do the work.** Any language, anything that prints to stdout.
14+- **Fast when you ask.** Print 60 times a second and the bar redraws 60 times a second. Print nothing and it uses no CPU or GPU.
1015
11-## Build and run
16+It needs a compositor with `wlr-layer-shell`, such as Sway, Hyprland, Niri, River or NeferWL. GNOME is not supported.
17+
18+## Install
19+
20+You need Go 1.27, a Vulkan 1.3 GPU driver, and a [Nerd Font](https://www.nerdfonts.com/).
1221
1322 ```sh
1423 go build -o neferbar ./cmd/neferbar
15-./neferbar -config examples/config.toml
24+./neferbar
1625 ```
1726
18-Flags: `-config <file>`, `-display <socket>`, `-pprof <loopback addr>`, `-memstats <interval>`.
27+Start it from your compositor's autostart. For NeferWL:
1928
20-The Wayland socket comes from `-display`, then `$NEFERBAR_DISPLAY`, then `$WAYLAND_DISPLAY`, then `wayland-0`. A relative name lives in `$XDG_RUNTIME_DIR`; an absolute path is used as is.
29+```
30+startup = /full/path/to/neferbar
31+```
2132
22-## Configuration
33+## Configure
2334
24-`$XDG_CONFIG_HOME/neferbar/config.toml`:
35+Create `~/.config/neferbar/config.toml`:
2536
2637 ```toml
2738 [bar]
28-font = "JetBrainsMono Nerd Font Mono"
29-size = 14 # logical pixels
30-scale = 1.0 # multiplier on top of the monitor scale
31-output = "" # wl_output name; empty lets the compositor choose
39+font = "JetBrainsMono Nerd Font Mono" # use a "Mono" Nerd Font so icons fit one cell
40+size = 14 # text size
3241 background = "#1e1e2e"
3342 foreground = "#cdd6f4"
3443
3544 [[module]]
36-name = "clock"
37-zone = "center" # left | center | right
38-exec = "clock.sh"
45+name = "clock" # a unique name
46+zone = "center" # left, center or right
47+exec = "~/.config/neferbar/clock.sh"
48+```
49+
50+Add one `[[module]]` block per script. Save the file and the bar updates immediately. If you make a typo, the bar keeps the old config and prints the error.
51+
52+| Setting | Default | Meaning |
53+|---|---|---|
54+| `bar.font` | `JetBrainsMono Nerd Font Mono` | A font family known to fontconfig (`fc-list`). |
55+| `bar.size` | `14` | Text size in logical pixels. |
56+| `bar.scale` | `1.0` | Extra zoom on top of the monitor scale. |
57+| `bar.output` | any | A monitor name such as `HDMI-A-1`. Needs a restart. |
58+| `bar.background`, `bar.foreground` | catppuccin | `#rrggbb`. |
59+
60+If the font is not installed, neferbar warns and falls back to another font, and icons may be missing.
61+
62+## Write a script
63+
64+A module is any program that prints lines. **Each line replaces what the module showed before.**
65+
66+The smallest module:
67+
68+```sh
69+#!/bin/sh
70+echo "hello"
71+sleep 100000
3972 ```
4073
41-## Live reload
74+A clock, updated every second:
4275
43-The config file is watched, including editors that save through a temp file and symlinked dotfiles. A valid change applies at once; a broken, invalid or deleted file is logged and the running config stays.
76+```sh
77+#!/bin/sh
78+while true; do
79+ date +%H:%M:%S
80+ sleep 1
81+done
82+```
83+
84+Make it executable (`chmod +x clock.sh`) and put its path in `exec`. That's all.
85+
86+Rules:
4487
45-- Colors, font, size and scale apply live; the bar rebuilds its font and recreates the surface if its height changes.
46-- Modules are matched by `name`: an unchanged one keeps running, a removed one is stopped, a new or changed one is started.
47-- `bar.output` needs a restart, and the bar says so.
48-- A module script that you edit on disk is picked up when the script restarts, not before.
88+1. Print one line, then print another line later. The new line replaces the old one.
89+2. A frame ends at a newline (`\n`) or a form feed (`\f`). Use `\f` if the frame itself should not end the line.
90+3. Keep the script running. A script that exits is restarted after a short delay, and the bar shows `[name!]` in the meantime.
91+4. Print errors to stderr. They go to the bar's log and not to the screen.
92+5. Frames are cut to 16 KiB, and text wider than the bar is clipped.
4993
50-## Modules
94+### Colors and styles
5195
52-A module is a long-running `/bin/sh -c` command. Its stdout is ANSI text (SGR colors, bold, inverse, dim). A frame ends at a newline or a form feed, and each frame replaces the previous one. Only the latest frame matters: a script that prints faster than the bar draws overwrites its pending frame.
96+Use ordinary terminal escape codes. If it works in a terminal, it works here.
5397
54-- Animate by printing frames quickly, ending each with `\f`. See `examples/modules/rainbow.sh`.
55-- A module that exits is restarted with backoff and shows `[name!]` while it is down. Its stderr goes to the bar's log.
56-- Left and right zones keep their text; the center is cut first when they would overlap.
98+```sh
99+printf '\033[32mOK\033[0m \033[1;31mFAIL\033[0m\n'
100+```
57101
58-## Height and scale
102+| Code | Effect |
103+|---|---|
104+| `\033[1m` | **bold** |
105+| `\033[2m` | dim |
106+| `\033[3m` | *italic* |
107+| `\033[4m` | underline |
108+| `\033[7m` | inverse |
109+| `\033[9m` | ~~strikethrough~~ |
110+| `\033[30m`..`\033[37m` | foreground colors |
111+| `\033[40m`..`\033[47m` | background colors |
112+| `\033[38;5;Nm` / `\033[48;5;Nm` | 256-color palette |
113+| `\033[38;2;R;G;Bm` / `\033[48;2;R;G;Bm` | true color |
114+| `\033[0m` | reset |
59115
60-The bar is exactly one cell high: `cell = ceil(size × scale × monitor scale)` physical pixels, and the layer surface height is `ceil(cell / monitor scale)` logical pixels. When the monitor scale changes the font is rebuilt, and the surface is recreated if its height changes.
116+Italic, bold and bold italic use the font's own files when it has them.
61117
62-## Profiling
118+### Icons
119+
120+Nerd Font icons are ordinary characters. Print them from a script with their UTF-8 bytes:
121+
122+```sh
123+printf '\357\200\227 %s\n' "$(date +%H:%M)" # clock icon
124+```
125+
126+Find icon codes at <https://www.nerdfonts.com/cheat-sheet>.
127+
128+### Animation
129+
130+Print faster. Each frame ends with `\f`:
131+
132+```sh
133+#!/bin/sh
134+# a spinner at 20 fps
135+while true; do
136+ for c in '|' '/' '-' '\'; do
137+ printf '%s\f' "$c"
138+ sleep 0.05
139+ done
140+done
141+```
142+
143+neferbar draws at most as often as your monitor refreshes. If a script prints faster than that, only the latest frame is drawn. A script that prints nothing costs nothing.
144+
145+### Example modules
146+
147+The `examples/modules` directory has three:
148+
149+- `static.sh`: one line, then idle.
150+- `clock.sh`: a clock with an icon.
151+- `rainbow.sh [fps] [width]`: a 60 fps scrolling rainbow.
152+
153+## Layout
154+
155+The bar has three zones. Modules in the same zone appear side by side, in the order of the config file.
156+
157+- **left** starts at the left edge.
158+- **right** ends at the right edge.
159+- **center** sits in the middle of the space that is left. If the bar is too narrow, the center is cut first.
160+
161+## Command line
162+
163+```
164+neferbar [-config file] [-display socket] [-pprof addr] [-memstats interval]
165+```
63166
64-`-pprof localhost:6060` serves the pprof handlers on a loopback address and records every allocation. `-memstats 5s` logs allocation counters. The tests assert zero allocations per frame for the draw path, the layout and the module hand-off.
167+| Flag | Meaning |
168+|---|---|
169+| `-config` | Config file. Default: `$XDG_CONFIG_HOME/neferbar/config.toml`. |
170+| `-display` | Wayland socket name or path. Default: `$NEFERBAR_DISPLAY`, then `$WAYLAND_DISPLAY`. |
171+| `-pprof` | Serve Go profiling on a loopback address, such as `localhost:6060`. |
172+| `-memstats` | Log allocation counters at this interval. |
65173
66-## Testing
174+## Test without touching your desktop
67175
68176 ```sh
69177 go test ./...
70-scripts/headless.sh examples/config.toml 5 # nested headless NeferWL, screenshots in /tmp/neferbar-shots
178+scripts/headless.sh examples/config.toml 5 # runs in a nested headless NeferWL
71179 SCALE=1.5 SIZE=1200x200 scripts/headless.sh examples/config.toml 5
72180 ```
73181
74-The GPU tests skip when no render node or Vulkan device is available.
182+Screenshots land in `/tmp/neferbar-shots`.
75183
76184 ## License
77185
@@ -1,77 +1,185 @@
1 # neferbar1 # neferbar
2 2
3-A one-cell-high, terminal-style status bar for Wayland (wlr-layer-shell). Modules are scripts that print ANSI text; the bar draws it with a monospace Nerd Font through raw Vulkan. It is read-only, follows the monitor's fractional scale, and renders only when a module changes, so an animated module can reach the compositor's frame rate and an idle bar costs nothing.3+A dead simple status bar for Wayland: **one line of terminal text, nothing else.**
4 4
5-## Requirements5+Each part of the bar is a script. A script prints text, and neferbar shows it. If you can write `echo`, you can write a module.
6 6
7-- A compositor with `zwlr_layer_shell_v1`, `zwp_linux_dmabuf_v1` v4 and `wp_linux_drm_syncobj_v1` (and, for fractional scale, `wp_fractional_scale_v1` with `wp_viewporter`). GNOME is not supported.7+```
8-- Linux 6.6 or newer, a Vulkan 1.3 driver with DMA-BUF export, and a Nerd Font installed (`fc-match` must find it).8+ workspace 2 firefox 14:32:07 cpu 12% vol 40% bat 88%
9-- Go 1.27.9+```
10+
11+- **Text only.** ASCII, Unicode and Nerd Font icons. No images, no widgets, no clicks.
12+- **Exactly one character high.** The bar is as tall as a terminal row, and it follows your monitor's scale.
13+- **Scripts do the work.** Any language, anything that prints to stdout.
14+- **Fast when you ask.** Print 60 times a second and the bar redraws 60 times a second. Print nothing and it uses no CPU or GPU.
10 15
11-## Build and run16+It needs a compositor with `wlr-layer-shell`, such as Sway, Hyprland, Niri, River or NeferWL. GNOME is not supported.
17+
18+## Install
19+
20+You need Go 1.27, a Vulkan 1.3 GPU driver, and a [Nerd Font](https://www.nerdfonts.com/).
12 21
13 ```sh22 ```sh
14 go build -o neferbar ./cmd/neferbar23 go build -o neferbar ./cmd/neferbar
15-./neferbar -config examples/config.toml24+./neferbar
16 ```25 ```
17 26
18-Flags: `-config <file>`, `-display <socket>`, `-pprof <loopback addr>`, `-memstats <interval>`.27+Start it from your compositor's autostart. For NeferWL:
19 28
20-The Wayland socket comes from `-display`, then `$NEFERBAR_DISPLAY`, then `$WAYLAND_DISPLAY`, then `wayland-0`. A relative name lives in `$XDG_RUNTIME_DIR`; an absolute path is used as is.29+```
30+startup = /full/path/to/neferbar
31+```
21 32
22-## Configuration33+## Configure
23 34
24-`$XDG_CONFIG_HOME/neferbar/config.toml`:35+Create `~/.config/neferbar/config.toml`:
25 36
26 ```toml37 ```toml
27 [bar]38 [bar]
28-font = "JetBrainsMono Nerd Font Mono"39+font = "JetBrainsMono Nerd Font Mono" # use a "Mono" Nerd Font so icons fit one cell
29-size = 14 # logical pixels40+size = 14 # text size
30-scale = 1.0 # multiplier on top of the monitor scale
31-output = "" # wl_output name; empty lets the compositor choose
32 background = "#1e1e2e"41 background = "#1e1e2e"
33 foreground = "#cdd6f4"42 foreground = "#cdd6f4"
34 43
35 [[module]]44 [[module]]
36-name = "clock"45+name = "clock" # a unique name
37-zone = "center" # left | center | right46+zone = "center" # left, center or right
38-exec = "clock.sh"47+exec = "~/.config/neferbar/clock.sh"
48+```
49+
50+Add one `[[module]]` block per script. Save the file and the bar updates immediately. If you make a typo, the bar keeps the old config and prints the error.
51+
52+| Setting | Default | Meaning |
53+|---|---|---|
54+| `bar.font` | `JetBrainsMono Nerd Font Mono` | A font family known to fontconfig (`fc-list`). |
55+| `bar.size` | `14` | Text size in logical pixels. |
56+| `bar.scale` | `1.0` | Extra zoom on top of the monitor scale. |
57+| `bar.output` | any | A monitor name such as `HDMI-A-1`. Needs a restart. |
58+| `bar.background`, `bar.foreground` | catppuccin | `#rrggbb`. |
59+
60+If the font is not installed, neferbar warns and falls back to another font, and icons may be missing.
61+
62+## Write a script
63+
64+A module is any program that prints lines. **Each line replaces what the module showed before.**
65+
66+The smallest module:
67+
68+```sh
69+#!/bin/sh
70+echo "hello"
71+sleep 100000
39 ```72 ```
40 73
41-## Live reload74+A clock, updated every second:
42 75
43-The config file is watched, including editors that save through a temp file and symlinked dotfiles. A valid change applies at once; a broken, invalid or deleted file is logged and the running config stays.76+```sh
77+#!/bin/sh
78+while true; do
79+ date +%H:%M:%S
80+ sleep 1
81+done
82+```
83+
84+Make it executable (`chmod +x clock.sh`) and put its path in `exec`. That's all.
85+
86+Rules:
44 87
45-- Colors, font, size and scale apply live; the bar rebuilds its font and recreates the surface if its height changes.88+1. Print one line, then print another line later. The new line replaces the old one.
46-- Modules are matched by `name`: an unchanged one keeps running, a removed one is stopped, a new or changed one is started.89+2. A frame ends at a newline (`\n`) or a form feed (`\f`). Use `\f` if the frame itself should not end the line.
47-- `bar.output` needs a restart, and the bar says so.90+3. Keep the script running. A script that exits is restarted after a short delay, and the bar shows `[name!]` in the meantime.
48-- A module script that you edit on disk is picked up when the script restarts, not before.91+4. Print errors to stderr. They go to the bar's log and not to the screen.
92+5. Frames are cut to 16 KiB, and text wider than the bar is clipped.
49 93
50-## Modules94+### Colors and styles
51 95
52-A module is a long-running `/bin/sh -c` command. Its stdout is ANSI text (SGR colors, bold, inverse, dim). A frame ends at a newline or a form feed, and each frame replaces the previous one. Only the latest frame matters: a script that prints faster than the bar draws overwrites its pending frame.96+Use ordinary terminal escape codes. If it works in a terminal, it works here.
53 97
54-- Animate by printing frames quickly, ending each with `\f`. See `examples/modules/rainbow.sh`.98+```sh
55-- A module that exits is restarted with backoff and shows `[name!]` while it is down. Its stderr goes to the bar's log.99+printf '\033[32mOK\033[0m \033[1;31mFAIL\033[0m\n'
56-- Left and right zones keep their text; the center is cut first when they would overlap.100+```
57 101
58-## Height and scale102+| Code | Effect |
103+|---|---|
104+| `\033[1m` | **bold** |
105+| `\033[2m` | dim |
106+| `\033[3m` | *italic* |
107+| `\033[4m` | underline |
108+| `\033[7m` | inverse |
109+| `\033[9m` | ~~strikethrough~~ |
110+| `\033[30m`..`\033[37m` | foreground colors |
111+| `\033[40m`..`\033[47m` | background colors |
112+| `\033[38;5;Nm` / `\033[48;5;Nm` | 256-color palette |
113+| `\033[38;2;R;G;Bm` / `\033[48;2;R;G;Bm` | true color |
114+| `\033[0m` | reset |
59 115
60-The bar is exactly one cell high: `cell = ceil(size × scale × monitor scale)` physical pixels, and the layer surface height is `ceil(cell / monitor scale)` logical pixels. When the monitor scale changes the font is rebuilt, and the surface is recreated if its height changes.116+Italic, bold and bold italic use the font's own files when it has them.
61 117
62-## Profiling118+### Icons
119+
120+Nerd Font icons are ordinary characters. Print them from a script with their UTF-8 bytes:
121+
122+```sh
123+printf '\357\200\227 %s\n' "$(date +%H:%M)" # clock icon
124+```
125+
126+Find icon codes at <https://www.nerdfonts.com/cheat-sheet>.
127+
128+### Animation
129+
130+Print faster. Each frame ends with `\f`:
131+
132+```sh
133+#!/bin/sh
134+# a spinner at 20 fps
135+while true; do
136+ for c in '|' '/' '-' '\'; do
137+ printf '%s\f' "$c"
138+ sleep 0.05
139+ done
140+done
141+```
142+
143+neferbar draws at most as often as your monitor refreshes. If a script prints faster than that, only the latest frame is drawn. A script that prints nothing costs nothing.
144+
145+### Example modules
146+
147+The `examples/modules` directory has three:
148+
149+- `static.sh`: one line, then idle.
150+- `clock.sh`: a clock with an icon.
151+- `rainbow.sh [fps] [width]`: a 60 fps scrolling rainbow.
152+
153+## Layout
154+
155+The bar has three zones. Modules in the same zone appear side by side, in the order of the config file.
156+
157+- **left** starts at the left edge.
158+- **right** ends at the right edge.
159+- **center** sits in the middle of the space that is left. If the bar is too narrow, the center is cut first.
160+
161+## Command line
162+
163+```
164+neferbar [-config file] [-display socket] [-pprof addr] [-memstats interval]
165+```
63 166
64-`-pprof localhost:6060` serves the pprof handlers on a loopback address and records every allocation. `-memstats 5s` logs allocation counters. The tests assert zero allocations per frame for the draw path, the layout and the module hand-off.167+| Flag | Meaning |
168+|---|---|
169+| `-config` | Config file. Default: `$XDG_CONFIG_HOME/neferbar/config.toml`. |
170+| `-display` | Wayland socket name or path. Default: `$NEFERBAR_DISPLAY`, then `$WAYLAND_DISPLAY`. |
171+| `-pprof` | Serve Go profiling on a loopback address, such as `localhost:6060`. |
172+| `-memstats` | Log allocation counters at this interval. |
65 173
66-## Testing174+## Test without touching your desktop
67 175
68 ```sh176 ```sh
69 go test ./...177 go test ./...
70-scripts/headless.sh examples/config.toml 5 # nested headless NeferWL, screenshots in /tmp/neferbar-shots178+scripts/headless.sh examples/config.toml 5 # runs in a nested headless NeferWL
71 SCALE=1.5 SIZE=1200x200 scripts/headless.sh examples/config.toml 5179 SCALE=1.5 SIZE=1200x200 scripts/headless.sh examples/config.toml 5
72 ```180 ```
73 181
74-The GPU tests skip when no render node or Vulkan device is available.182+Screenshots land in `/tmp/neferbar-shots`.
75 183
76 ## License184 ## License
77 185
M internal/bar/bar.go +22 -14
@@ -15,6 +15,7 @@ import (
1515 "math"
1616 "os"
1717 "path/filepath"
18+ "strings"
1819 "syscall"
1920
2021 "github.com/bnema/neferclient"
@@ -56,9 +57,9 @@ type Bar struct {
5657 dirty bool
5758
5859 // font
59- regularPath, boldPath string
60- face *glyph.Face
61- facePx float64
60+ fonts glyph.Paths
61+ face *glyph.Face
62+ facePx float64
6263
6364 dev *gpu.Device
6465 node *syncobj.Node
@@ -93,12 +94,9 @@ func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) {
9394 if b.bg, err = config.ParseColor(cfg.Bar.Background); err != nil {
9495 return nil, err
9596 }
96- if b.regularPath, err = glyph.FindFont(cfg.Bar.Font, false); err != nil {
97+ if b.fonts, err = findFonts(cfg.Bar.Font, log); err != nil {
9798 return nil, err
9899 }
99- if b.boldPath, err = glyph.FindFont(cfg.Bar.Font, true); err != nil {
100- b.boldPath = b.regularPath
101- }
102100 for i := range b.watched {
103101 b.watched[i] = -1
104102 }
@@ -168,22 +166,19 @@ func (b *Bar) applyConfig(next config.Config) {
168166 return
169167 }
170168 fontChanged := next.Bar.Font != old.Bar.Font
171- reg, bold := b.regularPath, b.boldPath
169+ fonts := b.fonts
172170 if fontChanged {
173- if reg, err = glyph.FindFont(next.Bar.Font, false); err != nil {
171+ if fonts, err = findFonts(next.Bar.Font, b.log); err != nil {
174172 b.log.Warn("config not applied", "err", err)
175173 return
176174 }
177- if bold, err = glyph.FindFont(next.Bar.Font, true); err != nil {
178- bold = reg
179- }
180175 }
181176 if next.Bar.Output != old.Bar.Output {
182177 b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output)
183178 next.Bar.Output = old.Bar.Output
184179 }
185180 b.cfg = next
186- b.regularPath, b.boldPath = reg, bold
181+ b.fonts = fonts
187182 b.syncModules(next.Module)
188183 if fg != b.fg || bg != b.bg {
189184 b.fg, b.bg = fg, bg
@@ -248,6 +243,19 @@ func (b *Bar) Run(ctx context.Context) (err error) {
248243 return nil
249244 }
250245
246+// findFonts resolves the four font files of a family and warns when
247+// fontconfig had to substitute another family.
248+func findFonts(family string, log *slog.Logger) (glyph.Paths, error) {
249+ p, err := glyph.FindFonts(family)
250+ if err != nil {
251+ return p, err
252+ }
253+ if got := glyph.FamilyOf(family); got != "" && !strings.EqualFold(got, family) && !strings.HasPrefix(strings.ToLower(family), strings.ToLower(got)) {
254+ log.Warn("font not found, fontconfig substituted another family: icons may be missing", "wanted", family, "got", got, "file", p[0])
255+ }
256+ return p, nil
257+}
258+
251259 // socketPath mirrors how neferclient resolves the socket, for error messages.
252260 func socketPath(display string) string {
253261 if display == "" {
@@ -389,7 +397,7 @@ func (b *Bar) reconcile() {
389397 _, h, scale := b.surf.Size()
390398 px := b.cfg.Bar.Size * b.cfg.Bar.Scale * scale
391399 if b.face == nil || b.facePx != px {
392- face, err := glyph.Load(b.regularPath, b.boldPath, px)
400+ face, err := glyph.Load(b.fonts, px)
393401 if err != nil {
394402 b.fail(err)
395403 return
@@ -15,6 +15,7 @@ import (
15 "math"15 "math"
16 "os"16 "os"
17 "path/filepath"17 "path/filepath"
18+ "strings"
18 "syscall"19 "syscall"
19 20
20 "github.com/bnema/neferclient"21 "github.com/bnema/neferclient"
@@ -56,9 +57,9 @@ type Bar struct {
56 dirty bool57 dirty bool
57 58
58 // font59 // font
59- regularPath, boldPath string60+ fonts glyph.Paths
60- face *glyph.Face61+ face *glyph.Face
61- facePx float6462+ facePx float64
62 63
63 dev *gpu.Device64 dev *gpu.Device
64 node *syncobj.Node65 node *syncobj.Node
@@ -93,12 +94,9 @@ func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) {
93 if b.bg, err = config.ParseColor(cfg.Bar.Background); err != nil {94 if b.bg, err = config.ParseColor(cfg.Bar.Background); err != nil {
94 return nil, err95 return nil, err
95 }96 }
96- if b.regularPath, err = glyph.FindFont(cfg.Bar.Font, false); err != nil {97+ if b.fonts, err = findFonts(cfg.Bar.Font, log); err != nil {
97 return nil, err98 return nil, err
98 }99 }
99- if b.boldPath, err = glyph.FindFont(cfg.Bar.Font, true); err != nil {
100- b.boldPath = b.regularPath
101- }
102 for i := range b.watched {100 for i := range b.watched {
103 b.watched[i] = -1101 b.watched[i] = -1
104 }102 }
@@ -168,22 +166,19 @@ func (b *Bar) applyConfig(next config.Config) {
168 return166 return
169 }167 }
170 fontChanged := next.Bar.Font != old.Bar.Font168 fontChanged := next.Bar.Font != old.Bar.Font
171- reg, bold := b.regularPath, b.boldPath169+ fonts := b.fonts
172 if fontChanged {170 if fontChanged {
173- if reg, err = glyph.FindFont(next.Bar.Font, false); err != nil {171+ if fonts, err = findFonts(next.Bar.Font, b.log); err != nil {
174 b.log.Warn("config not applied", "err", err)172 b.log.Warn("config not applied", "err", err)
175 return173 return
176 }174 }
177- if bold, err = glyph.FindFont(next.Bar.Font, true); err != nil {
178- bold = reg
179- }
180 }175 }
181 if next.Bar.Output != old.Bar.Output {176 if next.Bar.Output != old.Bar.Output {
182 b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output)177 b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output)
183 next.Bar.Output = old.Bar.Output178 next.Bar.Output = old.Bar.Output
184 }179 }
185 b.cfg = next180 b.cfg = next
186- b.regularPath, b.boldPath = reg, bold181+ b.fonts = fonts
187 b.syncModules(next.Module)182 b.syncModules(next.Module)
188 if fg != b.fg || bg != b.bg {183 if fg != b.fg || bg != b.bg {
189 b.fg, b.bg = fg, bg184 b.fg, b.bg = fg, bg
@@ -248,6 +243,19 @@ func (b *Bar) Run(ctx context.Context) (err error) {
248 return nil243 return nil
249 }244 }
250 245
246+// findFonts resolves the four font files of a family and warns when
247+// fontconfig had to substitute another family.
248+func findFonts(family string, log *slog.Logger) (glyph.Paths, error) {
249+ p, err := glyph.FindFonts(family)
250+ if err != nil {
251+ return p, err
252+ }
253+ if got := glyph.FamilyOf(family); got != "" && !strings.EqualFold(got, family) && !strings.HasPrefix(strings.ToLower(family), strings.ToLower(got)) {
254+ log.Warn("font not found, fontconfig substituted another family: icons may be missing", "wanted", family, "got", got, "file", p[0])
255+ }
256+ return p, nil
257+}
258+
251 // socketPath mirrors how neferclient resolves the socket, for error messages.259 // socketPath mirrors how neferclient resolves the socket, for error messages.
252 func socketPath(display string) string {260 func socketPath(display string) string {
253 if display == "" {261 if display == "" {
@@ -389,7 +397,7 @@ func (b *Bar) reconcile() {
389 _, h, scale := b.surf.Size()397 _, h, scale := b.surf.Size()
390 px := b.cfg.Bar.Size * b.cfg.Bar.Scale * scale398 px := b.cfg.Bar.Size * b.cfg.Bar.Scale * scale
391 if b.face == nil || b.facePx != px {399 if b.face == nil || b.facePx != px {
392- face, err := glyph.Load(b.regularPath, b.boldPath, px)400+ face, err := glyph.Load(b.fonts, px)
393 if err != nil {401 if err != nil {
394 b.fail(err)402 b.fail(err)
395 return403 return
M internal/glyph/glyph.go +130 -56
@@ -15,30 +15,63 @@ import (
1515 "golang.org/x/image/math/fixed"
1616 )
1717
18-// Face rasterizes runes of one font at one pixel size. All glyphs are clipped
19-// to a CellW x CellH box. It is used from one goroutine.
18+// Style is a set of text attributes. Bold and Italic pick a font file;
19+// Underline and Strike are drawn into the glyph bitmap.
20+type Style uint8
21+
22+const (
23+ Bold Style = 1 << iota
24+ Italic
25+ Underline
26+ Strike
27+)
28+
29+// fontBits is the part of a Style that selects one of the four font files.
30+const fontBits = Bold | Italic
31+
32+// Paths holds the font file of each weight and slant, indexed by Bold|Italic.
33+// An empty entry falls back to the nearest available face.
34+type Paths [4]string
35+
36+// Face rasterizes runes of one font family at one pixel size. All glyphs are
37+// clipped to a CellW x CellH box. It is used from one goroutine.
2038 type Face struct {
21- regular, bold font.Face
39+ faces [4]font.Face
2240 // CellW and CellH are the cell size in physical pixels.
2341 CellW, CellH int
2442 ascent fixed.Int26_6
43+ ascentPx int
44+ thick int // line thickness for underline and strike, in pixels
2545 }
2646
27-// FindFont asks fontconfig for the file of a family and weight.
28-func FindFont(family string, bold bool) (string, error) {
29- pattern := family
30- if bold {
31- pattern += ":bold"
47+// fcPatterns are the fontconfig suffixes for Paths' four entries.
48+var fcPatterns = [4]string{"", ":bold", ":italic", ":bold:italic"}
49+
50+// FindFonts asks fontconfig for the four files of a family. Styles the family
51+// lacks resolve to whatever fontconfig picks, usually the regular file.
52+func FindFonts(family string) (Paths, error) {
53+ var p Paths
54+ for i, suffix := range fcPatterns {
55+ out, err := exec.Command("fc-match", "-f", "%{file}", family+suffix).Output()
56+ if err != nil {
57+ return p, fmt.Errorf("fontconfig lookup of %q failed: %w", family+suffix, err)
58+ }
59+ p[i] = strings.TrimSpace(string(out))
60+ if p[i] == "" {
61+ return p, fmt.Errorf("fontconfig found no file for %q", family+suffix)
62+ }
3263 }
33- out, err := exec.Command("fc-match", "-f", "%{file}", pattern).Output()
64+ return p, nil
65+}
66+
67+// FamilyOf reports the family fontconfig resolved a request to, so callers can
68+// warn when it is not the family that was asked for.
69+func FamilyOf(request string) string {
70+ out, err := exec.Command("fc-match", "-f", "%{family[0]}", request).Output()
3471 if err != nil {
35- return "", fmt.Errorf("glyph: fc-match %q: %w", pattern, err)
36- }
37- path := strings.TrimSpace(string(out))
38- if path == "" {
39- return "", fmt.Errorf("glyph: no font file for %q", pattern)
72+ return ""
4073 }
41- return path, nil
74+ return strings.TrimSpace(string(out))
4275 }
4376
4477 func loadFace(path string, px float64) (font.Face, error) {
@@ -53,26 +86,48 @@ func loadFace(path string, px float64) (font.Face, error) {
5386 return opentype.NewFace(f, &opentype.FaceOptions{Size: px, DPI: 72, Hinting: font.HintingFull})
5487 }
5588
56-// Load opens a regular and an optional bold font file at px pixels. The cell
57-// size comes from the regular face: the advance of "M" and ascent plus descent.
58-func Load(regularPath, boldPath string, px float64) (*Face, error) {
89+// Load opens the font files at px pixels. The cell size comes from the regular
90+// face: the advance of "M", and ascent plus descent.
91+func Load(paths Paths, px float64) (*Face, error) {
5992 if px < 4 || px > 512 {
6093 return nil, fmt.Errorf("glyph: unsupported font size %.1f px", px)
6194 }
95+ if paths[0] == "" {
96+ return nil, fmt.Errorf("glyph: no regular font file")
97+ }
6298 f := &Face{}
63- var err error
64- if f.regular, err = loadFace(regularPath, px); err != nil {
65- return nil, err
66- }
67- f.bold = f.regular
68- if boldPath != "" && boldPath != regularPath {
69- if f.bold, err = loadFace(boldPath, px); err != nil {
70- f.regular.Close()
99+ byPath := map[string]font.Face{}
100+ for i, path := range paths {
101+ if path == "" {
102+ continue // filled from a neighbour below
103+ }
104+ if face, ok := byPath[path]; ok {
105+ f.faces[i] = face
106+ continue
107+ }
108+ face, err := loadFace(path, px)
109+ if err != nil {
110+ f.Close()
71111 return nil, err
72112 }
113+ byPath[path], f.faces[i] = face, face
73114 }
74- m := f.regular.Metrics()
75- adv, ok := f.regular.GlyphAdvance('M')
115+ // A missing bold-italic uses bold, then italic; any other gap uses regular.
116+ for i := range f.faces {
117+ if f.faces[i] != nil {
118+ continue
119+ }
120+ switch {
121+ case Style(i) == Bold|Italic && f.faces[Bold] != nil:
122+ f.faces[i] = f.faces[Bold]
123+ case Style(i) == Bold|Italic && f.faces[Italic] != nil:
124+ f.faces[i] = f.faces[Italic]
125+ default:
126+ f.faces[i] = f.faces[0]
127+ }
128+ }
129+ m := f.faces[0].Metrics()
130+ adv, ok := f.faces[0].GlyphAdvance('M')
76131 if !ok || adv <= 0 {
77132 f.Close()
78133 return nil, fmt.Errorf("glyph: font has no usable advance for 'M'")
@@ -80,6 +135,8 @@ func Load(regularPath, boldPath string, px float64) (*Face, error) {
80135 f.CellW = int(math.Round(float64(adv) / 64))
81136 f.CellH = int(math.Ceil(float64(m.Ascent+m.Descent) / 64))
82137 f.ascent = m.Ascent
138+ f.ascentPx = int(math.Round(float64(m.Ascent) / 64))
139+ f.thick = max(1, int(math.Round(px/14)))
83140 if f.CellW < 1 || f.CellH < 1 {
84141 f.Close()
85142 return nil, fmt.Errorf("glyph: degenerate cell %dx%d", f.CellW, f.CellH)
@@ -92,44 +149,61 @@ func (f *Face) Close() {
92149 if f == nil {
93150 return
94151 }
95- if f.bold != nil && f.bold != f.regular {
96- f.bold.Close()
97- }
98- if f.regular != nil {
99- f.regular.Close()
152+ closed := map[font.Face]bool{}
153+ for i, face := range f.faces {
154+ if face != nil && !closed[face] {
155+ closed[face] = true
156+ face.Close()
157+ }
158+ f.faces[i] = nil
100159 }
101- f.regular, f.bold = nil, nil
102160 }
103161
104162 // Rasterize draws r into dst, which must hold CellW*CellH bytes (stride
105163 // CellW). Pixels outside the cell are clipped; a missing glyph leaves dst
106-// empty.
107-func (f *Face) Rasterize(r rune, bold bool, dst []byte) {
164+// empty. Underline and Strike add a full-width line in the foreground.
165+func (f *Face) Rasterize(r rune, style Style, dst []byte) {
108166 clear(dst[:f.CellW*f.CellH])
109- face := f.regular
110- if bold {
111- face = f.bold
167+ face := f.faces[style&fontBits]
168+ if dr, mask, maskp, _, ok := face.Glyph(fixed.Point26_6{X: 0, Y: f.ascent}, r); ok {
169+ alpha, isAlpha := mask.(*image.Alpha)
170+ for y := dr.Min.Y; y < dr.Max.Y; y++ {
171+ if y < 0 || y >= f.CellH {
172+ continue
173+ }
174+ for x := dr.Min.X; x < dr.Max.X; x++ {
175+ if x < 0 || x >= f.CellW {
176+ continue
177+ }
178+ mx, my := maskp.X+x-dr.Min.X, maskp.Y+y-dr.Min.Y
179+ if isAlpha {
180+ dst[y*f.CellW+x] = alpha.Pix[alpha.PixOffset(mx, my)]
181+ continue
182+ }
183+ _, _, _, a := mask.At(mx, my).RGBA()
184+ dst[y*f.CellW+x] = uint8(a >> 8)
185+ }
186+ }
112187 }
113- dr, mask, maskp, _, ok := face.Glyph(fixed.Point26_6{X: 0, Y: f.ascent}, r)
114- if !ok {
115- return
188+ if style&Underline != 0 {
189+ // Below the baseline, a third of the way into the descent.
190+ descent := f.CellH - f.ascentPx
191+ f.line(dst, f.ascentPx+max(1, descent/3))
192+ }
193+ if style&Strike != 0 {
194+ // Through the middle of the lowercase letters.
195+ f.line(dst, f.ascentPx-max(1, f.ascentPx*3/10)-f.thick/2)
116196 }
117- alpha, isAlpha := mask.(*image.Alpha)
118- for y := dr.Min.Y; y < dr.Max.Y; y++ {
119- if y < 0 || y >= f.CellH {
197+}
198+
199+// line fills f.thick rows starting at y, clipped to the cell.
200+func (f *Face) line(dst []byte, y int) {
201+ for row := y; row < y+f.thick; row++ {
202+ if row < 0 || row >= f.CellH {
120203 continue
121204 }
122- for x := dr.Min.X; x < dr.Max.X; x++ {
123- if x < 0 || x >= f.CellW {
124- continue
125- }
126- mx, my := maskp.X+x-dr.Min.X, maskp.Y+y-dr.Min.Y
127- if isAlpha {
128- dst[y*f.CellW+x] = alpha.Pix[alpha.PixOffset(mx, my)]
129- continue
130- }
131- _, _, _, a := mask.At(mx, my).RGBA()
132- dst[y*f.CellW+x] = uint8(a >> 8)
205+ for x := 0; x < f.CellW; x++ {
206+ dst[row*f.CellW+x] = 255
133207 }
134208 }
135209 }
@@ -15,30 +15,63 @@ import (
15 "golang.org/x/image/math/fixed"15 "golang.org/x/image/math/fixed"
16 )16 )
17 17
18-// Face rasterizes runes of one font at one pixel size. All glyphs are clipped18+// Style is a set of text attributes. Bold and Italic pick a font file;
19-// to a CellW x CellH box. It is used from one goroutine.19+// Underline and Strike are drawn into the glyph bitmap.
20+type Style uint8
21+
22+const (
23+ Bold Style = 1 << iota
24+ Italic
25+ Underline
26+ Strike
27+)
28+
29+// fontBits is the part of a Style that selects one of the four font files.
30+const fontBits = Bold | Italic
31+
32+// Paths holds the font file of each weight and slant, indexed by Bold|Italic.
33+// An empty entry falls back to the nearest available face.
34+type Paths [4]string
35+
36+// Face rasterizes runes of one font family at one pixel size. All glyphs are
37+// clipped to a CellW x CellH box. It is used from one goroutine.
20 type Face struct {38 type Face struct {
21- regular, bold font.Face39+ faces [4]font.Face
22 // CellW and CellH are the cell size in physical pixels.40 // CellW and CellH are the cell size in physical pixels.
23 CellW, CellH int41 CellW, CellH int
24 ascent fixed.Int26_642 ascent fixed.Int26_6
43+ ascentPx int
44+ thick int // line thickness for underline and strike, in pixels
25 }45 }
26 46
27-// FindFont asks fontconfig for the file of a family and weight.47+// fcPatterns are the fontconfig suffixes for Paths' four entries.
28-func FindFont(family string, bold bool) (string, error) {48+var fcPatterns = [4]string{"", ":bold", ":italic", ":bold:italic"}
29- pattern := family49+
30- if bold {50+// FindFonts asks fontconfig for the four files of a family. Styles the family
31- pattern += ":bold"51+// lacks resolve to whatever fontconfig picks, usually the regular file.
52+func FindFonts(family string) (Paths, error) {
53+ var p Paths
54+ for i, suffix := range fcPatterns {
55+ out, err := exec.Command("fc-match", "-f", "%{file}", family+suffix).Output()
56+ if err != nil {
57+ return p, fmt.Errorf("fontconfig lookup of %q failed: %w", family+suffix, err)
58+ }
59+ p[i] = strings.TrimSpace(string(out))
60+ if p[i] == "" {
61+ return p, fmt.Errorf("fontconfig found no file for %q", family+suffix)
62+ }
32 }63 }
33- out, err := exec.Command("fc-match", "-f", "%{file}", pattern).Output()64+ return p, nil
65+}
66+
67+// FamilyOf reports the family fontconfig resolved a request to, so callers can
68+// warn when it is not the family that was asked for.
69+func FamilyOf(request string) string {
70+ out, err := exec.Command("fc-match", "-f", "%{family[0]}", request).Output()
34 if err != nil {71 if err != nil {
35- return "", fmt.Errorf("glyph: fc-match %q: %w", pattern, err)72+ return ""
36- }
37- path := strings.TrimSpace(string(out))
38- if path == "" {
39- return "", fmt.Errorf("glyph: no font file for %q", pattern)
40 }73 }
41- return path, nil74+ return strings.TrimSpace(string(out))
42 }75 }
43 76
44 func loadFace(path string, px float64) (font.Face, error) {77 func loadFace(path string, px float64) (font.Face, error) {
@@ -53,26 +86,48 @@ func loadFace(path string, px float64) (font.Face, error) {
53 return opentype.NewFace(f, &opentype.FaceOptions{Size: px, DPI: 72, Hinting: font.HintingFull})86 return opentype.NewFace(f, &opentype.FaceOptions{Size: px, DPI: 72, Hinting: font.HintingFull})
54 }87 }
55 88
56-// Load opens a regular and an optional bold font file at px pixels. The cell89+// Load opens the font files at px pixels. The cell size comes from the regular
57-// size comes from the regular face: the advance of "M" and ascent plus descent.90+// face: the advance of "M", and ascent plus descent.
58-func Load(regularPath, boldPath string, px float64) (*Face, error) {91+func Load(paths Paths, px float64) (*Face, error) {
59 if px < 4 || px > 512 {92 if px < 4 || px > 512 {
60 return nil, fmt.Errorf("glyph: unsupported font size %.1f px", px)93 return nil, fmt.Errorf("glyph: unsupported font size %.1f px", px)
61 }94 }
95+ if paths[0] == "" {
96+ return nil, fmt.Errorf("glyph: no regular font file")
97+ }
62 f := &Face{}98 f := &Face{}
63- var err error99+ byPath := map[string]font.Face{}
64- if f.regular, err = loadFace(regularPath, px); err != nil {100+ for i, path := range paths {
65- return nil, err101+ if path == "" {
66- }102+ continue // filled from a neighbour below
67- f.bold = f.regular103+ }
68- if boldPath != "" && boldPath != regularPath {104+ if face, ok := byPath[path]; ok {
69- if f.bold, err = loadFace(boldPath, px); err != nil {105+ f.faces[i] = face
70- f.regular.Close()106+ continue
107+ }
108+ face, err := loadFace(path, px)
109+ if err != nil {
110+ f.Close()
71 return nil, err111 return nil, err
72 }112 }
113+ byPath[path], f.faces[i] = face, face
73 }114 }
74- m := f.regular.Metrics()115+ // A missing bold-italic uses bold, then italic; any other gap uses regular.
75- adv, ok := f.regular.GlyphAdvance('M')116+ for i := range f.faces {
117+ if f.faces[i] != nil {
118+ continue
119+ }
120+ switch {
121+ case Style(i) == Bold|Italic && f.faces[Bold] != nil:
122+ f.faces[i] = f.faces[Bold]
123+ case Style(i) == Bold|Italic && f.faces[Italic] != nil:
124+ f.faces[i] = f.faces[Italic]
125+ default:
126+ f.faces[i] = f.faces[0]
127+ }
128+ }
129+ m := f.faces[0].Metrics()
130+ adv, ok := f.faces[0].GlyphAdvance('M')
76 if !ok || adv <= 0 {131 if !ok || adv <= 0 {
77 f.Close()132 f.Close()
78 return nil, fmt.Errorf("glyph: font has no usable advance for 'M'")133 return nil, fmt.Errorf("glyph: font has no usable advance for 'M'")
@@ -80,6 +135,8 @@ func Load(regularPath, boldPath string, px float64) (*Face, error) {
80 f.CellW = int(math.Round(float64(adv) / 64))135 f.CellW = int(math.Round(float64(adv) / 64))
81 f.CellH = int(math.Ceil(float64(m.Ascent+m.Descent) / 64))136 f.CellH = int(math.Ceil(float64(m.Ascent+m.Descent) / 64))
82 f.ascent = m.Ascent137 f.ascent = m.Ascent
138+ f.ascentPx = int(math.Round(float64(m.Ascent) / 64))
139+ f.thick = max(1, int(math.Round(px/14)))
83 if f.CellW < 1 || f.CellH < 1 {140 if f.CellW < 1 || f.CellH < 1 {
84 f.Close()141 f.Close()
85 return nil, fmt.Errorf("glyph: degenerate cell %dx%d", f.CellW, f.CellH)142 return nil, fmt.Errorf("glyph: degenerate cell %dx%d", f.CellW, f.CellH)
@@ -92,44 +149,61 @@ func (f *Face) Close() {
92 if f == nil {149 if f == nil {
93 return150 return
94 }151 }
95- if f.bold != nil && f.bold != f.regular {152+ closed := map[font.Face]bool{}
96- f.bold.Close()153+ for i, face := range f.faces {
97- }154+ if face != nil && !closed[face] {
98- if f.regular != nil {155+ closed[face] = true
99- f.regular.Close()156+ face.Close()
157+ }
158+ f.faces[i] = nil
100 }159 }
101- f.regular, f.bold = nil, nil
102 }160 }
103 161
104 // Rasterize draws r into dst, which must hold CellW*CellH bytes (stride162 // Rasterize draws r into dst, which must hold CellW*CellH bytes (stride
105 // CellW). Pixels outside the cell are clipped; a missing glyph leaves dst163 // CellW). Pixels outside the cell are clipped; a missing glyph leaves dst
106-// empty.164+// empty. Underline and Strike add a full-width line in the foreground.
107-func (f *Face) Rasterize(r rune, bold bool, dst []byte) {165+func (f *Face) Rasterize(r rune, style Style, dst []byte) {
108 clear(dst[:f.CellW*f.CellH])166 clear(dst[:f.CellW*f.CellH])
109- face := f.regular167+ face := f.faces[style&fontBits]
110- if bold {168+ if dr, mask, maskp, _, ok := face.Glyph(fixed.Point26_6{X: 0, Y: f.ascent}, r); ok {
111- face = f.bold169+ alpha, isAlpha := mask.(*image.Alpha)
170+ for y := dr.Min.Y; y < dr.Max.Y; y++ {
171+ if y < 0 || y >= f.CellH {
172+ continue
173+ }
174+ for x := dr.Min.X; x < dr.Max.X; x++ {
175+ if x < 0 || x >= f.CellW {
176+ continue
177+ }
178+ mx, my := maskp.X+x-dr.Min.X, maskp.Y+y-dr.Min.Y
179+ if isAlpha {
180+ dst[y*f.CellW+x] = alpha.Pix[alpha.PixOffset(mx, my)]
181+ continue
182+ }
183+ _, _, _, a := mask.At(mx, my).RGBA()
184+ dst[y*f.CellW+x] = uint8(a >> 8)
185+ }
186+ }
112 }187 }
113- dr, mask, maskp, _, ok := face.Glyph(fixed.Point26_6{X: 0, Y: f.ascent}, r)188+ if style&Underline != 0 {
114- if !ok {189+ // Below the baseline, a third of the way into the descent.
115- return190+ descent := f.CellH - f.ascentPx
191+ f.line(dst, f.ascentPx+max(1, descent/3))
192+ }
193+ if style&Strike != 0 {
194+ // Through the middle of the lowercase letters.
195+ f.line(dst, f.ascentPx-max(1, f.ascentPx*3/10)-f.thick/2)
116 }196 }
117- alpha, isAlpha := mask.(*image.Alpha)197+}
118- for y := dr.Min.Y; y < dr.Max.Y; y++ {198+
119- if y < 0 || y >= f.CellH {199+// line fills f.thick rows starting at y, clipped to the cell.
200+func (f *Face) line(dst []byte, y int) {
201+ for row := y; row < y+f.thick; row++ {
202+ if row < 0 || row >= f.CellH {
120 continue203 continue
121 }204 }
122- for x := dr.Min.X; x < dr.Max.X; x++ {205+ for x := 0; x < f.CellW; x++ {
123- if x < 0 || x >= f.CellW {206+ dst[row*f.CellW+x] = 255
124- continue
125- }
126- mx, my := maskp.X+x-dr.Min.X, maskp.Y+y-dr.Min.Y
127- if isAlpha {
128- dst[y*f.CellW+x] = alpha.Pix[alpha.PixOffset(mx, my)]
129- continue
130- }
131- _, _, _, a := mask.At(mx, my).RGBA()
132- dst[y*f.CellW+x] = uint8(a >> 8)
133 }207 }
134 }208 }
135 }209 }
A internal/glyph/glyph_test.go +84 -0
@@ -0,0 +1,84 @@
1+package glyph
2+
3+import (
4+ "os"
5+ "testing"
6+)
7+
8+func testFace(t *testing.T) *Face {
9+ t.Helper()
10+ paths, err := FindFonts("monospace")
11+ if err != nil {
12+ t.Skip(err)
13+ }
14+ if _, err := os.Stat(paths[0]); err != nil {
15+ t.Skip(err)
16+ }
17+ f, err := Load(paths, 16)
18+ if err != nil {
19+ t.Skip(err)
20+ }
21+ t.Cleanup(f.Close)
22+ return f
23+}
24+
25+func rowSum(f *Face, bmp []byte, y int) (sum int) {
26+ for x := 0; x < f.CellW; x++ {
27+ sum += int(bmp[y*f.CellW+x])
28+ }
29+ return
30+}
31+
32+func TestUnderlineAndStrikeDrawFullWidthLines(t *testing.T) {
33+ f := testFace(t)
34+ plain := make([]byte, f.CellW*f.CellH)
35+ f.Rasterize(' ', 0, plain) // a space: only the lines can ink
36+ for _, b := range plain {
37+ if b != 0 {
38+ t.Fatal("a plain space must be empty")
39+ }
40+ }
41+ for name, style := range map[string]Style{"underline": Underline, "strike": Strike} {
42+ bmp := make([]byte, len(plain))
43+ f.Rasterize(' ', style, bmp)
44+ full := 0
45+ for y := 0; y < f.CellH; y++ {
46+ if rowSum(f, bmp, y) == 255*f.CellW {
47+ full++
48+ }
49+ }
50+ if full != f.thick {
51+ t.Errorf("%s: %d full rows, want %d", name, full, f.thick)
52+ }
53+ }
54+ both := make([]byte, len(plain))
55+ f.Rasterize(' ', Underline|Strike, both)
56+ var u, s []byte = make([]byte, len(plain)), make([]byte, len(plain))
57+ f.Rasterize(' ', Underline, u)
58+ f.Rasterize(' ', Strike, s)
59+ for i := range both {
60+ if both[i] != max(u[i], s[i]) {
61+ t.Fatal("underline and strike must not overlap or cancel")
62+ }
63+ }
64+}
65+
66+func TestItalicAndBoldPickAFontFile(t *testing.T) {
67+ paths, err := FindFonts("monospace")
68+ if err != nil {
69+ t.Skip(err)
70+ }
71+ f := testFace(t)
72+ // Each style slot must be usable even when the family lacks the file.
73+ for style := Style(0); style <= fontBits; style++ {
74+ bmp := make([]byte, f.CellW*f.CellH)
75+ f.Rasterize('A', style, bmp)
76+ ink := 0
77+ for _, b := range bmp {
78+ ink += int(b)
79+ }
80+ if ink == 0 {
81+ t.Errorf("style %02b drew nothing (files: %v)", style, paths)
82+ }
83+ }
84+}
@@ -0,0 +1,84 @@
1+package glyph
2+
3+import (
4+ "os"
5+ "testing"
6+)
7+
8+func testFace(t *testing.T) *Face {
9+ t.Helper()
10+ paths, err := FindFonts("monospace")
11+ if err != nil {
12+ t.Skip(err)
13+ }
14+ if _, err := os.Stat(paths[0]); err != nil {
15+ t.Skip(err)
16+ }
17+ f, err := Load(paths, 16)
18+ if err != nil {
19+ t.Skip(err)
20+ }
21+ t.Cleanup(f.Close)
22+ return f
23+}
24+
25+func rowSum(f *Face, bmp []byte, y int) (sum int) {
26+ for x := 0; x < f.CellW; x++ {
27+ sum += int(bmp[y*f.CellW+x])
28+ }
29+ return
30+}
31+
32+func TestUnderlineAndStrikeDrawFullWidthLines(t *testing.T) {
33+ f := testFace(t)
34+ plain := make([]byte, f.CellW*f.CellH)
35+ f.Rasterize(' ', 0, plain) // a space: only the lines can ink
36+ for _, b := range plain {
37+ if b != 0 {
38+ t.Fatal("a plain space must be empty")
39+ }
40+ }
41+ for name, style := range map[string]Style{"underline": Underline, "strike": Strike} {
42+ bmp := make([]byte, len(plain))
43+ f.Rasterize(' ', style, bmp)
44+ full := 0
45+ for y := 0; y < f.CellH; y++ {
46+ if rowSum(f, bmp, y) == 255*f.CellW {
47+ full++
48+ }
49+ }
50+ if full != f.thick {
51+ t.Errorf("%s: %d full rows, want %d", name, full, f.thick)
52+ }
53+ }
54+ both := make([]byte, len(plain))
55+ f.Rasterize(' ', Underline|Strike, both)
56+ var u, s []byte = make([]byte, len(plain)), make([]byte, len(plain))
57+ f.Rasterize(' ', Underline, u)
58+ f.Rasterize(' ', Strike, s)
59+ for i := range both {
60+ if both[i] != max(u[i], s[i]) {
61+ t.Fatal("underline and strike must not overlap or cancel")
62+ }
63+ }
64+}
65+
66+func TestItalicAndBoldPickAFontFile(t *testing.T) {
67+ paths, err := FindFonts("monospace")
68+ if err != nil {
69+ t.Skip(err)
70+ }
71+ f := testFace(t)
72+ // Each style slot must be usable even when the family lacks the file.
73+ for style := Style(0); style <= fontBits; style++ {
74+ bmp := make([]byte, f.CellW*f.CellH)
75+ f.Rasterize('A', style, bmp)
76+ ink := 0
77+ for _, b := range bmp {
78+ ink += int(b)
79+ }
80+ if ink == 0 {
81+ t.Errorf("style %02b drew nothing (files: %v)", style, paths)
82+ }
83+ }
84+}
M internal/gpu/alloc_test.go +3 -3
@@ -36,14 +36,14 @@ func newTestRenderer(t *testing.T) (*Renderer, *Device, *syncobj.Node) {
3636 mod = Modifier(m)
3737 break
3838 }
39- path, err := glyph.FindFont("monospace", false)
39+ paths, err := glyph.FindFonts("monospace")
4040 if err != nil {
4141 t.Skip(err)
4242 }
43- if _, err := os.Stat(path); err != nil {
43+ if _, err := os.Stat(paths[0]); err != nil {
4444 t.Skip(err)
4545 }
46- face, err := glyph.Load(path, "", 16)
46+ face, err := glyph.Load(paths, 16)
4747 if err != nil {
4848 t.Skip(err)
4949 }
@@ -36,14 +36,14 @@ func newTestRenderer(t *testing.T) (*Renderer, *Device, *syncobj.Node) {
36 mod = Modifier(m)36 mod = Modifier(m)
37 break37 break
38 }38 }
39- path, err := glyph.FindFont("monospace", false)39+ paths, err := glyph.FindFonts("monospace")
40 if err != nil {40 if err != nil {
41 t.Skip(err)41 t.Skip(err)
42 }42 }
43- if _, err := os.Stat(path); err != nil {43+ if _, err := os.Stat(paths[0]); err != nil {
44 t.Skip(err)44 t.Skip(err)
45 }45 }
46- face, err := glyph.Load(path, "", 16)46+ face, err := glyph.Load(paths, 16)
47 if err != nil {47 if err != nil {
48 t.Skip(err)48 t.Skip(err)
49 }49 }
M internal/gpu/renderer.go +9 -9
@@ -28,7 +28,7 @@ const (
2828 type Cell struct {
2929 Rune rune
3030 FG, BG [3]uint8
31- Bold bool
31+ Style glyph.Style
3232 }
3333
3434 // instance is the per-cell vertex data (64 bytes, four vec4).
@@ -40,8 +40,8 @@ type instance struct {
4040 }
4141
4242 type glyphKey struct {
43- r rune
44- bold bool
43+ r rune
44+ style glyph.Style
4545 }
4646
4747 type slotState uint8
@@ -172,10 +172,10 @@ func NewRenderer(dev *Device, node *syncobj.Node, mod Modifier, face *glyph.Face
172172 }
173173 // Glyph 0 is the blank cell. Printable ASCII is assigned now and uploaded
174174 // with the first frame.
175- r.glyphs[glyphKey{' ', false}] = 0
175+ r.glyphs[glyphKey{' ', 0}] = 0
176176 r.nextGlyph = 1
177177 for c := rune(0x21); c < 0x7f; c++ {
178- r.glyphs[glyphKey{c, false}] = r.nextGlyph
178+ r.glyphs[glyphKey{c, 0}] = r.nextGlyph
179179 r.nextGlyph++
180180 }
181181 r.uploadKeys = make([]glyphKey, 0, maxUploads)
@@ -437,16 +437,16 @@ func (r *Renderer) Draw(cells []Cell, clear [3]uint8, out *Frame) (bool, error)
437437 }
438438 r.uploadKeys, r.uploadIdx = r.uploadKeys[:0], r.uploadIdx[:0]
439439 if !r.atlasInited {
440- r.queueGlyph(glyphKey{' ', false}, 0)
440+ r.queueGlyph(glyphKey{' ', 0}, 0)
441441 for c := rune(0x21); c < 0x7f; c++ {
442- r.queueGlyph(glyphKey{c, false}, r.glyphs[glyphKey{c, false}])
442+ r.queueGlyph(glyphKey{c, 0}, r.glyphs[glyphKey{c, 0}])
443443 }
444444 }
445445 cw, ch := float32(r.face.CellW), float32(r.face.CellH)
446446 inv := float32(1) / atlasSize
447447 for i := range cells {
448448 c := &cells[i]
449- gi := r.glyph(glyphKey{c.Rune, c.Bold})
449+ gi := r.glyph(glyphKey{c.Rune, c.Style})
450450 gx, gy := float32(int(gi)%r.atlasCols)*cw, float32(int(gi)/r.atlasCols)*ch
451451 in := &s.inst[i]
452452 in.Rect = [4]float32{float32(i) * cw, r.yOff, cw, ch}
@@ -458,7 +458,7 @@ func (r *Renderer) Draw(cells []Cell, clear [3]uint8, out *Frame) (bool, error)
458458 s.regions = s.regions[:0]
459459 for i, k := range r.uploadKeys {
460460 off := i * cellBytes
461- r.face.Rasterize(k.r, k.bold, s.stage[off:off+cellBytes])
461+ r.face.Rasterize(k.r, k.style, s.stage[off:off+cellBytes])
462462 gi := int(r.uploadIdx[i])
463463 s.regions = append(s.regions, vulkan.BufferImageCopy{
464464 BufferOffset: vulkan.DeviceSize(off),
@@ -28,7 +28,7 @@ const (
28 type Cell struct {28 type Cell struct {
29 Rune rune29 Rune rune
30 FG, BG [3]uint830 FG, BG [3]uint8
31- Bold bool31+ Style glyph.Style
32 }32 }
33 33
34 // instance is the per-cell vertex data (64 bytes, four vec4).34 // instance is the per-cell vertex data (64 bytes, four vec4).
@@ -40,8 +40,8 @@ type instance struct {
40 }40 }
41 41
42 type glyphKey struct {42 type glyphKey struct {
43- r rune43+ r rune
44- bold bool44+ style glyph.Style
45 }45 }
46 46
47 type slotState uint847 type slotState uint8
@@ -172,10 +172,10 @@ func NewRenderer(dev *Device, node *syncobj.Node, mod Modifier, face *glyph.Face
172 }172 }
173 // Glyph 0 is the blank cell. Printable ASCII is assigned now and uploaded173 // Glyph 0 is the blank cell. Printable ASCII is assigned now and uploaded
174 // with the first frame.174 // with the first frame.
175- r.glyphs[glyphKey{' ', false}] = 0175+ r.glyphs[glyphKey{' ', 0}] = 0
176 r.nextGlyph = 1176 r.nextGlyph = 1
177 for c := rune(0x21); c < 0x7f; c++ {177 for c := rune(0x21); c < 0x7f; c++ {
178- r.glyphs[glyphKey{c, false}] = r.nextGlyph178+ r.glyphs[glyphKey{c, 0}] = r.nextGlyph
179 r.nextGlyph++179 r.nextGlyph++
180 }180 }
181 r.uploadKeys = make([]glyphKey, 0, maxUploads)181 r.uploadKeys = make([]glyphKey, 0, maxUploads)
@@ -437,16 +437,16 @@ func (r *Renderer) Draw(cells []Cell, clear [3]uint8, out *Frame) (bool, error)
437 }437 }
438 r.uploadKeys, r.uploadIdx = r.uploadKeys[:0], r.uploadIdx[:0]438 r.uploadKeys, r.uploadIdx = r.uploadKeys[:0], r.uploadIdx[:0]
439 if !r.atlasInited {439 if !r.atlasInited {
440- r.queueGlyph(glyphKey{' ', false}, 0)440+ r.queueGlyph(glyphKey{' ', 0}, 0)
441 for c := rune(0x21); c < 0x7f; c++ {441 for c := rune(0x21); c < 0x7f; c++ {
442- r.queueGlyph(glyphKey{c, false}, r.glyphs[glyphKey{c, false}])442+ r.queueGlyph(glyphKey{c, 0}, r.glyphs[glyphKey{c, 0}])
443 }443 }
444 }444 }
445 cw, ch := float32(r.face.CellW), float32(r.face.CellH)445 cw, ch := float32(r.face.CellW), float32(r.face.CellH)
446 inv := float32(1) / atlasSize446 inv := float32(1) / atlasSize
447 for i := range cells {447 for i := range cells {
448 c := &cells[i]448 c := &cells[i]
449- gi := r.glyph(glyphKey{c.Rune, c.Bold})449+ gi := r.glyph(glyphKey{c.Rune, c.Style})
450 gx, gy := float32(int(gi)%r.atlasCols)*cw, float32(int(gi)/r.atlasCols)*ch450 gx, gy := float32(int(gi)%r.atlasCols)*cw, float32(int(gi)/r.atlasCols)*ch
451 in := &s.inst[i]451 in := &s.inst[i]
452 in.Rect = [4]float32{float32(i) * cw, r.yOff, cw, ch}452 in.Rect = [4]float32{float32(i) * cw, r.yOff, cw, ch}
@@ -458,7 +458,7 @@ func (r *Renderer) Draw(cells []Cell, clear [3]uint8, out *Frame) (bool, error)
458 s.regions = s.regions[:0]458 s.regions = s.regions[:0]
459 for i, k := range r.uploadKeys {459 for i, k := range r.uploadKeys {
460 off := i * cellBytes460 off := i * cellBytes
461- r.face.Rasterize(k.r, k.bold, s.stage[off:off+cellBytes])461+ r.face.Rasterize(k.r, k.style, s.stage[off:off+cellBytes])
462 gi := int(r.uploadIdx[i])462 gi := int(r.uploadIdx[i])
463 s.regions = append(s.regions, vulkan.BufferImageCopy{463 s.regions = append(s.regions, vulkan.BufferImageCopy{
464 BufferOffset: vulkan.DeviceSize(off),464 BufferOffset: vulkan.DeviceSize(off),
M internal/layout/layout.go +15 -1
@@ -4,6 +4,7 @@ package layout
44 import (
55 vt "github.com/bnema/vev-vt"
66
7+ "git.bnema.dev/bnema/neferbar/internal/glyph"
78 "git.bnema.dev/bnema/neferbar/internal/gpu"
89 "git.bnema.dev/bnema/neferbar/internal/module"
910 )
@@ -174,7 +175,20 @@ func convert(c vt.Cell, defFG, defBG [3]uint8) gpu.Cell {
174175 if r == 0 || c.Continuation {
175176 r = ' '
176177 }
177- return gpu.Cell{Rune: r, FG: fg, BG: bg, Bold: st.Bold}
178+ var style glyph.Style
179+ if st.Bold {
180+ style |= glyph.Bold
181+ }
182+ if st.Italic {
183+ style |= glyph.Italic
184+ }
185+ if st.Attrs&vt.AttrUnderline != 0 || st.UnderlineStyle != vt.UnderlineNone {
186+ style |= glyph.Underline
187+ }
188+ if st.Attrs&vt.AttrStrikethrough != 0 {
189+ style |= glyph.Strike
190+ }
191+ return gpu.Cell{Rune: r, FG: fg, BG: bg, Style: style}
178192 }
179193
180194 // Compose lays the zones out in one row: left from the left edge, right to
@@ -4,6 +4,7 @@ package layout
4 import (4 import (
5 vt "github.com/bnema/vev-vt"5 vt "github.com/bnema/vev-vt"
6 6
7+ "git.bnema.dev/bnema/neferbar/internal/glyph"
7 "git.bnema.dev/bnema/neferbar/internal/gpu"8 "git.bnema.dev/bnema/neferbar/internal/gpu"
8 "git.bnema.dev/bnema/neferbar/internal/module"9 "git.bnema.dev/bnema/neferbar/internal/module"
9 )10 )
@@ -174,7 +175,20 @@ func convert(c vt.Cell, defFG, defBG [3]uint8) gpu.Cell {
174 if r == 0 || c.Continuation {175 if r == 0 || c.Continuation {
175 r = ' '176 r = ' '
176 }177 }
177- return gpu.Cell{Rune: r, FG: fg, BG: bg, Bold: st.Bold}178+ var style glyph.Style
179+ if st.Bold {
180+ style |= glyph.Bold
181+ }
182+ if st.Italic {
183+ style |= glyph.Italic
184+ }
185+ if st.Attrs&vt.AttrUnderline != 0 || st.UnderlineStyle != vt.UnderlineNone {
186+ style |= glyph.Underline
187+ }
188+ if st.Attrs&vt.AttrStrikethrough != 0 {
189+ style |= glyph.Strike
190+ }
191+ return gpu.Cell{Rune: r, FG: fg, BG: bg, Style: style}
178 }192 }
179 193
180 // Compose lays the zones out in one row: left from the left edge, right to194 // Compose lays the zones out in one row: left from the left edge, right to
M internal/layout/layout_test.go +15 -1
@@ -5,6 +5,7 @@ import (
55 "log/slog"
66 "testing"
77
8+ "git.bnema.dev/bnema/neferbar/internal/glyph"
89 "git.bnema.dev/bnema/neferbar/internal/module"
910 "git.bnema.dev/bnema/neferbar/internal/racecheck"
1011 )
@@ -84,7 +85,7 @@ func TestColorsAndClip(t *testing.T) {
8485 if row[1].FG != l.fg {
8586 t.Fatalf("reset should restore default fg: %+v", row[1])
8687 }
87- if !row[2].Bold || row[2].FG != [3]uint8{1, 2, 3} {
88+ if row[2].Style&glyph.Bold == 0 || row[2].FG != [3]uint8{1, 2, 3} {
8889 t.Fatalf("bold truecolor cell: %+v", row[2])
8990 }
9091 }
@@ -125,3 +126,16 @@ func TestUpdateComposeAllocs(t *testing.T) {
125126 t.Errorf("Update+Compose allocates %.1f objects per frame; want 0", got)
126127 }
127128 }
129+
130+func TestTextStyles(t *testing.T) {
131+ l, m := newTestLayout(10)
132+ publish(m[0], "\x1b[3mi\x1b[0m\x1b[4mu\x1b[0m\x1b[9ms\x1b[0m\x1b[1;3;4;9mA\x1b[0mn")
133+ l.Update()
134+ row := l.Compose()
135+ want := []glyph.Style{glyph.Italic, glyph.Underline, glyph.Strike, glyph.Bold | glyph.Italic | glyph.Underline | glyph.Strike, 0}
136+ for i, w := range want {
137+ if row[i].Style != w {
138+ t.Errorf("cell %d (%q): style %04b, want %04b", i, row[i].Rune, row[i].Style, w)
139+ }
140+ }
141+}
@@ -5,6 +5,7 @@ import (
5 "log/slog"5 "log/slog"
6 "testing"6 "testing"
7 7
8+ "git.bnema.dev/bnema/neferbar/internal/glyph"
8 "git.bnema.dev/bnema/neferbar/internal/module"9 "git.bnema.dev/bnema/neferbar/internal/module"
9 "git.bnema.dev/bnema/neferbar/internal/racecheck"10 "git.bnema.dev/bnema/neferbar/internal/racecheck"
10 )11 )
@@ -84,7 +85,7 @@ func TestColorsAndClip(t *testing.T) {
84 if row[1].FG != l.fg {85 if row[1].FG != l.fg {
85 t.Fatalf("reset should restore default fg: %+v", row[1])86 t.Fatalf("reset should restore default fg: %+v", row[1])
86 }87 }
87- if !row[2].Bold || row[2].FG != [3]uint8{1, 2, 3} {88+ if row[2].Style&glyph.Bold == 0 || row[2].FG != [3]uint8{1, 2, 3} {
88 t.Fatalf("bold truecolor cell: %+v", row[2])89 t.Fatalf("bold truecolor cell: %+v", row[2])
89 }90 }
90 }91 }
@@ -125,3 +126,16 @@ func TestUpdateComposeAllocs(t *testing.T) {
125 t.Errorf("Update+Compose allocates %.1f objects per frame; want 0", got)126 t.Errorf("Update+Compose allocates %.1f objects per frame; want 0", got)
126 }127 }
127 }128 }
129+
130+func TestTextStyles(t *testing.T) {
131+ l, m := newTestLayout(10)
132+ publish(m[0], "\x1b[3mi\x1b[0m\x1b[4mu\x1b[0m\x1b[9ms\x1b[0m\x1b[1;3;4;9mA\x1b[0mn")
133+ l.Update()
134+ row := l.Compose()
135+ want := []glyph.Style{glyph.Italic, glyph.Underline, glyph.Strike, glyph.Bold | glyph.Italic | glyph.Underline | glyph.Strike, 0}
136+ for i, w := range want {
137+ if row[i].Style != w {
138+ t.Errorf("cell %d (%q): style %04b, want %04b", i, row[i].Rune, row[i].Style, w)
139+ }
140+ }
141+}

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