diff --git a/build.zig b/build.zig index 43a0333..2640ad8 100644 --- a/build.zig +++ b/build.zig @@ -118,46 +118,4 @@ pub fn build(b: *std.Build) !void { } const version = manifest.version; -/// Needed until https://github.com/ziglang/zig/issues/22775 -/// is addressed. -const manifest: struct { - name: @Type(.enum_literal), - version: []const u8, - fingerprint: u64, - minimum_zig_version: []const u8, - dependencies: struct { - wayland: struct { - url: []const u8, - hash: []const u8, - }, - xkbcommon: struct { - url: []const u8, - hash: []const u8, - }, - kdl: struct { - url: []const u8, - hash: []const u8, - }, - known_folders: struct { - url: []const u8, - hash: []const u8, - }, - pixman: struct { - url: []const u8, - hash: []const u8, - }, - zigimg: struct { - url: []const u8, - hash: []const u8, - }, - fcft: struct { - url: []const u8, - hash: []const u8, - }, - zeit: struct { - url: []const u8, - hash: []const u8, - }, - }, - paths: []const []const u8, -} = @import("build.zig.zon"); +const manifest = @import("build.zig.zon"); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8bd9adf..177da68 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -126,10 +126,10 @@ do not re-trigger rules. ## Bar -The bar is an optional widget that shows the time on your screen. Right now, that's it. -It is only created when a `bar` block is present in the config. All settings have -defaults, with the color based on the Catppuccin Mocha theme. An empty block can be used -to enable the widget with all defaults: +The bar is an optional widget that displays configurable components in three +slots: left, center, and right. It is only created when a `bar` block is present in +the config. All settings have defaults, with the color based on the Catppuccin +Mocha theme. An empty block can be used to enable the widget with all defaults: ```kdl bar { @@ -140,10 +140,16 @@ bar { | Setting | Type | Default | Description | |--------------------|--------|--------------------|-----------------------------------| -| `fonts` | string | `monospace:size=14` | Comma-separated FontConfig fonts | -| `text_color` | color | `0xcdd6f4` | Text color | -| `background_color` | color | `0x1e1e2e` | Background color | -| `position` | enum | `top` | Bar position (`top` or `bottom`) | +| `fonts` | string | `monospace:size=14` | Comma-separated FontConfig fonts | +| `text_color` | color | `0xcdd6f4` | Text color | +| `background_color` | color | `0x1e1e2e` | Background color | +| `position` | enum | `top` | Bar position (`top` or `bottom`) | +| `left` | enum | `title` | Component in the left slot (`title`, `clock`, `wm_info`, `none`) | +| `center` | enum | `clock` | Component in the center slot (`title`, `clock`, `wm_info`, `none`) | +| `right` | enum | `wm_info` | Component in the right slot (`title`, `clock`, `wm_info`, `none`) | +| `vertical_padding` | u8 | `5` | Vertical padding above and below text | +| `horizontal_padding` | u8 | `5` | Horizontal padding between bar edges and text | +| `time_format` | string | `%Y-%m-%d %H:%M, %A` | strftime format string for the clock display | ### Margins diff --git a/docs/TODO.md b/docs/TODO.md index e47f7a0..44cb883 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,16 +2,14 @@ These are in rough order of my priority, though no promises I do them in this order. +- [ ] Support window tag/order caching between WM restarts (within a river session) +- [ ] Add gap support - [ ] Add build-time options for including the wallpaper (and maybe bar) - [ ] Check pointer position and only warp if not on focused window already - [ ] Change focus direction when closing window - [ ] Use set_xcursor_theme request -- [ ] Support configuring bar item positions (left/center/right) -- [ ] Add focused window title to bar - [ ] Support overriding config location - [ ] Add support for center-primary layout -- [ ] Add bar padding to config -- [ ] Support 12-hour clock format (maybe take any time format string?) - [ ] Support per-output bar visibility - [ ] Support more window rule options (e.g. ssd/csd) - [ ] Support solid `background-color` fallback (no wallpaper) @@ -49,3 +47,7 @@ These are in rough order of my priority, though no promises I do them in this or - [x] Move orphan handling out of .output and .seat events; into manage() - [x] Add config for single-window width ratio (mostly because my ultrawide makes a single window massive) - [x] Support configuring primary vs secondary stack side +- [x] Add focused window title to bar +- [x] Add bar padding to config +- [x] Support 12-hour clock format (maybe take any time format string?) +- [x] Support configuring bar item positions (left/center/right) diff --git a/examples/config.kdl b/examples/config.kdl index 8c43c34..2fa5222 100644 --- a/examples/config.kdl +++ b/examples/config.kdl @@ -28,6 +28,7 @@ borders { // Bar widget; shows the time bar { position top + time_format "%H:%M" } // Tag overlay widget; shown briefly when switching tags // Remove this block to disable the overlay entirely diff --git a/man/beansprout.5.scd b/man/beansprout.5.scd index 6bf9c58..020cafe 100644 --- a/man/beansprout.5.scd +++ b/man/beansprout.5.scd @@ -114,8 +114,9 @@ initialization do not re-trigger rules. # BAR -The bar is an optional widget that shows the time. It is only created when a -*bar* block is present: +The bar is an optional widget that displays configurable components in three +slots: left, center, and right. It is only created when a *bar* block is present +in the config: ``` bar { @@ -135,9 +136,30 @@ bar { *position* *top*|*bottom* Bar position. (Default: *top*) +*left* *title*|*clock*|*wm_info*|*none* + Component shown in the left slot. (Default: *title*) + +*center* *title*|*clock*|*wm_info*|*none* + Component shown in the center slot. (Default: *clock*) + +*right* *title*|*clock*|*wm_info*|*none* + Component shown in the right slot. (Default: *wm_info*) + +*vertical_padding* _pixels_ + Vertical padding above and below text. (Default: 5) + +*horizontal_padding* _pixels_ + Horizontal padding between bar edges and text. (Default: 5) + +*time_format* _format_ + strftime format string for the clock display. Invalid format strings + are ignored and the default is used instead. (Default: "%Y-%m-%d %H:%M, %A") + The bar also supports *margins* and *anchors* child blocks; see *TAG OVERLAY* for their format. +An empty block can be used to enable the widget with all defaults. + # TAG OVERLAY The tag overlay is an optional widget that briefly shows tag state when diff --git a/src/Bar.zig b/src/Bar.zig index 4935bed..b19bd3a 100644 --- a/src/Bar.zig +++ b/src/Bar.zig @@ -7,6 +7,9 @@ const Bar = @This(); /// Standard base DPI at a scale of 1 const base_dpi = 96; +/// Default strftime string to use for the clock +pub const default_time_format = "%Y-%m-%d %H:%M, %A"; + context: *Context, /// The timezone of the computer. @@ -43,6 +46,7 @@ pub const PendingRender = struct { }; pub const Position = enum { top, bottom }; +pub const Component = enum { title, clock, wm_info, none }; pub const Options = struct { /// Comma separated list of FontConfig formatted font specifications @@ -55,7 +59,22 @@ pub const Options = struct { /// Whether the bar is at the top or bottom of the screen position: Position = .top, /// Directional margins top, right, bottom, left, in pixels - margins: struct { top: i32 = 0, right: i32 = 0, bottom: i32 = 0, left: i32 = 0 } = .{}, + margins: struct { top: u8 = 0, right: u8 = 0, bottom: u8 = 0, left: u8 = 0 } = .{}, + + /// Vertical padding between bar edges and content, in pixels + vertical_padding: u8 = 5, + /// Horizontal padding between bar edges and content, in pixels + horizontal_padding: u8 = 5, + + /// strftime format string for the clock display + time_format: []const u8 = default_time_format, + + /// Which component to show on the left side of the bar + left: Component = .title, + /// Which component to show in the center of the bar + center: Component = .clock, + /// Which component to show on the right side of the bar + right: Component = .wm_info, }; pub fn init(context: *Context, output: *Output, options: Options) !Bar { @@ -98,12 +117,14 @@ pub fn init(context: *Context, output: *Output, options: Options) !Bar { .node = node, }, .configured = true, + .pending_manage = .{ .output_geometry = true }, }; } pub fn deinit(bar: *Bar) void { bar.configured = false; bar.timezone.deinit(); + bar.fcft_fonts.destroy(); if (bar.surfaces) |surfaces| { surfaces.node.destroy(); surfaces.river_shell_surface.destroy(); @@ -112,6 +133,16 @@ pub fn deinit(bar: *Bar) void { } } +/// Update bar options in-place without destroying/recreating Wayland surfaces +/// This is used when reloading the config +pub fn reconfigure(bar: *Bar, options: Options) !void { + const new_fonts = try getFcftFonts(options.fonts, bar.font_scale); + bar.fcft_fonts.destroy(); + bar.fcft_fonts = new_fonts; + bar.options = options; + bar.pending_manage.output_geometry = true; +} + pub fn manage(bar: *Bar) !void { if (!bar.configured) return; defer bar.pending_manage = .{}; @@ -131,10 +162,9 @@ pub fn manage(bar: *Bar) !void { bar.font_scale = scale; } - const vertical_padding = 5; const logical_font_height = @divFloor(bar.fcft_fonts.height, @as(i32, bar.font_scale)); - const height: u31 = @intCast(logical_font_height + 2 * vertical_padding); - const width: u31 = output.geometry.width; + const height: u31 = @intCast(logical_font_height + 2 * options.vertical_padding); + const width: u31 = output.geometry.width -| @as(u31, @intCast(options.margins.left + options.margins.right)); if (bar.geometry.width != width or bar.geometry.height != height) { bar.geometry.width = width; @@ -206,46 +236,90 @@ pub fn draw(bar: *Bar) !void { ); // Set-up text color - const text_color = options.text_color; - const color = pixman.Image.createSolidFill(&text_color) orelse return error.FailedToCreatePixmanImage; - defer _ = color.unref(); + const text_color = pixman.Image.createSolidFill(&options.text_color) orelse return error.FailedToCreatePixmanImage; + defer _ = text_color.unref(); - // Get the current time in seconds since the epoch, - // then load the local timezone, - // then convert `now` to the `local` timezone - const now = try zeit.instant(.{}); - const now_local = now.in(&bar.timezone); - - // Generate date/time info for this instant - const dt = now_local.time(); - - // Convert time to a string - var buf: [255:0]u8 = undefined; - var fbs = io.fixedBufferStream(&buf); - try dt.strftime(fbs.writer(), "%H:%M"); - - // Convert ASCII text string to unicode - // XXX: Not sure if this even needs to be converted to unicode - var codepoint_it = (try unicode.Utf8View.init(fbs.getWritten())).iterator(); - const codepoint_count = try unicode.utf8CountCodepoints(fbs.getWritten()); - // We use u32 for fcft even if zig uses u21 - var codepoints: []u32 = try utils.gpa.alloc(u32, codepoint_count); - defer utils.gpa.free(codepoints); - var i: usize = 0; - while (codepoint_it.nextCodepoint()) |cp| : (i += 1) { - codepoints[i] = cp; - } - - const text_width = try bar.textWidth(codepoints); - var x: i32 = @divFloor(buffer.width - text_width, 2); + // Y is shared between all components const y: i32 = @divFloor(buffer.height - bar.fcft_fonts.height, 2); - // Actually render the unicode codepoints - try bar.renderChars(codepoints, buffer, &x, y, color); + // Pre-compute codepoints for each component type - // Finally, attach the buffer to the surface + // Clock + var time_buf: [255:0]u8 = undefined; + var time_writer = Io.Writer.fixed(&time_buf); + const now = try zeit.instant(.{}); + const now_local = now.in(&bar.timezone); + try now_local.time().strftime(&time_writer, options.time_format); + const clock_codepoints = try utils.utf8ToCodepoints(time_writer.buffered()); + defer utils.gpa.free(clock_codepoints); + + // Title (empty string if no focused window) + const focused_title: []const u8 = if (context.wm.seats.first()) |seat| + if (seat.focused_window) |window| window.title orelse "" else "" + else + ""; + const title_codepoints = try utils.utf8ToCodepoints(focused_title); + defer utils.gpa.free(title_codepoints); + + // WM info + const output = bar.output; + var wm_info_buf: [255:0]u8 = undefined; + var wm_info_writer = Io.Writer.fixed(&wm_info_buf); + const ratio_percent: u32 = @intFromFloat(@round(output.primary_ratio * 100)); + try wm_info_writer.print("P:{d}/{d}% W:{d}({d})", .{ + output.primary_count, + ratio_percent, + output.countVisible(), + output.windows.length(), + }); + const wm_info_codepoints = try utils.utf8ToCodepoints(wm_info_writer.buffered()); + defer utils.gpa.free(wm_info_codepoints); + + // Map a Component to its pre-computed codepoints slice + const componentSlice = struct { + fn f(component: Component, clock: []u32, title: []u32, wm_info: []u32) []u32 { + return switch (component) { + .clock => clock, + .title => title, + .wm_info => wm_info, + .none => &.{}, + }; + } + }.f; + + // Measure center first — needed to constrain left and right widths + const center_codepoints = componentSlice(options.center, clock_codepoints, title_codepoints, wm_info_codepoints); + const center_width = try bar.textWidth(center_codepoints); + var center_x: i32 = @divFloor(buffer.width - center_width, 2); + + // Render left slot + const left_codepoints = componentSlice(options.left, clock_codepoints, title_codepoints, wm_info_codepoints); + if (left_codepoints.len > 0) { + const max_width = center_x - 2 * options.horizontal_padding; + const truncated = try bar.truncateToWidth(left_codepoints, max_width); + var x: i32 = options.horizontal_padding; + try bar.renderChars(truncated, buffer, &x, y, text_color); + } + + // Render right slot + const right_codepoints = componentSlice(options.right, clock_codepoints, title_codepoints, wm_info_codepoints); + if (right_codepoints.len > 0) { + const max_width = buffer.width - (center_x + center_width) - 2 * options.horizontal_padding; + const truncated = try bar.truncateToWidth(right_codepoints, max_width); + const text_width = try bar.textWidth(truncated); + var x: i32 = buffer.width - text_width - options.horizontal_padding; + try bar.renderChars(truncated, buffer, &x, y, text_color); + } + + // Render center slot + if (center_codepoints.len > 0) { + try bar.renderChars(center_codepoints, buffer, ¢er_x, y, text_color); + } + + // Attach the buffer to the surface const surfaces = bar.surfaces orelse return error.NoSurfaces; const wl_surface = surfaces.wl_surface; + // sync_next_commit ensures frame-perfect application surfaces.river_shell_surface.syncNextCommit(); wl_surface.setBufferScale(scale); wl_surface.attach(buffer.wl_buffer, 0, 0); @@ -253,7 +327,6 @@ pub fn draw(bar: *Bar) !void { wl_surface.commit(); } -// TODO: This should be moved to utils once fonts are in config /// Computes the pixel width of a text string. fn textWidth(bar: *Bar, text: []const u32) !i32 { var width: i32 = 0; @@ -270,6 +343,25 @@ fn textWidth(bar: *Bar, text: []const u32) !i32 { return width; } +/// Return the longest prefix of `text` that fits within `max_width` pixels. +fn truncateToWidth(bar: *Bar, text: []const u32, max_width: i32) ![]const u32 { + var width: i32 = 0; + for (text, 0..) |cp, i| { + const glyph = try bar.fcft_fonts.rasterizeCharUtf32(cp, .default); + if (i > 0) { + var x_kern: c_long = 0; + if (bar.fcft_fonts.kerning(text[i - 1], cp, &x_kern, null)) { + width += @intCast(x_kern); + } + } + if (width + glyph.advance.x > max_width) { + return text[0..i]; + } + width += glyph.advance.x; + } + return text; +} + // Borrowed and modified from https://git.sr.ht/~novakane/zig-fcft-example fn renderChars( bar: *Bar, @@ -373,7 +465,7 @@ fn getFcftFonts(fonts: []const u8, scale: u31) !*fcft.Font { log.debug("Scaling font DPI: base={d} scale={d}", .{ base_dpi, scale }); const scaled = try arena_alloc.dupeZ( u8, - try std.fmt.allocPrint(arena_alloc, "{s}:dpi={}", .{ font, @as(u32, base_dpi) * scale }), + try fmt.allocPrint(arena_alloc, "{s}:dpi={}", .{ font, @as(u32, base_dpi) * scale }), ); try list.append(arena_alloc, scaled); } else { @@ -390,10 +482,10 @@ fn getFcftFonts(fonts: []const u8, scale: u31) !*fcft.Font { const std = @import("std"); const assert = std.debug.assert; -const io = std.io; +const fmt = std.fmt; const mem = std.mem; const process = std.process; -const unicode = std.unicode; +const Io = std.Io; const wayland = @import("wayland"); const wl = wayland.client.wl; diff --git a/src/Context.zig b/src/Context.zig index 78f2e71..3cd35c0 100644 --- a/src/Context.zig +++ b/src/Context.zig @@ -200,23 +200,33 @@ pub fn manage(context: *Context) void { } } - // Recreate or destroy bars on all outputs + // Update, create, or destroy bars on all outputs const has_bar = new_config.bar_config != null; if (had_bar or has_bar) { var out_it = context.wm.outputs.iterator(.forward); while (out_it.next()) |output| { - // Destroy existing bar - if (output.bar) |*bar| { - bar.deinit(); - output.bar = null; - } - // Create new bar if configured if (new_config.bar_config) |bar_config| { - output.bar = Bar.init(context, output, bar_config.toBarOptions()) catch |e| { - log.err("Failed to create bar: {}", .{e}); - continue; - }; - output.bar.?.pending_manage.output_geometry = true; + if (output.bar) |*bar| { + // Existing bar; reconfigure in-place to keep surfaces + bar.reconfigure(bar_config.toBarOptions()) catch |e| { + log.err("Failed to reconfigure bar: {}", .{e}); + bar.deinit(); + output.bar = null; + continue; + }; + } else { + // No bar; we need to initialize a new one + output.bar = Bar.init(context, output, bar_config.toBarOptions()) catch |e| { + log.err("Failed to create bar: {}", .{e}); + continue; + }; + } + } else { + // New config doesn't have a bar; destroy existing one + if (output.bar) |*bar| { + bar.deinit(); + output.bar = null; + } } } } diff --git a/src/Output.zig b/src/Output.zig index 03c934f..2db197f 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -523,10 +523,16 @@ pub fn manage(output: *Output) void { Config.min_primary_ratio, Config.max_primary_ratio, ); + if (output.bar) |*bar| { + bar.pending_render.draw = true; + } } if (output.pending_manage.primary_count) |primary_count| { // Don't allow less than 1 primary output.primary_count = @max(1, primary_count); + if (output.bar) |*bar| { + bar.pending_render.draw = true; + } } if (output.pending_manage.single_window_ratio) |single_window_ratio| { output.single_window_ratio = std.math.clamp( @@ -554,6 +560,33 @@ pub fn manage(output: *Output) void { output.tags = new_tags; + // If the focused window is no longer visible on the new tags, update focus. + if (output.context.wm.seats.first()) |seat| { + if (seat.focused_output == output) { + // Whether focus has changed, either to a new window or to no focus + const should_update_focus = if (seat.focused_window) |w| + w.tags & new_tags == 0 + else + true; + if (should_update_focus) { + var new_focus: ?*Window = null; + var it = output.windows.iterator(.forward); + while (it.next()) |window| { + if (window.tags & new_tags != 0) { + new_focus = window; + break; + } + } + if (new_focus) |w| { + seat.pending_manage.window = .{ .window = w }; + seat.pending_manage.should_warp_pointer = true; + } else { + seat.pending_manage.window = .clear_focus; + } + } + } + } + // Show tag overlay and arm the hide timer if (output.tag_overlay) |*tag_overlay| { if (tag_overlay.surfaces) |_| { @@ -580,6 +613,10 @@ pub fn manage(output: *Output) void { }; } } + + if (output.bar) |*bar| { + bar.pending_render.draw = true; + } } if (output.bar) |*bar| { @@ -794,6 +831,15 @@ pub fn occupiedTags(output: *Output) u32 { return occupied_tags; } +pub fn countVisible(output: *Output) usize { + var visible: usize = 0; + var it = output.windows.iterator(.forward); + while (it.next()) |window| { + if (window.tags & output.tags != 0) visible += 1; + } + return visible; +} + const std = @import("std"); const assert = std.debug.assert; const mem = std.mem; diff --git a/src/Seat.zig b/src/Seat.zig index b24e050..012ab3c 100644 --- a/src/Seat.zig +++ b/src/Seat.zig @@ -137,9 +137,15 @@ pub fn manage(seat: *Seat) void { switch (pending_window) { .window => |window| { if (seat.focused_window) |focused| { - // Tell the previously focused Window that it's no longer focused if (focused != window) { + // Tell the previously focused Window that it's no longer focused focused.pending_render.focused = false; + // Update the Bar to have the newly-focused window's title + if (focused.output) |output| { + if (output.bar) |*bar| { + bar.pending_render.draw = true; + } + } } } seat.focused_window = window; diff --git a/src/Window.zig b/src/Window.zig index 2cbe4fa..7ddf85e 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -97,6 +97,9 @@ pub fn create(context: *Context, river_window_v1: *river.WindowV1, output: ?*Out .output = output, .tags = if (output) |o| o.tags else 0x0001, .link = undefined, // Handled by the wl.list + // Ensure borders are applied on the first render cycle, even for windows that + // are never explicitly told they are unfocused (e.g. on WM restart). + .pending_render = .{ .focused = false }, }; window.river_window_v1.setListener(*Window, windowListener, window); @@ -145,7 +148,7 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event, while (it.next()) |seat| { if (seat.focused_window == window) { // Find another window to focus and warp pointer there - if (output.prevWindow(window)) |next_focus| { + if (output.nextWindow(window)) |next_focus| { if (next_focus != window) { seat.pending_manage.window = .{ .window = next_focus }; seat.pending_manage.should_warp_pointer = true; @@ -188,6 +191,19 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event, utils.gpa.dupe(u8, std.mem.span(t)) catch @panic("Out of memory") else null; + + // Need to update the bar if this window is focused + if (window.context.wm.seats.first()) |seat| { + if (seat.focused_window) |focused_window| { + if (focused_window == window) { + if (window.output) |output| { + if (output.bar) |*bar| { + bar.pending_render.draw = true; + } + } + } + } + } }, .parent => |ev| { // Nothing to do if ev.parent is null diff --git a/src/config/BarConfig.zig b/src/config/BarConfig.zig index 5e3b23c..ea7179c 100644 --- a/src/config/BarConfig.zig +++ b/src/config/BarConfig.zig @@ -9,7 +9,13 @@ const NodeName = enum { text_color, background_color, position, + left, + center, + right, + vertical_padding, + horizontal_padding, margins, + time_format, }; const MarginsNodeName = enum { top, right, bottom, left }; @@ -18,11 +24,34 @@ const MarginsNodeName = enum { top, right, bottom, left }; fonts: ?[]const u8 = null, text_color: pixman.Color = utils.parseRgbaPixmanComptime("0xcdd6f4"), background_color: pixman.Color = utils.parseRgbaPixmanComptime("0x1e1e2e"), + +/// Whether the bar is at the top or bottom of the screen position: Bar.Position = .top, -margin_top: i32 = 0, -margin_right: i32 = 0, -margin_bottom: i32 = 0, -margin_left: i32 = 0, + +/// Which component to show on the left side of the bar +left: Bar.Component = .title, +/// Which component to show in the center of the bar +center: Bar.Component = .clock, +/// Which component to show on the right side of the bar +right: Bar.Component = .wm_info, + +/// Margin above the top of the bar and another element (a window or the top of the output) +margin_top: u8 = 0, +/// Margin above the right of the bar and another element (a window or the top of the output) +margin_right: u8 = 0, +/// Margin above bottom top of the bar and another element (a window or the top of the output) +margin_bottom: u8 = 0, +/// Margin above left top of the bar and another element (a window or the top of the output) +margin_left: u8 = 0, + +/// Vertical padding between bar edges and content, in pixels +vertical_padding: u8 = 5, +/// Horizontal padding between bar edges and content, in pixels +horizontal_padding: u8 = 5, + +/// strftime format string for the clock display. +/// null means use the default. +time_format: ?[]const u8 = null, pub fn toBarOptions(config: BarConfig) Bar.Options { return .{ @@ -30,12 +59,18 @@ pub fn toBarOptions(config: BarConfig) Bar.Options { .text_color = config.text_color, .background_color = config.background_color, .position = config.position, + .left = config.left, + .center = config.center, + .right = config.right, .margins = .{ .top = config.margin_top, .right = config.margin_right, .bottom = config.margin_bottom, .left = config.margin_left, }, + .vertical_padding = config.vertical_padding, + .horizontal_padding = config.horizontal_padding, + .time_format = config.time_format orelse Bar.default_time_format, }; } @@ -76,6 +111,18 @@ pub fn load(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void { logWarnInvalidNodeArg(name, val_str); } }, + .time_format => { + if (node.argcount() < 1) { + logWarnMissingNodeArg(name, "format string"); + continue; + } + if (validateTimeFormat(val_str)) { + config.bar_config.?.time_format = utils.gpa.dupe(u8, val_str) catch @panic("Out of memory"); + logDebugSettingNode(name, val_str); + } else { + logWarnInvalidNodeArg(name, val_str); + } + }, .margins => next_child_block = .margins, inline .background_color, .text_color, @@ -86,6 +133,25 @@ pub fn load(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void { }; logDebugSettingNode(name, val_str); }, + inline .vertical_padding, + .horizontal_padding, + => |tag| { + const padding_str = utils.stripQuotes(node.arg(parser, 0) orelse ""); + const padding = fmt.parseInt(u8, padding_str, 10) catch { + logWarnInvalidNodeArg(name, padding_str); + continue; + }; + @field(config.bar_config.?, @tagName(tag)) = padding; + logDebugSettingNode(name, padding_str); + }, + inline .left, .center, .right => |tag| { + if (std.meta.stringToEnum(Bar.Component, val_str)) |component| { + @field(config.bar_config.?, @tagName(tag)) = component; + logDebugSettingNode(name, val_str); + } else { + logWarnInvalidNodeArg(name, val_str); + } + }, } } else { helpers.logWarnInvalidNode(node.name); @@ -117,7 +183,7 @@ fn loadMarginsBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) continue; } const val_str = utils.stripQuotes(node.arg(parser, 0) orelse ""); - const val = fmt.parseInt(i32, val_str, 10) catch { + const val = fmt.parseInt(u8, val_str, 10) catch { logWarnInvalidNodeArg(name, val_str); continue; }; @@ -165,15 +231,26 @@ inline fn logWarnInvalidNodeArg(node_name: anytype, node_value: []const u8) void } } +fn validateTimeFormat(format: []const u8) bool { + // Try formatting with a dummy time to validate the format string + var buf: [255]u8 = undefined; + var writer = Io.Writer.fixed(&buf); + const dummy_time = zeit.Time{}; + dummy_time.strftime(&writer, format) catch return false; + return true; +} + inline fn logWarnMissingNodeArg(node_name: NodeName, comptime arg: []const u8) void { log.warn("\"bar.{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}); } const std = @import("std"); const fmt = std.fmt; +const Io = std.Io; const kdl = @import("kdl"); const pixman = @import("pixman"); +const zeit = @import("zeit"); const utils = @import("../utils.zig"); const Bar = @import("../Bar.zig"); diff --git a/src/main.zig b/src/main.zig index 4191091..ba9eceb 100644 --- a/src/main.zig +++ b/src/main.zig @@ -129,19 +129,17 @@ fn run(wl_display: *wl.Display, context: *Context) !void { fatal("wl_display flush failed: E{s}", .{@tagName(errno)}); } - // Get the number of milliseconds to the top of the next minute - const time = std.time.timestamp(); - if (time < 0) { - log.err("Got a negative time ({d})", .{time}); - return error.InvalidTime; - } - const timeout: i32 = @intCast((@divFloor(time, 60) * 60 + 60 - time) * 1000); + // Get the number of milliseconds to the top of the next second + const time_ns = std.time.nanoTimestamp(); + const ns_per_sec = std.time.ns_per_s; + const remainder_ns = @mod(time_ns, ns_per_sec); + const timeout: i32 = @intCast(@divFloor(ns_per_sec - remainder_ns, std.time.ns_per_ms)); const poll_rc = posix.poll(&pollfds, timeout) catch |err| { fatal("Failed to poll {s}", .{@errorName(err)}); }; if (poll_rc == 0) { - // If poll returns 0, it timed out, meaning we hit the top of the minute + // If poll returns 0, it timed out, meaning we hit the top of the next second // and need to update the clock. var it = context.wm.outputs.iterator(.forward); while (it.next()) |output| { diff --git a/src/utils.zig b/src/utils.zig index 094b494..0243443 100644 --- a/src/utils.zig +++ b/src/utils.zig @@ -193,6 +193,21 @@ pub fn stripQuotes(s: []const u8) []const u8 { return s; } +/// Convert a Utf-8 string into codepoints +/// Caller owns the returned slice and is responsible for freeing it. +pub fn utf8ToCodepoints(utf8: []const u8) ![]u32 { + var codepoint_it = (try unicode.Utf8View.init(utf8)).iterator(); + const codepoint_count = try unicode.utf8CountCodepoints(utf8); + // We use u32 for fcft even if zig uses u21 + const codepoints: []u32 = try gpa.alloc(u32, codepoint_count); + var i: usize = 0; + while (codepoint_it.nextCodepoint()) |cp| : (i += 1) { + codepoints[i] = cp; + } + + return codepoints; +} + /// Report that the given WaylandGlobal wasn't advertised and exit the program pub fn interfaceNotAdvertised(comptime WaylandGlobal: type) noreturn { fatal("{s} not advertised. Exiting", .{WaylandGlobal.interface.name}); @@ -207,6 +222,7 @@ const std = @import("std"); const fatal = std.process.fatal; const fmt = std.fmt; const mem = std.mem; +const unicode = std.unicode; const wayland = @import("wayland"); const river = wayland.client.river; @@ -447,3 +463,41 @@ test "tokenizeShell quotes mid-token" { try testing.expectEqual(1, result.len); try testing.expectEqualStrings("foobar bazqux", result[0]); } + +test "utf8ToCodepoints ASCII" { + const codepoints = try utf8ToCodepoints("hello"); + defer gpa.free(codepoints); + try testing.expectEqual(5, codepoints.len); + try testing.expectEqual('h', codepoints[0]); + try testing.expectEqual('e', codepoints[1]); + try testing.expectEqual('l', codepoints[2]); + try testing.expectEqual('l', codepoints[3]); + try testing.expectEqual('o', codepoints[4]); +} + +test "utf8ToCodepoints multi-byte" { + const codepoints = try utf8ToCodepoints("grüezi"); + defer gpa.free(codepoints); + try testing.expectEqual(6, codepoints.len); + try testing.expectEqual('g', codepoints[0]); + try testing.expectEqual('r', codepoints[1]); + try testing.expectEqual(0x00FC, codepoints[2]); // ü + try testing.expectEqual('e', codepoints[3]); + try testing.expectEqual('z', codepoints[4]); + try testing.expectEqual('i', codepoints[5]); +} + +test "utf8ToCodepoints empty" { + const codepoints = try utf8ToCodepoints(""); + defer gpa.free(codepoints); + try testing.expectEqual(0, codepoints.len); +} + +test "utf8ToCodepoints emoji" { + // 🇨🇦 is two regional indicator symbols: U+1F1E8 U+1F1E6 + const codepoints = try utf8ToCodepoints("🇨🇦"); + defer gpa.free(codepoints); + try testing.expectEqual(2, codepoints.len); + try testing.expectEqual(0x1F1E8, codepoints[0]); + try testing.expectEqual(0x1F1E6, codepoints[1]); +}