feat(bar): restart modules when their script folder changes and add bar.position
8 files · +272 -8
M README.md +3 -0
| @@ -46,11 +46,14 @@ exec = "~/.config/neferbar/clock.sh" | |||
| 46 | 46 | ||
| 47 | 47 | 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. | |
| 48 | 48 | ||
| 49 | + | The bar also watches the folder of each script. Edit a script, or a helper it sources from the same folder, and the modules in that folder restart by themselves. Hidden files (`.name`) and backups (`name~`) are ignored, so an editor's swap file does nothing. | |
| 50 | + | ||
| 49 | 51 | | Setting | Default | Meaning | | |
| 50 | 52 | |---|---|---| | |
| 51 | 53 | | `bar.font` | `JetBrainsMono Nerd Font Mono` | A font family known to fontconfig (`fc-list`). | | |
| 52 | 54 | | `bar.size` | `14` | Text size in logical pixels. | | |
| 53 | 55 | | `bar.scale` | `1.0` | Extra zoom on top of the monitor scale. | | |
| 56 | + | | `bar.position` | `top` | `top` or `bottom`: the screen edge the bar sits on. Applies live. | | |
| 54 | 57 | | `bar.output` | any | A monitor name such as `HDMI-A-1`. Needs a restart. | | |
| 55 | 58 | | `bar.theme` | `auto` | `auto` reads the colors of the terminal you use. Or the path of a theme file (see Colors). | | |
| 56 | 59 | | `bar.accent` | `4` | Which of the theme's 16 colors (0-15) the highlights use. 4 is the ANSI blue; try 2 (green), 6 (cyan) or 5 (magenta) to match your theme. | | |
| @@ -46,11 +46,14 @@ exec = "~/.config/neferbar/clock.sh" | |||||
| 46 | 46 | ||||
| 47 | 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. | 47 | 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. | ||
| 48 | 48 | ||||
| 49 | + | The bar also watches the folder of each script. Edit a script, or a helper it sources from the same folder, and the modules in that folder restart by themselves. Hidden files (`.name`) and backups (`name~`) are ignored, so an editor's swap file does nothing. | |||
| 50 | + | ||||
| 49 | | Setting | Default | Meaning | | 51 | | Setting | Default | Meaning | | ||
| 50 | |---|---|---| | 52 | |---|---|---| | ||
| 51 | | `bar.font` | `JetBrainsMono Nerd Font Mono` | A font family known to fontconfig (`fc-list`). | | 53 | | `bar.font` | `JetBrainsMono Nerd Font Mono` | A font family known to fontconfig (`fc-list`). | | ||
| 52 | | `bar.size` | `14` | Text size in logical pixels. | | 54 | | `bar.size` | `14` | Text size in logical pixels. | | ||
| 53 | | `bar.scale` | `1.0` | Extra zoom on top of the monitor scale. | | 55 | | `bar.scale` | `1.0` | Extra zoom on top of the monitor scale. | | ||
| 56 | + | | `bar.position` | `top` | `top` or `bottom`: the screen edge the bar sits on. Applies live. | | |||
| 54 | | `bar.output` | any | A monitor name such as `HDMI-A-1`. Needs a restart. | | 57 | | `bar.output` | any | A monitor name such as `HDMI-A-1`. Needs a restart. | | ||
| 55 | | `bar.theme` | `auto` | `auto` reads the colors of the terminal you use. Or the path of a theme file (see Colors). | | 58 | | `bar.theme` | `auto` | `auto` reads the colors of the terminal you use. Or the path of a theme file (see Colors). | | ||
| 56 | | `bar.accent` | `4` | Which of the theme's 16 colors (0-15) the highlights use. 4 is the ANSI blue; try 2 (green), 6 (cyan) or 5 (magenta) to match your theme. | | 59 | | `bar.accent` | `4` | Which of the theme's 16 colors (0-15) the highlights use. 4 is the ANSI blue; try 2 (green), 6 (cyan) or 5 (magenta) to match your theme. | | ||
M internal/bar/bar.go +87 -3
| @@ -84,6 +84,11 @@ type Bar struct { | |||
| 84 | 84 | cfgPath string | |
| 85 | 85 | cfgCh chan config.Config | |
| 86 | 86 | ||
| 87 | + | // scriptCh carries the directory of a script that changed on disk. | |
| 88 | + | scriptCh chan string | |
| 89 | + | scriptCancel context.CancelFunc | |
| 90 | + | stale map[string]bool // directories whose modules restart at the next syncModules | |
| 91 | + | ||
| 87 | 92 | theme theme.Theme | |
| 88 | 93 | themeCh chan struct{} | |
| 89 | 94 | themeCancel context.CancelFunc | |
| @@ -121,7 +126,7 @@ type Bar struct { | |||
| 121 | 126 | // empty uses $WAYLAND_DISPLAY. | |
| 122 | 127 | func New(cfg config.Config, cfgPath string, log *slog.Logger, display string) (*Bar, error) { | |
| 123 | 128 | b := &Bar{cfg: cfg, cfgPath: cfgPath, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1), | |
| 124 | - | themeCh: make(chan struct{}, 1)} | |
| 129 | + | themeCh: make(chan struct{}, 1), scriptCh: make(chan string, 16)} | |
| 125 | 130 | var err error | |
| 126 | 131 | if err = b.loadTheme(); err != nil { | |
| 127 | 132 | return nil, err | |
| @@ -199,6 +204,58 @@ func (b *Bar) watchTheme() { | |||
| 199 | 204 | } | |
| 200 | 205 | } | |
| 201 | 206 | ||
| 207 | + | // watchScripts watches the directory of every module's script, so editing a | |
| 208 | + | // script, or a helper it sources from the same directory, restarts the modules | |
| 209 | + | // that live there. It runs again whenever the set of modules changes. | |
| 210 | + | func (b *Bar) watchScripts() { | |
| 211 | + | if b.scriptCancel != nil { | |
| 212 | + | b.scriptCancel() | |
| 213 | + | } | |
| 214 | + | if b.runCtx == nil { | |
| 215 | + | return // Run has not started; it calls syncModules, which calls this | |
| 216 | + | } | |
| 217 | + | ctx, cancel := context.WithCancel(b.runCtx) | |
| 218 | + | b.scriptCancel = cancel | |
| 219 | + | seen := map[string]bool{} | |
| 220 | + | for _, r := range b.runners { | |
| 221 | + | dir := module.ScriptDir(r.cfg.Exec) | |
| 222 | + | if dir == "" || seen[dir] { | |
| 223 | + | continue | |
| 224 | + | } | |
| 225 | + | seen[dir] = true | |
| 226 | + | go func() { | |
| 227 | + | changed := make(chan struct{}, 1) | |
| 228 | + | go func() { | |
| 229 | + | if err := fswatch.WatchDir(ctx, dir, 150*time.Millisecond, changed); err != nil { | |
| 230 | + | b.log.Warn("cannot watch a script directory; edits to its scripts need a restart", "dir", dir, "err", err) | |
| 231 | + | } | |
| 232 | + | }() | |
| 233 | + | for { | |
| 234 | + | select { | |
| 235 | + | case <-ctx.Done(): | |
| 236 | + | return | |
| 237 | + | case <-changed: | |
| 238 | + | select { | |
| 239 | + | case b.scriptCh <- dir: | |
| 240 | + | case <-ctx.Done(): | |
| 241 | + | return | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | }() | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // restartScripts restarts the modules whose script is in dir. | |
| 250 | + | func (b *Bar) restartScripts(dir string) { | |
| 251 | + | b.log.Info("script changed; restarting its modules", "dir", dir) | |
| 252 | + | if b.stale == nil { | |
| 253 | + | b.stale = map[string]bool{} | |
| 254 | + | } | |
| 255 | + | b.stale[dir] = true | |
| 256 | + | b.syncModules(b.cfg.Module) | |
| 257 | + | } | |
| 258 | + | ||
| 202 | 259 | // reloadTheme re-reads the theme after one of its files changed. | |
| 203 | 260 | func (b *Bar) reloadTheme() { | |
| 204 | 261 | base := filepath.Dir(b.cfgPath) | |
| @@ -245,7 +302,7 @@ func (b *Bar) syncModules(want []config.Module) { | |||
| 245 | 302 | for _, w := range want { | |
| 246 | 303 | if r, ok := old[w.Name]; ok { | |
| 247 | 304 | delete(old, w.Name) | |
| 248 | - | if r.cfg == w && slices.Equal(r.env, b.env) { | |
| 305 | + | if r.cfg == w && slices.Equal(r.env, b.env) && !b.stale[module.ScriptDir(w.Exec)] { | |
| 249 | 306 | next = append(next, r) | |
| 250 | 307 | continue | |
| 251 | 308 | } | |
| @@ -261,6 +318,8 @@ func (b *Bar) syncModules(want []config.Module) { | |||
| 261 | 318 | r.cancel() | |
| 262 | 319 | } | |
| 263 | 320 | b.runners = next | |
| 321 | + | clear(b.stale) | |
| 322 | + | b.watchScripts() | |
| 264 | 323 | b.mods = b.mods[:0] | |
| 265 | 324 | for _, r := range next { | |
| 266 | 325 | b.mods = append(b.mods, r.m) | |
| @@ -293,6 +352,21 @@ func (b *Bar) applyConfig(next config.Config) { | |||
| 293 | 352 | } | |
| 294 | 353 | b.syncModules(next.Module) | |
| 295 | 354 | b.dirty = true | |
| 355 | + | if next.Bar.Position != old.Bar.Position && b.surf != nil { | |
| 356 | + | // The edge is part of how a layer surface is created: make a new one. | |
| 357 | + | b.log.Info("moving the bar", "to", next.Bar.Position) | |
| 358 | + | b.dropRenderer() | |
| 359 | + | _, h, _ := b.surf.Size() | |
| 360 | + | if err = b.surf.Close(); err != nil { | |
| 361 | + | b.fail(err) | |
| 362 | + | return | |
| 363 | + | } | |
| 364 | + | if err = b.createSurface(h); err != nil { | |
| 365 | + | b.fail(err) | |
| 366 | + | return | |
| 367 | + | } | |
| 368 | + | b.Stats.Recreated++ | |
| 369 | + | } | |
| 296 | 370 | if fontChanged || next.Bar.Size != old.Bar.Size || next.Bar.Scale != old.Bar.Scale { | |
| 297 | 371 | b.facePx = -1 // reconcile rebuilds the font, and the surface if its height changed | |
| 298 | 372 | b.reconcile() | |
| @@ -341,6 +415,8 @@ func (b *Bar) Run(ctx context.Context) (err error) { | |||
| 341 | 415 | b.applyConfig(next) | |
| 342 | 416 | case <-b.themeCh: | |
| 343 | 417 | b.reloadTheme() | |
| 418 | + | case dir := <-b.scriptCh: | |
| 419 | + | b.restartScripts(dir) | |
| 344 | 420 | case <-b.settleC: | |
| 345 | 421 | b.settleC, b.settling = nil, false | |
| 346 | 422 | b.reconcile() | |
| @@ -399,12 +475,20 @@ func connectError(display string, err error) error { | |||
| 399 | 475 | // estimateHeight is the first request, before the real cell height is known. | |
| 400 | 476 | func estimateHeight(px float64) int32 { return int32(math.Ceil(px * 1.5)) } | |
| 401 | 477 | ||
| 478 | + | // edge is the layer-shell anchor for a bar.position value. | |
| 479 | + | func edge(position string) neferclient.Anchor { | |
| 480 | + | if position == "bottom" { | |
| 481 | + | return neferclient.AnchorBottom | |
| 482 | + | } | |
| 483 | + | return neferclient.AnchorTop | |
| 484 | + | } | |
| 485 | + | ||
| 402 | 486 | func (b *Bar) createSurface(h int32) error { | |
| 403 | 487 | surf, err := b.conn.NewLayerSurface(neferclient.LayerConfig{ | |
| 404 | 488 | Output: b.cfg.Bar.Output, | |
| 405 | 489 | Namespace: "neferbar", | |
| 406 | 490 | Level: neferclient.LayerTop, | |
| 407 | - | Anchors: neferclient.AnchorTop | neferclient.AnchorLeft | neferclient.AnchorRight, | |
| 491 | + | Anchors: edge(b.cfg.Bar.Position) | neferclient.AnchorLeft | neferclient.AnchorRight, | |
| 408 | 492 | ExclusiveZone: h, | |
| 409 | 493 | Height: h, | |
| 410 | 494 | InputRects: []neferclient.Rect{}, // read only: click-through | |
| @@ -84,6 +84,11 @@ type Bar struct { | |||||
| 84 | cfgPath string | 84 | cfgPath string | ||
| 85 | cfgCh chan config.Config | 85 | cfgCh chan config.Config | ||
| 86 | 86 | ||||
| 87 | + | // scriptCh carries the directory of a script that changed on disk. | |||
| 88 | + | scriptCh chan string | |||
| 89 | + | scriptCancel context.CancelFunc | |||
| 90 | + | stale map[string]bool // directories whose modules restart at the next syncModules | |||
| 91 | + | ||||
| 87 | theme theme.Theme | 92 | theme theme.Theme | ||
| 88 | themeCh chan struct{} | 93 | themeCh chan struct{} | ||
| 89 | themeCancel context.CancelFunc | 94 | themeCancel context.CancelFunc | ||
| @@ -121,7 +126,7 @@ type Bar struct { | |||||
| 121 | // empty uses $WAYLAND_DISPLAY. | 126 | // empty uses $WAYLAND_DISPLAY. | ||
| 122 | func New(cfg config.Config, cfgPath string, log *slog.Logger, display string) (*Bar, error) { | 127 | func New(cfg config.Config, cfgPath string, log *slog.Logger, display string) (*Bar, error) { | ||
| 123 | b := &Bar{cfg: cfg, cfgPath: cfgPath, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1), | 128 | b := &Bar{cfg: cfg, cfgPath: cfgPath, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1), | ||
| 124 | - | themeCh: make(chan struct{}, 1)} | 129 | + | themeCh: make(chan struct{}, 1), scriptCh: make(chan string, 16)} |
| 125 | var err error | 130 | var err error | ||
| 126 | if err = b.loadTheme(); err != nil { | 131 | if err = b.loadTheme(); err != nil { | ||
| 127 | return nil, err | 132 | return nil, err | ||
| @@ -199,6 +204,58 @@ func (b *Bar) watchTheme() { | |||||
| 199 | } | 204 | } | ||
| 200 | } | 205 | } | ||
| 201 | 206 | ||||
| 207 | + | // watchScripts watches the directory of every module's script, so editing a | |||
| 208 | + | // script, or a helper it sources from the same directory, restarts the modules | |||
| 209 | + | // that live there. It runs again whenever the set of modules changes. | |||
| 210 | + | func (b *Bar) watchScripts() { | |||
| 211 | + | if b.scriptCancel != nil { | |||
| 212 | + | b.scriptCancel() | |||
| 213 | + | } | |||
| 214 | + | if b.runCtx == nil { | |||
| 215 | + | return // Run has not started; it calls syncModules, which calls this | |||
| 216 | + | } | |||
| 217 | + | ctx, cancel := context.WithCancel(b.runCtx) | |||
| 218 | + | b.scriptCancel = cancel | |||
| 219 | + | seen := map[string]bool{} | |||
| 220 | + | for _, r := range b.runners { | |||
| 221 | + | dir := module.ScriptDir(r.cfg.Exec) | |||
| 222 | + | if dir == "" || seen[dir] { | |||
| 223 | + | continue | |||
| 224 | + | } | |||
| 225 | + | seen[dir] = true | |||
| 226 | + | go func() { | |||
| 227 | + | changed := make(chan struct{}, 1) | |||
| 228 | + | go func() { | |||
| 229 | + | if err := fswatch.WatchDir(ctx, dir, 150*time.Millisecond, changed); err != nil { | |||
| 230 | + | b.log.Warn("cannot watch a script directory; edits to its scripts need a restart", "dir", dir, "err", err) | |||
| 231 | + | } | |||
| 232 | + | }() | |||
| 233 | + | for { | |||
| 234 | + | select { | |||
| 235 | + | case <-ctx.Done(): | |||
| 236 | + | return | |||
| 237 | + | case <-changed: | |||
| 238 | + | select { | |||
| 239 | + | case b.scriptCh <- dir: | |||
| 240 | + | case <-ctx.Done(): | |||
| 241 | + | return | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | }() | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // restartScripts restarts the modules whose script is in dir. | |||
| 250 | + | func (b *Bar) restartScripts(dir string) { | |||
| 251 | + | b.log.Info("script changed; restarting its modules", "dir", dir) | |||
| 252 | + | if b.stale == nil { | |||
| 253 | + | b.stale = map[string]bool{} | |||
| 254 | + | } | |||
| 255 | + | b.stale[dir] = true | |||
| 256 | + | b.syncModules(b.cfg.Module) | |||
| 257 | + | } | |||
| 258 | + | ||||
| 202 | // reloadTheme re-reads the theme after one of its files changed. | 259 | // reloadTheme re-reads the theme after one of its files changed. | ||
| 203 | func (b *Bar) reloadTheme() { | 260 | func (b *Bar) reloadTheme() { | ||
| 204 | base := filepath.Dir(b.cfgPath) | 261 | base := filepath.Dir(b.cfgPath) | ||
| @@ -245,7 +302,7 @@ func (b *Bar) syncModules(want []config.Module) { | |||||
| 245 | for _, w := range want { | 302 | for _, w := range want { | ||
| 246 | if r, ok := old[w.Name]; ok { | 303 | if r, ok := old[w.Name]; ok { | ||
| 247 | delete(old, w.Name) | 304 | delete(old, w.Name) | ||
| 248 | - | if r.cfg == w && slices.Equal(r.env, b.env) { | 305 | + | if r.cfg == w && slices.Equal(r.env, b.env) && !b.stale[module.ScriptDir(w.Exec)] { |
| 249 | next = append(next, r) | 306 | next = append(next, r) | ||
| 250 | continue | 307 | continue | ||
| 251 | } | 308 | } | ||
| @@ -261,6 +318,8 @@ func (b *Bar) syncModules(want []config.Module) { | |||||
| 261 | r.cancel() | 318 | r.cancel() | ||
| 262 | } | 319 | } | ||
| 263 | b.runners = next | 320 | b.runners = next | ||
| 321 | + | clear(b.stale) | |||
| 322 | + | b.watchScripts() | |||
| 264 | b.mods = b.mods[:0] | 323 | b.mods = b.mods[:0] | ||
| 265 | for _, r := range next { | 324 | for _, r := range next { | ||
| 266 | b.mods = append(b.mods, r.m) | 325 | b.mods = append(b.mods, r.m) | ||
| @@ -293,6 +352,21 @@ func (b *Bar) applyConfig(next config.Config) { | |||||
| 293 | } | 352 | } | ||
| 294 | b.syncModules(next.Module) | 353 | b.syncModules(next.Module) | ||
| 295 | b.dirty = true | 354 | b.dirty = true | ||
| 355 | + | if next.Bar.Position != old.Bar.Position && b.surf != nil { | |||
| 356 | + | // The edge is part of how a layer surface is created: make a new one. | |||
| 357 | + | b.log.Info("moving the bar", "to", next.Bar.Position) | |||
| 358 | + | b.dropRenderer() | |||
| 359 | + | _, h, _ := b.surf.Size() | |||
| 360 | + | if err = b.surf.Close(); err != nil { | |||
| 361 | + | b.fail(err) | |||
| 362 | + | return | |||
| 363 | + | } | |||
| 364 | + | if err = b.createSurface(h); err != nil { | |||
| 365 | + | b.fail(err) | |||
| 366 | + | return | |||
| 367 | + | } | |||
| 368 | + | b.Stats.Recreated++ | |||
| 369 | + | } | |||
| 296 | if fontChanged || next.Bar.Size != old.Bar.Size || next.Bar.Scale != old.Bar.Scale { | 370 | if fontChanged || next.Bar.Size != old.Bar.Size || next.Bar.Scale != old.Bar.Scale { | ||
| 297 | b.facePx = -1 // reconcile rebuilds the font, and the surface if its height changed | 371 | b.facePx = -1 // reconcile rebuilds the font, and the surface if its height changed | ||
| 298 | b.reconcile() | 372 | b.reconcile() | ||
| @@ -341,6 +415,8 @@ func (b *Bar) Run(ctx context.Context) (err error) { | |||||
| 341 | b.applyConfig(next) | 415 | b.applyConfig(next) | ||
| 342 | case <-b.themeCh: | 416 | case <-b.themeCh: | ||
| 343 | b.reloadTheme() | 417 | b.reloadTheme() | ||
| 418 | + | case dir := <-b.scriptCh: | |||
| 419 | + | b.restartScripts(dir) | |||
| 344 | case <-b.settleC: | 420 | case <-b.settleC: | ||
| 345 | b.settleC, b.settling = nil, false | 421 | b.settleC, b.settling = nil, false | ||
| 346 | b.reconcile() | 422 | b.reconcile() | ||
| @@ -399,12 +475,20 @@ func connectError(display string, err error) error { | |||||
| 399 | // estimateHeight is the first request, before the real cell height is known. | 475 | // estimateHeight is the first request, before the real cell height is known. | ||
| 400 | func estimateHeight(px float64) int32 { return int32(math.Ceil(px * 1.5)) } | 476 | func estimateHeight(px float64) int32 { return int32(math.Ceil(px * 1.5)) } | ||
| 401 | 477 | ||||
| 478 | + | // edge is the layer-shell anchor for a bar.position value. | |||
| 479 | + | func edge(position string) neferclient.Anchor { | |||
| 480 | + | if position == "bottom" { | |||
| 481 | + | return neferclient.AnchorBottom | |||
| 482 | + | } | |||
| 483 | + | return neferclient.AnchorTop | |||
| 484 | + | } | |||
| 485 | + | ||||
| 402 | func (b *Bar) createSurface(h int32) error { | 486 | func (b *Bar) createSurface(h int32) error { | ||
| 403 | surf, err := b.conn.NewLayerSurface(neferclient.LayerConfig{ | 487 | surf, err := b.conn.NewLayerSurface(neferclient.LayerConfig{ | ||
| 404 | Output: b.cfg.Bar.Output, | 488 | Output: b.cfg.Bar.Output, | ||
| 405 | Namespace: "neferbar", | 489 | Namespace: "neferbar", | ||
| 406 | Level: neferclient.LayerTop, | 490 | Level: neferclient.LayerTop, | ||
| 407 | - | Anchors: neferclient.AnchorTop | neferclient.AnchorLeft | neferclient.AnchorRight, | 491 | + | Anchors: edge(b.cfg.Bar.Position) | neferclient.AnchorLeft | neferclient.AnchorRight, |
| 408 | ExclusiveZone: h, | 492 | ExclusiveZone: h, | ||
| 409 | Height: h, | 493 | Height: h, | ||
| 410 | InputRects: []neferclient.Rect{}, // read only: click-through | 494 | InputRects: []neferclient.Rect{}, // read only: click-through | ||
M internal/config/config.go +6 -1
| @@ -17,6 +17,8 @@ type Bar struct { | |||
| 17 | 17 | Scale float64 `toml:"scale"` | |
| 18 | 18 | // Output is the wl_output name; empty lets the compositor choose. | |
| 19 | 19 | Output string `toml:"output"` | |
| 20 | + | // Position is the screen edge the bar sits on: "top" or "bottom". | |
| 21 | + | Position string `toml:"position"` | |
| 20 | 22 | // Theme is "auto" (the colors of the terminal in use) or the path of a | |
| 21 | 23 | // terminal theme or config file: kitty, foot, ghostty or alacritty. | |
| 22 | 24 | Theme string `toml:"theme"` | |
| @@ -45,7 +47,7 @@ type Config struct { | |||
| 45 | 47 | func Default() Config { | |
| 46 | 48 | return Config{Bar: Bar{ | |
| 47 | 49 | Font: "JetBrainsMono Nerd Font Mono", Size: 14, Scale: 1, | |
| 48 | - | Theme: "auto", Accent: 4, | |
| 50 | + | Theme: "auto", Accent: 4, Position: "top", | |
| 49 | 51 | }} | |
| 50 | 52 | } | |
| 51 | 53 | ||
| @@ -96,6 +98,9 @@ func (c *Config) Validate() error { | |||
| 96 | 98 | return err | |
| 97 | 99 | } | |
| 98 | 100 | } | |
| 101 | + | if b.Position != "top" && b.Position != "bottom" { | |
| 102 | + | return fmt.Errorf(`config: bar.position %q must be "top" or "bottom"`, b.Position) | |
| 103 | + | } | |
| 99 | 104 | if b.Accent < 0 || b.Accent > 15 { | |
| 100 | 105 | return fmt.Errorf("config: bar.accent %d must be a color number from 0 to 15", b.Accent) | |
| 101 | 106 | } | |
| @@ -17,6 +17,8 @@ type Bar struct { | |||||
| 17 | Scale float64 `toml:"scale"` | 17 | Scale float64 `toml:"scale"` | ||
| 18 | // Output is the wl_output name; empty lets the compositor choose. | 18 | // Output is the wl_output name; empty lets the compositor choose. | ||
| 19 | Output string `toml:"output"` | 19 | Output string `toml:"output"` | ||
| 20 | + | // Position is the screen edge the bar sits on: "top" or "bottom". | |||
| 21 | + | Position string `toml:"position"` | |||
| 20 | // Theme is "auto" (the colors of the terminal in use) or the path of a | 22 | // Theme is "auto" (the colors of the terminal in use) or the path of a | ||
| 21 | // terminal theme or config file: kitty, foot, ghostty or alacritty. | 23 | // terminal theme or config file: kitty, foot, ghostty or alacritty. | ||
| 22 | Theme string `toml:"theme"` | 24 | Theme string `toml:"theme"` | ||
| @@ -45,7 +47,7 @@ type Config struct { | |||||
| 45 | func Default() Config { | 47 | func Default() Config { | ||
| 46 | return Config{Bar: Bar{ | 48 | return Config{Bar: Bar{ | ||
| 47 | Font: "JetBrainsMono Nerd Font Mono", Size: 14, Scale: 1, | 49 | Font: "JetBrainsMono Nerd Font Mono", Size: 14, Scale: 1, | ||
| 48 | - | Theme: "auto", Accent: 4, | 50 | + | Theme: "auto", Accent: 4, Position: "top", |
| 49 | }} | 51 | }} | ||
| 50 | } | 52 | } | ||
| 51 | 53 | ||||
| @@ -96,6 +98,9 @@ func (c *Config) Validate() error { | |||||
| 96 | return err | 98 | return err | ||
| 97 | } | 99 | } | ||
| 98 | } | 100 | } | ||
| 101 | + | if b.Position != "top" && b.Position != "bottom" { | |||
| 102 | + | return fmt.Errorf(`config: bar.position %q must be "top" or "bottom"`, b.Position) | |||
| 103 | + | } | |||
| 99 | if b.Accent < 0 || b.Accent > 15 { | 104 | if b.Accent < 0 || b.Accent > 15 { | ||
| 100 | return fmt.Errorf("config: bar.accent %d must be a color number from 0 to 15", b.Accent) | 105 | return fmt.Errorf("config: bar.accent %d must be a color number from 0 to 15", b.Accent) | ||
| 101 | } | 106 | } | ||
M internal/config/config_test.go +14 -0
| @@ -37,6 +37,7 @@ func TestLoadErrors(t *testing.T) { | |||
| 37 | 37 | "bad size": "[bar]\nsize = 1\n", | |
| 38 | 38 | "bad scale": "[bar]\nscale = 20\n", | |
| 39 | 39 | "bad color": "[bar]\nbackground = \"red\"\n", | |
| 40 | + | "position": "[bar]\nposition = \"middle\"\n", | |
| 40 | 41 | "accent low": "[bar]\naccent = -1\n", | |
| 41 | 42 | "accent high": "[bar]\naccent = 16\n", | |
| 42 | 43 | "syntax error": "[bar\n", | |
| @@ -69,3 +70,16 @@ func TestAccentDefaultsToBlue(t *testing.T) { | |||
| 69 | 70 | t.Fatalf("accent = %d, err %v; want 2", c.Bar.Accent, err) | |
| 70 | 71 | } | |
| 71 | 72 | } | |
| 73 | + | ||
| 74 | + | func TestPositionDefaultsToTop(t *testing.T) { | |
| 75 | + | for config, want := range map[string]string{ | |
| 76 | + | "[bar]\nsize = 14\n": "top", | |
| 77 | + | "[bar]\nposition = \"top\"\n": "top", | |
| 78 | + | "[bar]\nposition = \"bottom\"\n": "bottom", | |
| 79 | + | } { | |
| 80 | + | c, err := Load(write(t, config), true) | |
| 81 | + | if err != nil || c.Bar.Position != want { | |
| 82 | + | t.Errorf("%q: position = %q, err %v; want %q", config, c.Bar.Position, err, want) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | } | |
| @@ -37,6 +37,7 @@ func TestLoadErrors(t *testing.T) { | |||||
| 37 | "bad size": "[bar]\nsize = 1\n", | 37 | "bad size": "[bar]\nsize = 1\n", | ||
| 38 | "bad scale": "[bar]\nscale = 20\n", | 38 | "bad scale": "[bar]\nscale = 20\n", | ||
| 39 | "bad color": "[bar]\nbackground = \"red\"\n", | 39 | "bad color": "[bar]\nbackground = \"red\"\n", | ||
| 40 | + | "position": "[bar]\nposition = \"middle\"\n", | |||
| 40 | "accent low": "[bar]\naccent = -1\n", | 41 | "accent low": "[bar]\naccent = -1\n", | ||
| 41 | "accent high": "[bar]\naccent = 16\n", | 42 | "accent high": "[bar]\naccent = 16\n", | ||
| 42 | "syntax error": "[bar\n", | 43 | "syntax error": "[bar\n", | ||
| @@ -69,3 +70,16 @@ func TestAccentDefaultsToBlue(t *testing.T) { | |||||
| 69 | t.Fatalf("accent = %d, err %v; want 2", c.Bar.Accent, err) | 70 | t.Fatalf("accent = %d, err %v; want 2", c.Bar.Accent, err) | ||
| 70 | } | 71 | } | ||
| 71 | } | 72 | } | ||
| 73 | + | ||||
| 74 | + | func TestPositionDefaultsToTop(t *testing.T) { | |||
| 75 | + | for config, want := range map[string]string{ | |||
| 76 | + | "[bar]\nsize = 14\n": "top", | |||
| 77 | + | "[bar]\nposition = \"top\"\n": "top", | |||
| 78 | + | "[bar]\nposition = \"bottom\"\n": "bottom", | |||
| 79 | + | } { | |||
| 80 | + | c, err := Load(write(t, config), true) | |||
| 81 | + | if err != nil || c.Bar.Position != want { | |||
| 82 | + | t.Errorf("%q: position = %q, err %v; want %q", config, c.Bar.Position, err, want) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | } | |||
M internal/fswatch/fswatch.go +27 -4
| @@ -1,4 +1,5 @@ | |||
| 1 | - | // Package fswatch reports changes to one file with inotify. | |
| 1 | + | // Package fswatch reports changes to one file, or to the files of one | |
| 2 | + | // directory, with inotify. | |
| 2 | 3 | // | |
| 3 | 4 | // It watches the parent directory, so a file that is replaced by a rename (the | |
| 4 | 5 | // way editors and NeferWL write) is noticed, and it follows symlinks, so a | |
| @@ -54,6 +55,23 @@ func files(path string) []string { | |||
| 54 | 55 | // full, the pending signal already says "something changed", which is all a | |
| 55 | 56 | // reader needs. Use a channel with a buffer of 1. Watch blocks until ctx ends. | |
| 56 | 57 | func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- struct{}) error { | |
| 58 | + | return watch(ctx, path, false, debounce, out) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // WatchDir is Watch for every file directly inside dir. Hidden files (a name | |
| 62 | + | // starting with a dot, such as an editor's swap file) and backup files (a name | |
| 63 | + | // ending in "~") are ignored. Subdirectories are not watched. | |
| 64 | + | func WatchDir(ctx context.Context, dir string, debounce time.Duration, out chan<- struct{}) error { | |
| 65 | + | return watch(ctx, dir, true, debounce, out) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // visible reports whether a change to a file called name in a watched | |
| 69 | + | // directory is worth reporting. | |
| 70 | + | func visible(name string) bool { | |
| 71 | + | return name != "" && name[0] != '.' && name[len(name)-1] != '~' | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func watch(ctx context.Context, path string, dirMode bool, debounce time.Duration, out chan<- struct{}) error { | |
| 57 | 75 | fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) | |
| 58 | 76 | if err != nil { | |
| 59 | 77 | return err | |
| @@ -78,6 +96,9 @@ func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- | |||
| 78 | 96 | } | |
| 79 | 97 | clear(names) | |
| 80 | 98 | watched = files(path) | |
| 99 | + | if dirMode { | |
| 100 | + | watched = []string{filepath.Join(path, "x")} // its Dir is the directory itself | |
| 101 | + | } | |
| 81 | 102 | for _, file := range watched { | |
| 82 | 103 | wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask) | |
| 83 | 104 | if errors.Is(err, unix.ENOENT) { | |
| @@ -90,7 +111,9 @@ func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- | |||
| 90 | 111 | if names[int32(wd)] == nil { | |
| 91 | 112 | names[int32(wd)] = map[string]bool{} | |
| 92 | 113 | } | |
| 93 | - | names[int32(wd)][filepath.Base(file)] = true | |
| 114 | + | if !dirMode { | |
| 115 | + | names[int32(wd)][filepath.Base(file)] = true | |
| 116 | + | } | |
| 94 | 117 | } | |
| 95 | 118 | } | |
| 96 | 119 | ||
| @@ -147,9 +170,9 @@ func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- | |||
| 147 | 170 | for len(name) > 0 && name[len(name)-1] == 0 { | |
| 148 | 171 | name = name[:len(name)-1] | |
| 149 | 172 | } | |
| 150 | - | if names[ev.Wd][string(name)] && ev.Mask&mask != 0 { | |
| 173 | + | if ev.Mask&mask != 0 && (names[ev.Wd][string(name)] || (dirMode && names[ev.Wd] != nil && visible(string(name)))) { | |
| 151 | 174 | pending = time.Now().Add(debounce) | |
| 152 | - | if !slices.Equal(watched, files(path)) { | |
| 175 | + | if !dirMode && !slices.Equal(watched, files(path)) { | |
| 153 | 176 | rewatch = true // a symlink now points elsewhere | |
| 154 | 177 | } | |
| 155 | 178 | } | |
| @@ -1,4 +1,5 @@ | |||||
| 1 | - | // Package fswatch reports changes to one file with inotify. | 1 | + | // Package fswatch reports changes to one file, or to the files of one |
| 2 | + | // directory, with inotify. | |||
| 2 | // | 3 | // | ||
| 3 | // It watches the parent directory, so a file that is replaced by a rename (the | 4 | // It watches the parent directory, so a file that is replaced by a rename (the | ||
| 4 | // way editors and NeferWL write) is noticed, and it follows symlinks, so a | 5 | // way editors and NeferWL write) is noticed, and it follows symlinks, so a | ||
| @@ -54,6 +55,23 @@ func files(path string) []string { | |||||
| 54 | // full, the pending signal already says "something changed", which is all a | 55 | // full, the pending signal already says "something changed", which is all a | ||
| 55 | // reader needs. Use a channel with a buffer of 1. Watch blocks until ctx ends. | 56 | // reader needs. Use a channel with a buffer of 1. Watch blocks until ctx ends. | ||
| 56 | func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- struct{}) error { | 57 | func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- struct{}) error { | ||
| 58 | + | return watch(ctx, path, false, debounce, out) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // WatchDir is Watch for every file directly inside dir. Hidden files (a name | |||
| 62 | + | // starting with a dot, such as an editor's swap file) and backup files (a name | |||
| 63 | + | // ending in "~") are ignored. Subdirectories are not watched. | |||
| 64 | + | func WatchDir(ctx context.Context, dir string, debounce time.Duration, out chan<- struct{}) error { | |||
| 65 | + | return watch(ctx, dir, true, debounce, out) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // visible reports whether a change to a file called name in a watched | |||
| 69 | + | // directory is worth reporting. | |||
| 70 | + | func visible(name string) bool { | |||
| 71 | + | return name != "" && name[0] != '.' && name[len(name)-1] != '~' | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func watch(ctx context.Context, path string, dirMode bool, debounce time.Duration, out chan<- struct{}) error { | |||
| 57 | fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) | 75 | fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) | ||
| 58 | if err != nil { | 76 | if err != nil { | ||
| 59 | return err | 77 | return err | ||
| @@ -78,6 +96,9 @@ func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- | |||||
| 78 | } | 96 | } | ||
| 79 | clear(names) | 97 | clear(names) | ||
| 80 | watched = files(path) | 98 | watched = files(path) | ||
| 99 | + | if dirMode { | |||
| 100 | + | watched = []string{filepath.Join(path, "x")} // its Dir is the directory itself | |||
| 101 | + | } | |||
| 81 | for _, file := range watched { | 102 | for _, file := range watched { | ||
| 82 | wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask) | 103 | wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask) | ||
| 83 | if errors.Is(err, unix.ENOENT) { | 104 | if errors.Is(err, unix.ENOENT) { | ||
| @@ -90,7 +111,9 @@ func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- | |||||
| 90 | if names[int32(wd)] == nil { | 111 | if names[int32(wd)] == nil { | ||
| 91 | names[int32(wd)] = map[string]bool{} | 112 | names[int32(wd)] = map[string]bool{} | ||
| 92 | } | 113 | } | ||
| 93 | - | names[int32(wd)][filepath.Base(file)] = true | 114 | + | if !dirMode { |
| 115 | + | names[int32(wd)][filepath.Base(file)] = true | |||
| 116 | + | } | |||
| 94 | } | 117 | } | ||
| 95 | } | 118 | } | ||
| 96 | 119 | ||||
| @@ -147,9 +170,9 @@ func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- | |||||
| 147 | for len(name) > 0 && name[len(name)-1] == 0 { | 170 | for len(name) > 0 && name[len(name)-1] == 0 { | ||
| 148 | name = name[:len(name)-1] | 171 | name = name[:len(name)-1] | ||
| 149 | } | 172 | } | ||
| 150 | - | if names[ev.Wd][string(name)] && ev.Mask&mask != 0 { | 173 | + | if ev.Mask&mask != 0 && (names[ev.Wd][string(name)] || (dirMode && names[ev.Wd] != nil && visible(string(name)))) { |
| 151 | pending = time.Now().Add(debounce) | 174 | pending = time.Now().Add(debounce) | ||
| 152 | - | if !slices.Equal(watched, files(path)) { | 175 | + | if !dirMode && !slices.Equal(watched, files(path)) { |
| 153 | rewatch = true // a symlink now points elsewhere | 176 | rewatch = true // a symlink now points elsewhere | ||
| 154 | } | 177 | } | ||
| 155 | } | 178 | } | ||
A internal/fswatch/fswatch_test.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package fswatch | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func waitSignal(ch <-chan struct{}, d time.Duration) bool { | |
| 12 | + | select { | |
| 13 | + | case <-ch: | |
| 14 | + | return true | |
| 15 | + | case <-time.After(d): | |
| 16 | + | return false | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // start runs WatchDir and returns once the watch is surely in place. | |
| 21 | + | func start(t *testing.T, dir string) <-chan struct{} { | |
| 22 | + | t.Helper() | |
| 23 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 24 | + | t.Cleanup(cancel) | |
| 25 | + | ch := make(chan struct{}, 1) | |
| 26 | + | go func() { _ = WatchDir(ctx, dir, 30*time.Millisecond, ch) }() | |
| 27 | + | time.Sleep(150 * time.Millisecond) | |
| 28 | + | return ch | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestWatchDirReportsAnyFileOfTheDirectory(t *testing.T) { | |
| 32 | + | dir := t.TempDir() | |
| 33 | + | ch := start(t, dir) | |
| 34 | + | if err := os.WriteFile(filepath.Join(dir, "lib.sh"), []byte("x"), 0o644); err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | if !waitSignal(ch, 2*time.Second) { | |
| 38 | + | t.Fatal("a new file in the directory was not reported") | |
| 39 | + | } | |
| 40 | + | // An edit of an existing file, and a replacement by rename, are reported too. | |
| 41 | + | if err := os.WriteFile(filepath.Join(dir, "lib.sh"), []byte("y"), 0o644); err != nil { | |
| 42 | + | t.Fatal(err) | |
| 43 | + | } | |
| 44 | + | if !waitSignal(ch, 2*time.Second) { | |
| 45 | + | t.Fatal("an edit was not reported") | |
| 46 | + | } | |
| 47 | + | tmp := filepath.Join(dir, "lib.sh.new") | |
| 48 | + | _ = os.WriteFile(tmp, []byte("z"), 0o644) | |
| 49 | + | waitSignal(ch, 2*time.Second) // the .new file itself | |
| 50 | + | if err := os.Rename(tmp, filepath.Join(dir, "lib.sh")); err != nil { | |
| 51 | + | t.Fatal(err) | |
| 52 | + | } | |
| 53 | + | if !waitSignal(ch, 2*time.Second) { | |
| 54 | + | t.Fatal("a rename over a file was not reported") | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestWatchDirIgnoresHiddenAndBackupFiles(t *testing.T) { | |
| 59 | + | dir := t.TempDir() | |
| 60 | + | ch := start(t, dir) | |
| 61 | + | for _, name := range []string{".left.sh.swp", "left.sh~"} { | |
| 62 | + | if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil { | |
| 63 | + | t.Fatal(err) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | if waitSignal(ch, 400*time.Millisecond) { | |
| 67 | + | t.Fatal("a swap or backup file was reported") | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestWatchDirWaitsForAMissingDirectory(t *testing.T) { | |
| 72 | + | dir := filepath.Join(t.TempDir(), "later") | |
| 73 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 74 | + | t.Cleanup(cancel) | |
| 75 | + | ch := make(chan struct{}, 1) | |
| 76 | + | go func() { _ = WatchDir(ctx, dir, 30*time.Millisecond, ch) }() | |
| 77 | + | time.Sleep(100 * time.Millisecond) | |
| 78 | + | if err := os.Mkdir(dir, 0o755); err != nil { | |
| 79 | + | t.Fatal(err) | |
| 80 | + | } | |
| 81 | + | time.Sleep(2500 * time.Millisecond) // the missing directory is looked for every 2 s | |
| 82 | + | if err := os.WriteFile(filepath.Join(dir, "a.sh"), []byte("x"), 0o644); err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | if !waitSignal(ch, 2*time.Second) { | |
| 86 | + | t.Fatal("a directory that appeared later was not watched") | |
| 87 | + | } | |
| 88 | + | } | |
| @@ -0,0 +1,88 @@ | |||||
| 1 | + | package fswatch | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func waitSignal(ch <-chan struct{}, d time.Duration) bool { | |||
| 12 | + | select { | |||
| 13 | + | case <-ch: | |||
| 14 | + | return true | |||
| 15 | + | case <-time.After(d): | |||
| 16 | + | return false | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // start runs WatchDir and returns once the watch is surely in place. | |||
| 21 | + | func start(t *testing.T, dir string) <-chan struct{} { | |||
| 22 | + | t.Helper() | |||
| 23 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 24 | + | t.Cleanup(cancel) | |||
| 25 | + | ch := make(chan struct{}, 1) | |||
| 26 | + | go func() { _ = WatchDir(ctx, dir, 30*time.Millisecond, ch) }() | |||
| 27 | + | time.Sleep(150 * time.Millisecond) | |||
| 28 | + | return ch | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestWatchDirReportsAnyFileOfTheDirectory(t *testing.T) { | |||
| 32 | + | dir := t.TempDir() | |||
| 33 | + | ch := start(t, dir) | |||
| 34 | + | if err := os.WriteFile(filepath.Join(dir, "lib.sh"), []byte("x"), 0o644); err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | if !waitSignal(ch, 2*time.Second) { | |||
| 38 | + | t.Fatal("a new file in the directory was not reported") | |||
| 39 | + | } | |||
| 40 | + | // An edit of an existing file, and a replacement by rename, are reported too. | |||
| 41 | + | if err := os.WriteFile(filepath.Join(dir, "lib.sh"), []byte("y"), 0o644); err != nil { | |||
| 42 | + | t.Fatal(err) | |||
| 43 | + | } | |||
| 44 | + | if !waitSignal(ch, 2*time.Second) { | |||
| 45 | + | t.Fatal("an edit was not reported") | |||
| 46 | + | } | |||
| 47 | + | tmp := filepath.Join(dir, "lib.sh.new") | |||
| 48 | + | _ = os.WriteFile(tmp, []byte("z"), 0o644) | |||
| 49 | + | waitSignal(ch, 2*time.Second) // the .new file itself | |||
| 50 | + | if err := os.Rename(tmp, filepath.Join(dir, "lib.sh")); err != nil { | |||
| 51 | + | t.Fatal(err) | |||
| 52 | + | } | |||
| 53 | + | if !waitSignal(ch, 2*time.Second) { | |||
| 54 | + | t.Fatal("a rename over a file was not reported") | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestWatchDirIgnoresHiddenAndBackupFiles(t *testing.T) { | |||
| 59 | + | dir := t.TempDir() | |||
| 60 | + | ch := start(t, dir) | |||
| 61 | + | for _, name := range []string{".left.sh.swp", "left.sh~"} { | |||
| 62 | + | if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil { | |||
| 63 | + | t.Fatal(err) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | if waitSignal(ch, 400*time.Millisecond) { | |||
| 67 | + | t.Fatal("a swap or backup file was reported") | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestWatchDirWaitsForAMissingDirectory(t *testing.T) { | |||
| 72 | + | dir := filepath.Join(t.TempDir(), "later") | |||
| 73 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 74 | + | t.Cleanup(cancel) | |||
| 75 | + | ch := make(chan struct{}, 1) | |||
| 76 | + | go func() { _ = WatchDir(ctx, dir, 30*time.Millisecond, ch) }() | |||
| 77 | + | time.Sleep(100 * time.Millisecond) | |||
| 78 | + | if err := os.Mkdir(dir, 0o755); err != nil { | |||
| 79 | + | t.Fatal(err) | |||
| 80 | + | } | |||
| 81 | + | time.Sleep(2500 * time.Millisecond) // the missing directory is looked for every 2 s | |||
| 82 | + | if err := os.WriteFile(filepath.Join(dir, "a.sh"), []byte("x"), 0o644); err != nil { | |||
| 83 | + | t.Fatal(err) | |||
| 84 | + | } | |||
| 85 | + | if !waitSignal(ch, 2*time.Second) { | |||
| 86 | + | t.Fatal("a directory that appeared later was not watched") | |||
| 87 | + | } | |||
| 88 | + | } | |||
M internal/module/module.go +27 -0
| @@ -12,6 +12,8 @@ import ( | |||
| 12 | 12 | "log/slog" | |
| 13 | 13 | "os" | |
| 14 | 14 | "os/exec" | |
| 15 | + | "path/filepath" | |
| 16 | + | "strings" | |
| 15 | 17 | "sync" | |
| 16 | 18 | "syscall" | |
| 17 | 19 | "time" | |
| @@ -219,3 +221,28 @@ func (w *logWriter) Write(p []byte) (int, error) { | |||
| 219 | 221 | ||
| 220 | 222 | // PublishForTest injects a frame as if the script had printed it. | |
| 221 | 223 | func PublishForTest(m *Module, frame []byte) { m.publish(frame) } | |
| 224 | + | ||
| 225 | + | // ScriptDir is the directory of the program a module's command starts, or "" | |
| 226 | + | // when it has none to watch: a bare name such as "date" is found through $PATH, | |
| 227 | + | // and a command that cannot be split into words is left alone. A leading "~/" | |
| 228 | + | // is the home directory, as the shell reads it, and a relative path is taken | |
| 229 | + | // from the current directory. | |
| 230 | + | func ScriptDir(command string) string { | |
| 231 | + | fields := strings.Fields(command) | |
| 232 | + | if len(fields) == 0 || !strings.Contains(fields[0], "/") { | |
| 233 | + | return "" | |
| 234 | + | } | |
| 235 | + | path := fields[0] | |
| 236 | + | if rest, ok := strings.CutPrefix(path, "~/"); ok { | |
| 237 | + | home, err := os.UserHomeDir() | |
| 238 | + | if err != nil { | |
| 239 | + | return "" | |
| 240 | + | } | |
| 241 | + | path = filepath.Join(home, rest) | |
| 242 | + | } | |
| 243 | + | abs, err := filepath.Abs(path) | |
| 244 | + | if err != nil { | |
| 245 | + | return "" | |
| 246 | + | } | |
| 247 | + | return filepath.Dir(abs) | |
| 248 | + | } | |
| @@ -12,6 +12,8 @@ import ( | |||||
| 12 | "log/slog" | 12 | "log/slog" | ||
| 13 | "os" | 13 | "os" | ||
| 14 | "os/exec" | 14 | "os/exec" | ||
| 15 | + | "path/filepath" | |||
| 16 | + | "strings" | |||
| 15 | "sync" | 17 | "sync" | ||
| 16 | "syscall" | 18 | "syscall" | ||
| 17 | "time" | 19 | "time" | ||
| @@ -219,3 +221,28 @@ func (w *logWriter) Write(p []byte) (int, error) { | |||||
| 219 | 221 | ||||
| 220 | // PublishForTest injects a frame as if the script had printed it. | 222 | // PublishForTest injects a frame as if the script had printed it. | ||
| 221 | func PublishForTest(m *Module, frame []byte) { m.publish(frame) } | 223 | func PublishForTest(m *Module, frame []byte) { m.publish(frame) } | ||
| 224 | + | ||||
| 225 | + | // ScriptDir is the directory of the program a module's command starts, or "" | |||
| 226 | + | // when it has none to watch: a bare name such as "date" is found through $PATH, | |||
| 227 | + | // and a command that cannot be split into words is left alone. A leading "~/" | |||
| 228 | + | // is the home directory, as the shell reads it, and a relative path is taken | |||
| 229 | + | // from the current directory. | |||
| 230 | + | func ScriptDir(command string) string { | |||
| 231 | + | fields := strings.Fields(command) | |||
| 232 | + | if len(fields) == 0 || !strings.Contains(fields[0], "/") { | |||
| 233 | + | return "" | |||
| 234 | + | } | |||
| 235 | + | path := fields[0] | |||
| 236 | + | if rest, ok := strings.CutPrefix(path, "~/"); ok { | |||
| 237 | + | home, err := os.UserHomeDir() | |||
| 238 | + | if err != nil { | |||
| 239 | + | return "" | |||
| 240 | + | } | |||
| 241 | + | path = filepath.Join(home, rest) | |||
| 242 | + | } | |||
| 243 | + | abs, err := filepath.Abs(path) | |||
| 244 | + | if err != nil { | |||
| 245 | + | return "" | |||
| 246 | + | } | |||
| 247 | + | return filepath.Dir(abs) | |||
| 248 | + | } | |||
M internal/module/module_test.go +20 -0
| @@ -111,3 +111,23 @@ func TestStopEndsTheScriptsChildren(t *testing.T) { | |||
| 111 | 111 | t.Fatal("a child of the script is still running after Wait returned") | |
| 112 | 112 | } | |
| 113 | 113 | } | |
| 114 | + | ||
| 115 | + | func TestScriptDir(t *testing.T) { | |
| 116 | + | home, _ := os.UserHomeDir() | |
| 117 | + | cwd, _ := os.Getwd() | |
| 118 | + | for command, want := range map[string]string{ | |
| 119 | + | "/opt/bar/left.sh": "/opt/bar", | |
| 120 | + | "/opt/bar/left.sh 60 30": "/opt/bar", | |
| 121 | + | "~/.config/neferbar/a.sh": filepath.Join(home, ".config/neferbar"), | |
| 122 | + | "examples/modules/clock.sh": filepath.Join(cwd, "examples/modules"), | |
| 123 | + | "./local.sh": cwd, | |
| 124 | + | "date": "", | |
| 125 | + | "sh -c 'echo hi'": "", | |
| 126 | + | "": "", | |
| 127 | + | " ": "", | |
| 128 | + | } { | |
| 129 | + | if got := ScriptDir(command); got != want { | |
| 130 | + | t.Errorf("ScriptDir(%q) = %q, want %q", command, got, want) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | } | |
| @@ -111,3 +111,23 @@ func TestStopEndsTheScriptsChildren(t *testing.T) { | |||||
| 111 | t.Fatal("a child of the script is still running after Wait returned") | 111 | t.Fatal("a child of the script is still running after Wait returned") | ||
| 112 | } | 112 | } | ||
| 113 | } | 113 | } | ||
| 114 | + | ||||
| 115 | + | func TestScriptDir(t *testing.T) { | |||
| 116 | + | home, _ := os.UserHomeDir() | |||
| 117 | + | cwd, _ := os.Getwd() | |||
| 118 | + | for command, want := range map[string]string{ | |||
| 119 | + | "/opt/bar/left.sh": "/opt/bar", | |||
| 120 | + | "/opt/bar/left.sh 60 30": "/opt/bar", | |||
| 121 | + | "~/.config/neferbar/a.sh": filepath.Join(home, ".config/neferbar"), | |||
| 122 | + | "examples/modules/clock.sh": filepath.Join(cwd, "examples/modules"), | |||
| 123 | + | "./local.sh": cwd, | |||
| 124 | + | "date": "", | |||
| 125 | + | "sh -c 'echo hi'": "", | |||
| 126 | + | "": "", | |||
| 127 | + | " ": "", | |||
| 128 | + | } { | |||
| 129 | + | if got := ScriptDir(command); got != want { | |||
| 130 | + | t.Errorf("ScriptDir(%q) = %q, want %q", command, got, want) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | } | |||