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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +422 -120
M README.md +144 -36
| @@ -1,77 +1,185 @@ | |||
| 1 | 1 | # 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 | - | ## 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. | |
| 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. | |
| 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. | |
| 10 | 15 | ||
| 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/). | |
| 12 | 21 | ||
| 13 | 22 | ```sh | |
| 14 | 23 | go build -o neferbar ./cmd/neferbar | |
| 15 | - | ./neferbar -config examples/config.toml | |
| 24 | + | ./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 | - | ## Configuration | |
| 33 | + | ## Configure | |
| 23 | 34 | ||
| 24 | - | `$XDG_CONFIG_HOME/neferbar/config.toml`: | |
| 35 | + | Create `~/.config/neferbar/config.toml`: | |
| 25 | 36 | ||
| 26 | 37 | ```toml | |
| 27 | 38 | [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 | |
| 32 | 41 | background = "#1e1e2e" | |
| 33 | 42 | foreground = "#cdd6f4" | |
| 34 | 43 | ||
| 35 | 44 | [[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 | |
| 39 | 72 | ``` | |
| 40 | 73 | ||
| 41 | - | ## Live reload | |
| 74 | + | 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. | |
| 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. | |
| 49 | 93 | ||
| 50 | - | ## Modules | |
| 94 | + | ### 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`. | |
| 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 | + | ``` | |
| 57 | 101 | ||
| 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 | | |
| 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 | - | ## 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 | + | ``` | |
| 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 | - | ## Testing | |
| 174 | + | ## Test without touching your desktop | |
| 67 | 175 | ||
| 68 | 176 | ```sh | |
| 69 | 177 | 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 | |
| 71 | 179 | 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 | 184 | ## License | |
| 77 | 185 | ||
| @@ -1,77 +1,185 @@ | |||||
| 1 | # neferbar | 1 | # 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 | - | ## 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. |
| 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 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/). | |||
| 12 | 21 | ||||
| 13 | ```sh | 22 | ```sh | ||
| 14 | go build -o neferbar ./cmd/neferbar | 23 | go build -o neferbar ./cmd/neferbar | ||
| 15 | - | ./neferbar -config examples/config.toml | 24 | + | ./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 | - | ## Configuration | 33 | + | ## Configure |
| 23 | 34 | ||||
| 24 | - | `$XDG_CONFIG_HOME/neferbar/config.toml`: | 35 | + | Create `~/.config/neferbar/config.toml`: |
| 25 | 36 | ||||
| 26 | ```toml | 37 | ```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 pixels | 40 | + | 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 | right | 46 | + | 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 reload | 74 | + | 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 | - | ## Modules | 94 | + | ### 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 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 | | |||
| 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 | - | ## 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 | + | ``` | |||
| 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 | - | ## Testing | 174 | + | ## Test without touching your desktop |
| 67 | 175 | ||||
| 68 | ```sh | 176 | ```sh | ||
| 69 | go test ./... | 177 | 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 |
| 71 | SCALE=1.5 SIZE=1200x200 scripts/headless.sh examples/config.toml 5 | 179 | 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 | ## License | 184 | ## License | ||
| 77 | 185 | ||||
M internal/bar/bar.go +22 -14
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "math" | |
| 16 | 16 | "os" | |
| 17 | 17 | "path/filepath" | |
| 18 | + | "strings" | |
| 18 | 19 | "syscall" | |
| 19 | 20 | ||
| 20 | 21 | "github.com/bnema/neferclient" | |
| @@ -56,9 +57,9 @@ type Bar struct { | |||
| 56 | 57 | dirty bool | |
| 57 | 58 | ||
| 58 | 59 | // font | |
| 59 | - | regularPath, boldPath string | |
| 60 | - | face *glyph.Face | |
| 61 | - | facePx float64 | |
| 60 | + | fonts glyph.Paths | |
| 61 | + | face *glyph.Face | |
| 62 | + | facePx float64 | |
| 62 | 63 | ||
| 63 | 64 | dev *gpu.Device | |
| 64 | 65 | node *syncobj.Node | |
| @@ -93,12 +94,9 @@ func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) { | |||
| 93 | 94 | if b.bg, err = config.ParseColor(cfg.Bar.Background); err != nil { | |
| 94 | 95 | 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 | 98 | 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 | 100 | for i := range b.watched { | |
| 103 | 101 | b.watched[i] = -1 | |
| 104 | 102 | } | |
| @@ -168,22 +166,19 @@ func (b *Bar) applyConfig(next config.Config) { | |||
| 168 | 166 | return | |
| 169 | 167 | } | |
| 170 | 168 | fontChanged := next.Bar.Font != old.Bar.Font | |
| 171 | - | reg, bold := b.regularPath, b.boldPath | |
| 169 | + | fonts := b.fonts | |
| 172 | 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 | 172 | b.log.Warn("config not applied", "err", err) | |
| 175 | 173 | return | |
| 176 | 174 | } | |
| 177 | - | if bold, err = glyph.FindFont(next.Bar.Font, true); err != nil { | |
| 178 | - | bold = reg | |
| 179 | - | } | |
| 180 | 175 | } | |
| 181 | 176 | if next.Bar.Output != old.Bar.Output { | |
| 182 | 177 | b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output) | |
| 183 | 178 | next.Bar.Output = old.Bar.Output | |
| 184 | 179 | } | |
| 185 | 180 | b.cfg = next | |
| 186 | - | b.regularPath, b.boldPath = reg, bold | |
| 181 | + | b.fonts = fonts | |
| 187 | 182 | b.syncModules(next.Module) | |
| 188 | 183 | if fg != b.fg || bg != b.bg { | |
| 189 | 184 | b.fg, b.bg = fg, bg | |
| @@ -248,6 +243,19 @@ func (b *Bar) Run(ctx context.Context) (err error) { | |||
| 248 | 243 | 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 | 259 | // socketPath mirrors how neferclient resolves the socket, for error messages. | |
| 252 | 260 | func socketPath(display string) string { | |
| 253 | 261 | if display == "" { | |
| @@ -389,7 +397,7 @@ func (b *Bar) reconcile() { | |||
| 389 | 397 | _, h, scale := b.surf.Size() | |
| 390 | 398 | px := b.cfg.Bar.Size * b.cfg.Bar.Scale * scale | |
| 391 | 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 | 401 | if err != nil { | |
| 394 | 402 | b.fail(err) | |
| 395 | 403 | 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 bool | 57 | dirty bool | ||
| 57 | 58 | ||||
| 58 | // font | 59 | // font | ||
| 59 | - | regularPath, boldPath string | 60 | + | fonts glyph.Paths |
| 60 | - | face *glyph.Face | 61 | + | face *glyph.Face |
| 61 | - | facePx float64 | 62 | + | facePx float64 |
| 62 | 63 | ||||
| 63 | dev *gpu.Device | 64 | dev *gpu.Device | ||
| 64 | node *syncobj.Node | 65 | 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, err | 95 | 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, err | 98 | 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] = -1 | 101 | b.watched[i] = -1 | ||
| 104 | } | 102 | } | ||
| @@ -168,22 +166,19 @@ func (b *Bar) applyConfig(next config.Config) { | |||||
| 168 | return | 166 | return | ||
| 169 | } | 167 | } | ||
| 170 | fontChanged := next.Bar.Font != old.Bar.Font | 168 | fontChanged := next.Bar.Font != old.Bar.Font | ||
| 171 | - | reg, bold := b.regularPath, b.boldPath | 169 | + | 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 | return | 173 | 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.Output | 178 | next.Bar.Output = old.Bar.Output | ||
| 184 | } | 179 | } | ||
| 185 | b.cfg = next | 180 | b.cfg = next | ||
| 186 | - | b.regularPath, b.boldPath = reg, bold | 181 | + | 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, bg | 184 | b.fg, b.bg = fg, bg | ||
| @@ -248,6 +243,19 @@ func (b *Bar) Run(ctx context.Context) (err error) { | |||||
| 248 | return nil | 243 | 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 * scale | 398 | 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 | return | 403 | return | ||
M internal/glyph/glyph.go +130 -56
| @@ -15,30 +15,63 @@ import ( | |||
| 15 | 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 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. | |
| 20 | 38 | type Face struct { | |
| 21 | - | regular, bold font.Face | |
| 39 | + | faces [4]font.Face | |
| 22 | 40 | // CellW and CellH are the cell size in physical pixels. | |
| 23 | 41 | CellW, CellH int | |
| 24 | 42 | 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. | |
| 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 | + | } | |
| 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 | 71 | 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 "" | |
| 40 | 73 | } | |
| 41 | - | return path, nil | |
| 74 | + | return strings.TrimSpace(string(out)) | |
| 42 | 75 | } | |
| 43 | 76 | ||
| 44 | 77 | func loadFace(path string, px float64) (font.Face, error) { | |
| @@ -53,26 +86,48 @@ func loadFace(path string, px float64) (font.Face, error) { | |||
| 53 | 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 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) { | |
| 59 | 92 | if px < 4 || px > 512 { | |
| 60 | 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 | 98 | 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() | |
| 71 | 111 | return nil, err | |
| 72 | 112 | } | |
| 113 | + | byPath[path], f.faces[i] = face, face | |
| 73 | 114 | } | |
| 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') | |
| 76 | 131 | if !ok || adv <= 0 { | |
| 77 | 132 | f.Close() | |
| 78 | 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 | 135 | f.CellW = int(math.Round(float64(adv) / 64)) | |
| 81 | 136 | f.CellH = int(math.Ceil(float64(m.Ascent+m.Descent) / 64)) | |
| 82 | 137 | 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 | 140 | if f.CellW < 1 || f.CellH < 1 { | |
| 84 | 141 | f.Close() | |
| 85 | 142 | return nil, fmt.Errorf("glyph: degenerate cell %dx%d", f.CellW, f.CellH) | |
| @@ -92,44 +149,61 @@ func (f *Face) Close() { | |||
| 92 | 149 | if f == nil { | |
| 93 | 150 | return | |
| 94 | 151 | } | |
| 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 | |
| 100 | 159 | } | |
| 101 | - | f.regular, f.bold = nil, nil | |
| 102 | 160 | } | |
| 103 | 161 | ||
| 104 | 162 | // Rasterize draws r into dst, which must hold CellW*CellH bytes (stride | |
| 105 | 163 | // 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) { | |
| 108 | 166 | 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 | + | } | |
| 112 | 187 | } | |
| 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) | |
| 116 | 196 | } | |
| 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 { | |
| 120 | 203 | continue | |
| 121 | 204 | } | |
| 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 | |
| 133 | 207 | } | |
| 134 | 208 | } | |
| 135 | 209 | } | |
| @@ -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 clipped | 18 | + | // 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.Face | 39 | + | 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 int | 41 | CellW, CellH int | ||
| 24 | ascent fixed.Int26_6 | 42 | 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 := family | 49 | + | |
| 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, nil | 74 | + | 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 cell | 89 | + | // 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 error | 99 | + | byPath := map[string]font.Face{} |
| 64 | - | if f.regular, err = loadFace(regularPath, px); err != nil { | 100 | + | for i, path := range paths { |
| 65 | - | return nil, err | 101 | + | if path == "" { |
| 66 | - | } | 102 | + | continue // filled from a neighbour below |
| 67 | - | f.bold = f.regular | 103 | + | } |
| 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, err | 111 | 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.Ascent | 137 | 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 | return | 150 | 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 (stride | 162 | // Rasterize draws r into dst, which must hold CellW*CellH bytes (stride | ||
| 105 | // CellW). Pixels outside the cell are clipped; a missing glyph leaves dst | 163 | // 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.regular | 167 | + | 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.bold | 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 | + | } | |||
| 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 | - | return | 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) | |||
| 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 | continue | 203 | 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) { | |||
| 36 | 36 | mod = Modifier(m) | |
| 37 | 37 | break | |
| 38 | 38 | } | |
| 39 | - | path, err := glyph.FindFont("monospace", false) | |
| 39 | + | paths, err := glyph.FindFonts("monospace") | |
| 40 | 40 | if err != nil { | |
| 41 | 41 | t.Skip(err) | |
| 42 | 42 | } | |
| 43 | - | if _, err := os.Stat(path); err != nil { | |
| 43 | + | if _, err := os.Stat(paths[0]); err != nil { | |
| 44 | 44 | t.Skip(err) | |
| 45 | 45 | } | |
| 46 | - | face, err := glyph.Load(path, "", 16) | |
| 46 | + | face, err := glyph.Load(paths, 16) | |
| 47 | 47 | if err != nil { | |
| 48 | 48 | t.Skip(err) | |
| 49 | 49 | } | |
| @@ -36,14 +36,14 @@ func newTestRenderer(t *testing.T) (*Renderer, *Device, *syncobj.Node) { | |||||
| 36 | mod = Modifier(m) | 36 | mod = Modifier(m) | ||
| 37 | break | 37 | 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 ( | |||
| 28 | 28 | type Cell struct { | |
| 29 | 29 | Rune rune | |
| 30 | 30 | FG, BG [3]uint8 | |
| 31 | - | Bold bool | |
| 31 | + | Style glyph.Style | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | 34 | // instance is the per-cell vertex data (64 bytes, four vec4). | |
| @@ -40,8 +40,8 @@ type instance struct { | |||
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | type glyphKey struct { | |
| 43 | - | r rune | |
| 44 | - | bold bool | |
| 43 | + | r rune | |
| 44 | + | style glyph.Style | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | 47 | type slotState uint8 | |
| @@ -172,10 +172,10 @@ func NewRenderer(dev *Device, node *syncobj.Node, mod Modifier, face *glyph.Face | |||
| 172 | 172 | } | |
| 173 | 173 | // Glyph 0 is the blank cell. Printable ASCII is assigned now and uploaded | |
| 174 | 174 | // with the first frame. | |
| 175 | - | r.glyphs[glyphKey{' ', false}] = 0 | |
| 175 | + | r.glyphs[glyphKey{' ', 0}] = 0 | |
| 176 | 176 | r.nextGlyph = 1 | |
| 177 | 177 | for c := rune(0x21); c < 0x7f; c++ { | |
| 178 | - | r.glyphs[glyphKey{c, false}] = r.nextGlyph | |
| 178 | + | r.glyphs[glyphKey{c, 0}] = r.nextGlyph | |
| 179 | 179 | r.nextGlyph++ | |
| 180 | 180 | } | |
| 181 | 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 | 438 | r.uploadKeys, r.uploadIdx = r.uploadKeys[:0], r.uploadIdx[:0] | |
| 439 | 439 | if !r.atlasInited { | |
| 440 | - | r.queueGlyph(glyphKey{' ', false}, 0) | |
| 440 | + | r.queueGlyph(glyphKey{' ', 0}, 0) | |
| 441 | 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 | 445 | cw, ch := float32(r.face.CellW), float32(r.face.CellH) | |
| 446 | 446 | inv := float32(1) / atlasSize | |
| 447 | 447 | for i := range cells { | |
| 448 | 448 | c := &cells[i] | |
| 449 | - | gi := r.glyph(glyphKey{c.Rune, c.Bold}) | |
| 449 | + | gi := r.glyph(glyphKey{c.Rune, c.Style}) | |
| 450 | 450 | gx, gy := float32(int(gi)%r.atlasCols)*cw, float32(int(gi)/r.atlasCols)*ch | |
| 451 | 451 | in := &s.inst[i] | |
| 452 | 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 | 458 | s.regions = s.regions[:0] | |
| 459 | 459 | for i, k := range r.uploadKeys { | |
| 460 | 460 | 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 | 462 | gi := int(r.uploadIdx[i]) | |
| 463 | 463 | s.regions = append(s.regions, vulkan.BufferImageCopy{ | |
| 464 | 464 | BufferOffset: vulkan.DeviceSize(off), | |
| @@ -28,7 +28,7 @@ const ( | |||||
| 28 | type Cell struct { | 28 | type Cell struct { | ||
| 29 | Rune rune | 29 | Rune rune | ||
| 30 | FG, BG [3]uint8 | 30 | FG, BG [3]uint8 | ||
| 31 | - | Bold bool | 31 | + | 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 rune | 43 | + | r rune |
| 44 | - | bold bool | 44 | + | style glyph.Style |
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | type slotState uint8 | 47 | 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 uploaded | 173 | // 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}] = 0 | 175 | + | r.glyphs[glyphKey{' ', 0}] = 0 |
| 176 | r.nextGlyph = 1 | 176 | 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.nextGlyph | 178 | + | 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) / atlasSize | 446 | 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)*ch | 450 | 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 * cellBytes | 460 | 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 | |||
| 4 | 4 | import ( | |
| 5 | 5 | vt "github.com/bnema/vev-vt" | |
| 6 | 6 | ||
| 7 | + | "git.bnema.dev/bnema/neferbar/internal/glyph" | |
| 7 | 8 | "git.bnema.dev/bnema/neferbar/internal/gpu" | |
| 8 | 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 | 175 | if r == 0 || c.Continuation { | |
| 175 | 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 | 194 | // 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 to | 194 | // 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 ( | |||
| 5 | 5 | "log/slog" | |
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | + | "git.bnema.dev/bnema/neferbar/internal/glyph" | |
| 8 | 9 | "git.bnema.dev/bnema/neferbar/internal/module" | |
| 9 | 10 | "git.bnema.dev/bnema/neferbar/internal/racecheck" | |
| 10 | 11 | ) | |
| @@ -84,7 +85,7 @@ func TestColorsAndClip(t *testing.T) { | |||
| 84 | 85 | if row[1].FG != l.fg { | |
| 85 | 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 | 89 | t.Fatalf("bold truecolor cell: %+v", row[2]) | |
| 89 | 90 | } | |
| 90 | 91 | } | |
| @@ -125,3 +126,16 @@ func TestUpdateComposeAllocs(t *testing.T) { | |||
| 125 | 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 | + | } | |
| @@ -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 | + | } | |||